diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8a25793..3450209c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: matrix: java: [ 'openjdk@1.11' ] steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v2.3.5 with: fetch-depth: 100 - uses: jorelali/setup-elm@v3 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index adb18852..b2e6adcb 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,7 +6,7 @@ jobs: docker-images: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v2.3.5 with: fetch-depth: 0 - name: Set current version diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index e5f0f671..b20042d4 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -11,7 +11,7 @@ jobs: matrix: java: [ 'openjdk@1.11' ] steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v2.3.5 with: fetch-depth: 0 - uses: olafurpg/setup-scala@v13 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba3ff89c..51ca8f75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: matrix: java: [ 'openjdk@1.11' ] steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v2.3.5 with: fetch-depth: 0 - uses: olafurpg/setup-scala@v13 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index d7185b3c..774da824 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -7,7 +7,7 @@ jobs: publish-website: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v2.3.5 with: fetch-depth: 0 - name: Set current version diff --git a/.gitignore b/.gitignore index fa587c4d..1af7f1de 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ _site/ /website/site/templates/shortcodes/server.conf /website/site/templates/shortcodes/sample-exim.conf /website/site/templates/shortcodes/joex.conf +/website/site/templates/shortcodes/config.env.txt /docker/docs /docker/dev-log diff --git a/.scalafmt.conf b/.scalafmt.conf index 66fb20fd..28250946 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,7 +1,8 @@ -version = "3.0.4" +version = "3.0.6" preset = default align.preset = some +runner.dialect = scala213 maxColumn = 90 diff --git a/build.sbt b/build.sbt index bd15be3f..82ebe6c2 100644 --- a/build.sbt +++ b/build.sbt @@ -260,6 +260,18 @@ val openapiScalaSettings = Seq( .copy(typeDef = TypeDef("AccountSource", Imports("docspell.common.AccountSource")) ) + case "itemquery" => + field => + field + .copy(typeDef = + TypeDef( + "ItemQuery", + Imports( + "docspell.query.ItemQuery", + "docspell.restapi.codec.ItemQueryJson._" + ) + ) + ) }) ) @@ -275,15 +287,29 @@ val common = project .settings(testSettingsMUnit) .settings( name := "docspell-common", + addCompilerPlugin(Dependencies.kindProjectorPlugin), libraryDependencies ++= Dependencies.fs2 ++ Dependencies.circe ++ Dependencies.loggingApi ++ Dependencies.calevCore ++ - Dependencies.calevCirce ++ - Dependencies.pureconfig.map(_ % "optional") + Dependencies.calevCirce ) +val config = project + .in(file("modules/config")) + .disablePlugins(RevolverPlugin) + .settings(sharedSettings) + .settings(testSettingsMUnit) + .settings( + name := "docspell-config", + addCompilerPlugin(Dependencies.kindProjectorPlugin), + libraryDependencies ++= + Dependencies.fs2 ++ + Dependencies.pureconfig + ) + .dependsOn(common) + // Some example files for testing // https://file-examples.com/index.php/sample-documents-download/sample-doc-download/ val files = project @@ -366,6 +392,7 @@ val store = project .settings(testSettingsMUnit) .settings( name := "docspell-store", + addCompilerPlugin(Dependencies.kindProjectorPlugin), libraryDependencies ++= Dependencies.doobie ++ Dependencies.binny ++ @@ -409,7 +436,8 @@ val convert = project name := "docspell-convert", libraryDependencies ++= Dependencies.flexmark ++ - Dependencies.twelvemonkeys + Dependencies.twelvemonkeys ++ + Dependencies.pdfbox ) .dependsOn(common, files % "compile->compile;test->test") @@ -470,7 +498,7 @@ val restapi = project openapiSpec := (Compile / resourceDirectory).value / "docspell-openapi.yml", openapiStaticGen := OpenApiDocGenerator.Redoc ) - .dependsOn(common) + .dependsOn(common, query.jvm) val joexapi = project .in(file("modules/joexapi")) @@ -588,7 +616,17 @@ val joex = project ), Revolver.enableDebugging(port = 5051, suspend = false) ) - .dependsOn(store, backend, extract, convert, analysis, joexapi, restapi, ftssolr) + .dependsOn( + config, + store, + backend, + extract, + convert, + analysis, + joexapi, + restapi, + ftssolr + ) val restserver = project .in(file("modules/restserver")) @@ -651,7 +689,7 @@ val restserver = project } } ) - .dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc) + .dependsOn(config, restapi, joexapi, backend, webapp, ftssolr, oidc) // --- Website Documentation @@ -671,7 +709,6 @@ val website = project val templateOut = baseDirectory.value / "site" / "templates" / "shortcodes" val staticOut = baseDirectory.value / "site" / "static" / "openapi" IO.createDirectories(Seq(templateOut, staticOut)) - val logger = streams.value.log val files = Seq( (restserver / Compile / resourceDirectory).value / "reference.conf" -> templateOut / "server.conf", @@ -683,6 +720,17 @@ val website = project IO.copy(files) files.map(_._2) }.taskValue, + Compile / resourceGenerators += Def.task { + val templateOut = + baseDirectory.value / "site" / "templates" / "shortcodes" / "config.env.txt" + val files = List( + (restserver / Compile / resourceDirectory).value / "reference.conf", + (joex / Compile / resourceDirectory).value / "reference.conf" + ) + val cfg = EnvConfig.makeConfig(files) + EnvConfig.serializeTo(cfg, templateOut) + Seq(templateOut) + }.taskValue, Compile / resourceGenerators += Def.task { val changelog = (LocalRootProject / baseDirectory).value / "Changelog.md" val targetDir = baseDirectory.value / "site" / "content" / "docs" / "changelog" @@ -716,6 +764,7 @@ val root = project ) .aggregate( common, + config, extract, convert, analysis, diff --git a/docker/docker-compose/docker-compose.yml b/docker/docker-compose/docker-compose.yml index 18712b4c..84bf5979 100644 --- a/docker/docker-compose/docker-compose.yml +++ b/docker/docker-compose/docker-compose.yml @@ -19,6 +19,7 @@ services: image: docspell/joex:latest container_name: docspell-joex command: + - -J-Xmx3G - /opt/docspell.conf restart: unless-stopped env_file: ./.env @@ -50,7 +51,7 @@ services: - restserver db: - image: postgres:13.4 + image: postgres:14.0 container_name: postgres_db restart: unless-stopped volumes: diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index fd12ec40..4aea530f 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -48,6 +48,7 @@ trait BackendApp[F[_]] { def simpleSearch: OSimpleSearch[F] def clientSettings: OClientSettings[F] def totp: OTotp[F] + def share: OShare[F] } object BackendApp { @@ -85,6 +86,9 @@ object BackendApp { customFieldsImpl <- OCustomFields(store) simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) clientSettingsImpl <- OClientSettings(store) + shareImpl <- Resource.pure( + OShare(store, itemSearchImpl, simpleSearchImpl, javaEmil) + ) } yield new BackendApp[F] { val login = loginImpl val signup = signupImpl @@ -107,16 +111,16 @@ object BackendApp { val simpleSearch = simpleSearchImpl val clientSettings = clientSettingsImpl val totp = totpImpl + val share = shareImpl } def apply[F[_]: Async]( cfg: Config, - connectEC: ExecutionContext, - httpClientEc: ExecutionContext + connectEC: ExecutionContext )(ftsFactory: Client[F] => Resource[F, FtsClient[F]]): Resource[F, BackendApp[F]] = for { store <- Store.create(cfg.jdbc, cfg.files.chunkSize, connectEC) - httpClient <- BlazeClientBuilder[F](httpClientEc).resource + httpClient <- BlazeClientBuilder[F].resource ftsClient <- ftsFactory(httpClient) backend <- create(cfg, store, httpClient, ftsClient) } yield backend diff --git a/modules/backend/src/main/scala/docspell/backend/auth/ShareToken.scala b/modules/backend/src/main/scala/docspell/backend/auth/ShareToken.scala new file mode 100644 index 00000000..c26124d6 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/auth/ShareToken.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.auth + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.Common +import docspell.common.{Ident, Timestamp} + +import scodec.bits.ByteVector + +/** Can be used as an authenticator to access data behind a share. */ +final case class ShareToken(created: Timestamp, id: Ident, salt: String, sig: String) { + def asString = s"${created.toMillis}-${TokenUtil.b64enc(id.id)}-$salt-$sig" + + def sigValid(key: ByteVector): Boolean = { + val newSig = TokenUtil.sign(this, key) + TokenUtil.constTimeEq(sig, newSig) + } + def sigInvalid(key: ByteVector): Boolean = + !sigValid(key) +} + +object ShareToken { + + def fromString(s: String): Either[String, ShareToken] = + s.split("-", 4) match { + case Array(ms, id, salt, sig) => + for { + created <- ms.toLongOption.toRight("Invalid timestamp") + idStr <- TokenUtil.b64dec(id).toRight("Cannot read authenticator data") + shareId <- Ident.fromString(idStr) + } yield ShareToken(Timestamp.ofMillis(created), shareId, salt, sig) + + case _ => + Left("Invalid authenticator") + } + + def create[F[_]: Sync](shareId: Ident, key: ByteVector): F[ShareToken] = + for { + now <- Timestamp.current[F] + salt <- Common.genSaltString[F] + cd = ShareToken(now, shareId, salt, "") + sig = TokenUtil.sign(cd, key) + } yield cd.copy(sig = sig) + +} diff --git a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala index 7958ed0a..9bba4823 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/TokenUtil.scala @@ -18,17 +18,24 @@ private[auth] object TokenUtil { def sign(cd: RememberToken, key: ByteVector): String = { val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt - val mac = Mac.getInstance("HmacSHA1") - mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) - ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 + signRaw(raw, key) } def sign(cd: AuthToken, key: ByteVector): String = { val raw = cd.nowMillis.toString + cd.account.asString + cd.requireSecondFactor + cd.salt + signRaw(raw, key) + } + + def sign(sd: ShareToken, key: ByteVector): String = { + val raw = s"${sd.created.toMillis}${sd.id.id}${sd.salt}" + signRaw(raw, key) + } + + private def signRaw(data: String, key: ByteVector): String = { val mac = Mac.getInstance("HmacSHA1") mac.init(new SecretKeySpec(key.toArray, "HmacSHA1")) - ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64 + ByteVector.view(mac.doFinal(data.getBytes(utf8))).toBase64 } def b64enc(s: String): String = diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 1b2825fe..907bfcef 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -63,6 +63,12 @@ trait OCollective[F[_]] { def findEnabledSource(sourceId: Ident): F[Option[RSource]] + def addPassword(collective: Ident, pw: Password): F[Unit] + + def getPasswords(collective: Ident): F[List[RCollectivePassword]] + + def removePassword(id: Ident): F[Unit] + def startLearnClassifier(collective: Ident): F[Unit] def startEmptyTrash(args: EmptyTrashArgs): F[Unit] @@ -149,7 +155,7 @@ object OCollective { private def updateLearnClassifierTask(coll: Ident, sett: Settings): F[Unit] = for { id <- Ident.randomId[F] - on = sett.classifier.map(_.enabled).getOrElse(false) + on = sett.classifier.exists(_.enabled) timer = sett.classifier.map(_.schedule).getOrElse(CalEvent.unsafe("")) args = LearnClassifierArgs(coll) ut = UserTask( @@ -174,6 +180,18 @@ object OCollective { _ <- joex.notifyAllNodes } yield () + def addPassword(collective: Ident, pw: Password): F[Unit] = + for { + cpass <- RCollectivePassword.createNew[F](collective, pw) + _ <- store.transact(RCollectivePassword.upsert(cpass)) + } yield () + + def getPasswords(collective: Ident): F[List[RCollectivePassword]] = + store.transact(RCollectivePassword.findAll(collective)) + + def removePassword(id: Ident): F[Unit] = + store.transact(RCollectivePassword.deleteById(id)).map(_ => ()) + def startLearnClassifier(collective: Ident): F[Unit] = for { id <- Ident.randomId[F] diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OJoex.scala b/modules/backend/src/main/scala/docspell/backend/ops/OJoex.scala index fb35267c..d4aaf956 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OJoex.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OJoex.scala @@ -6,8 +6,6 @@ package docspell.backend.ops -import scala.concurrent.ExecutionContext - import cats.data.OptionT import cats.effect._ import cats.implicits._ @@ -42,10 +40,7 @@ object OJoex { } yield cancel.success).getOrElse(false) }) - def create[F[_]: Async]( - ec: ExecutionContext, - store: Store[F] - ): Resource[F, OJoex[F]] = - JoexClient.resource(ec).flatMap(client => apply(client, store)) + def create[F[_]: Async](store: Store[F]): Resource[F, OJoex[F]] = + JoexClient.resource.flatMap(client => apply(client, store)) } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala index 8d9debfe..368477d0 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -51,6 +51,22 @@ trait OMail[F[_]] { } object OMail { + sealed trait SendResult + + object SendResult { + + /** Mail was successfully sent and stored to db. */ + case class Success(id: Ident) extends SendResult + + /** There was a failure sending the mail. The mail is then not saved to db. */ + case class SendFailure(ex: Throwable) extends SendResult + + /** The mail was successfully sent, but storing to db failed. */ + case class StoreFailure(ex: Throwable) extends SendResult + + /** Something could not be found required for sending (mail configs, items etc). */ + case object NotFound extends SendResult + } case class Sent( id: Ident, diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala new file mode 100644 index 00000000..ba27ea70 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala @@ -0,0 +1,381 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.backend.ops + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.backend.PasswordCrypt +import docspell.backend.auth.ShareToken +import docspell.backend.ops.OItemSearch._ +import docspell.backend.ops.OShare._ +import docspell.backend.ops.OSimpleSearch.StringSearchResult +import docspell.common._ +import docspell.query.ItemQuery +import docspell.query.ItemQuery.Expr +import docspell.query.ItemQuery.Expr.AttachId +import docspell.store.Store +import docspell.store.queries.SearchSummary +import docspell.store.records._ + +import emil._ +import scodec.bits.ByteVector + +trait OShare[F[_]] { + + def findAll( + collective: Ident, + ownerLogin: Option[Ident], + query: Option[String] + ): F[List[ShareData]] + + def delete(id: Ident, collective: Ident): F[Boolean] + + def addNew(share: OShare.NewShare): F[OShare.ChangeResult] + + def findOne(id: Ident, collective: Ident): OptionT[F, ShareData] + + def update( + id: Ident, + share: OShare.NewShare, + removePassword: Boolean + ): F[OShare.ChangeResult] + + // --- + + /** Verifies the given id and password and returns a authorization token on success. */ + def verify(key: ByteVector)(id: Ident, password: Option[Password]): F[VerifyResult] + + /** Verifies the authorization token. */ + def verifyToken(key: ByteVector)(token: String): F[VerifyResult] + + def findShareQuery(id: Ident): OptionT[F, ShareQuery] + + def findAttachmentPreview( + attachId: Ident, + shareId: Ident + ): OptionT[F, AttachmentPreviewData[F]] + + def findAttachment(attachId: Ident, shareId: Ident): OptionT[F, AttachmentData[F]] + + def findItem(itemId: Ident, shareId: Ident): OptionT[F, ItemData] + + def searchSummary( + settings: OSimpleSearch.StatsSettings + )(shareId: Ident, q: ItemQueryString): OptionT[F, StringSearchResult[SearchSummary]] + + def sendMail(account: AccountId, connection: Ident, mail: ShareMail): F[SendResult] +} + +object OShare { + final case class ShareMail( + shareId: Ident, + subject: String, + recipients: List[MailAddress], + cc: List[MailAddress], + bcc: List[MailAddress], + body: String + ) + + sealed trait SendResult + object SendResult { + + /** Mail was successfully sent and stored to db. */ + case class Success(msgId: String) extends SendResult + + /** There was a failure sending the mail. The mail is then not saved to db. */ + case class SendFailure(ex: Throwable) extends SendResult + + /** Something could not be found required for sending (mail configs, items etc). */ + case object NotFound extends SendResult + } + + final case class ShareQuery(id: Ident, account: AccountId, query: ItemQuery) + + sealed trait VerifyResult { + def toEither: Either[String, ShareToken] = + this match { + case VerifyResult.Success(token, _) => + Right(token) + case _ => Left("Authentication failed.") + } + } + object VerifyResult { + case class Success(token: ShareToken, shareName: Option[String]) extends VerifyResult + case object NotFound extends VerifyResult + case object PasswordMismatch extends VerifyResult + case object InvalidToken extends VerifyResult + + def success(token: ShareToken): VerifyResult = Success(token, None) + def success(token: ShareToken, name: Option[String]): VerifyResult = + Success(token, name) + def notFound: VerifyResult = NotFound + def passwordMismatch: VerifyResult = PasswordMismatch + def invalidToken: VerifyResult = InvalidToken + } + + final case class NewShare( + account: AccountId, + name: Option[String], + query: ItemQuery, + enabled: Boolean, + password: Option[Password], + publishUntil: Timestamp + ) + + sealed trait ChangeResult + object ChangeResult { + final case class Success(id: Ident) extends ChangeResult + case object PublishUntilInPast extends ChangeResult + case object NotFound extends ChangeResult + + def success(id: Ident): ChangeResult = Success(id) + def publishUntilInPast: ChangeResult = PublishUntilInPast + def notFound: ChangeResult = NotFound + } + + final case class ShareData(share: RShare, user: RUser) + + def apply[F[_]: Async]( + store: Store[F], + itemSearch: OItemSearch[F], + simpleSearch: OSimpleSearch[F], + emil: Emil[F] + ): OShare[F] = + new OShare[F] { + private[this] val logger = Logger.log4s[F](org.log4s.getLogger) + + def findAll( + collective: Ident, + ownerLogin: Option[Ident], + query: Option[String] + ): F[List[ShareData]] = + store + .transact(RShare.findAllByCollective(collective, ownerLogin, query)) + .map(_.map(ShareData.tupled)) + + def delete(id: Ident, collective: Ident): F[Boolean] = + store.transact(RShare.deleteByIdAndCid(id, collective)).map(_ > 0) + + def addNew(share: NewShare): F[ChangeResult] = + for { + curTime <- Timestamp.current[F] + id <- Ident.randomId[F] + user <- store.transact(RUser.findByAccount(share.account)) + pass = share.password.map(PasswordCrypt.crypt) + record = RShare( + id, + user.map(_.uid).getOrElse(Ident.unsafe("-error-no-user-")), + share.name, + share.query, + share.enabled, + pass, + curTime, + share.publishUntil, + 0, + None + ) + res <- + if (share.publishUntil < curTime) ChangeResult.publishUntilInPast.pure[F] + else store.transact(RShare.insert(record)).map(_ => ChangeResult.success(id)) + } yield res + + def update( + id: Ident, + share: OShare.NewShare, + removePassword: Boolean + ): F[ChangeResult] = + for { + curTime <- Timestamp.current[F] + user <- store.transact(RUser.findByAccount(share.account)) + record = RShare( + id, + user.map(_.uid).getOrElse(Ident.unsafe("-error-no-user-")), + share.name, + share.query, + share.enabled, + share.password.map(PasswordCrypt.crypt), + Timestamp.Epoch, + share.publishUntil, + 0, + None + ) + res <- + if (share.publishUntil < curTime) ChangeResult.publishUntilInPast.pure[F] + else + store + .transact(RShare.updateData(record, removePassword)) + .map(n => if (n > 0) ChangeResult.success(id) else ChangeResult.notFound) + } yield res + + def findOne(id: Ident, collective: Ident): OptionT[F, ShareData] = + RShare + .findOne(id, collective) + .mapK(store.transform) + .map(ShareData.tupled) + + def verify( + key: ByteVector + )(id: Ident, password: Option[Password]): F[VerifyResult] = + RShare + .findCurrentActive(id) + .mapK(store.transform) + .semiflatMap { case (share, _) => + val pwCheck = + share.password.map(encPw => password.exists(PasswordCrypt.check(_, encPw))) + + // add the password (if existing) to the server secret key; this way the token + // invalidates when the user changes the password + val shareKey = + share.password.map(pw => key ++ pw.asByteVector).getOrElse(key) + + val token = ShareToken + .create(id, shareKey) + .flatTap(_ => store.transact(RShare.incAccess(share.id))) + pwCheck match { + case Some(true) => token.map(t => VerifyResult.success(t, share.name)) + case None => token.map(t => VerifyResult.success(t, share.name)) + case Some(false) => VerifyResult.passwordMismatch.pure[F] + } + } + .getOrElse(VerifyResult.notFound) + + def verifyToken(key: ByteVector)(token: String): F[VerifyResult] = + ShareToken.fromString(token) match { + case Right(st) => + RShare + .findActivePassword(st.id) + .mapK(store.transform) + .semiflatMap { password => + val shareKey = + password.map(pw => key ++ pw.asByteVector).getOrElse(key) + if (st.sigValid(shareKey)) VerifyResult.success(st).pure[F] + else + logger.info( + s"Signature failure for share: ${st.id.id}" + ) *> VerifyResult.invalidToken.pure[F] + } + .getOrElse(VerifyResult.notFound) + + case Left(err) => + logger.debug(s"Invalid session token: $err") *> + VerifyResult.invalidToken.pure[F] + } + + def findShareQuery(id: Ident): OptionT[F, ShareQuery] = + RShare + .findCurrentActive(id) + .mapK(store.transform) + .map { case (share, user) => + ShareQuery(share.id, user.accountId, share.query) + } + + def findAttachmentPreview( + attachId: Ident, + shareId: Ident + ): OptionT[F, AttachmentPreviewData[F]] = + for { + sq <- findShareQuery(shareId) + _ <- checkAttachment(sq, AttachId(attachId.id)) + res <- OptionT( + itemSearch.findAttachmentPreview(attachId, sq.account.collective) + ) + } yield res + + def findAttachment(attachId: Ident, shareId: Ident): OptionT[F, AttachmentData[F]] = + for { + sq <- findShareQuery(shareId) + _ <- checkAttachment(sq, AttachId(attachId.id)) + res <- OptionT(itemSearch.findAttachment(attachId, sq.account.collective)) + } yield res + + def findItem(itemId: Ident, shareId: Ident): OptionT[F, ItemData] = + for { + sq <- findShareQuery(shareId) + _ <- checkAttachment(sq, Expr.itemIdEq(itemId.id)) + res <- OptionT(itemSearch.findItem(itemId, sq.account.collective)) + } yield res + + /** Check whether the attachment with the given id is in the results of the given + * share + */ + private def checkAttachment(sq: ShareQuery, idExpr: Expr): OptionT[F, Unit] = { + val checkQuery = Query( + Query.Fix(sq.account, Some(sq.query.expr), None), + Query.QueryExpr(idExpr) + ) + OptionT( + itemSearch + .findItems(0)(checkQuery, Batch.limit(1)) + .map(_.headOption.map(_ => ())) + ).flatTapNone( + logger.info( + s"Attempt to load unshared data '$idExpr' via share: ${sq.id.id}" + ) + ) + } + + def searchSummary( + settings: OSimpleSearch.StatsSettings + )( + shareId: Ident, + q: ItemQueryString + ): OptionT[F, StringSearchResult[SearchSummary]] = + findShareQuery(shareId) + .semiflatMap { share => + val fix = Query.Fix(share.account, Some(share.query.expr), None) + simpleSearch + .searchSummaryByString(settings)(fix, q) + .map { + case StringSearchResult.Success(summary) => + StringSearchResult.Success(summary.onlyExisting) + case other => other + } + } + + def sendMail( + account: AccountId, + connection: Ident, + mail: ShareMail + ): F[SendResult] = { + val getSmtpSettings: OptionT[F, RUserEmail] = + OptionT(store.transact(RUserEmail.getByName(account, connection))) + + def createMail(sett: RUserEmail): OptionT[F, Mail[F]] = { + import _root_.emil.builder._ + + OptionT.pure( + MailBuilder.build( + From(sett.mailFrom), + Tos(mail.recipients), + Ccs(mail.cc), + Bccs(mail.bcc), + XMailer.emil, + Subject(mail.subject), + TextBody[F](mail.body) + ) + ) + } + + def sendMail(cfg: MailConfig, mail: Mail[F]): F[Either[SendResult, String]] = + emil(cfg).send(mail).map(_.head).attempt.map(_.left.map(SendResult.SendFailure)) + + (for { + _ <- RShare + .findCurrentActive(mail.shareId) + .filter(_._2.cid == account.collective) + .mapK(store.transform) + mailCfg <- getSmtpSettings + mail <- createMail(mailCfg) + mid <- OptionT.liftF(sendMail(mailCfg.toMailConfig, mail)) + conv = mid.fold(identity, id => SendResult.Success(id)) + } yield conv).getOrElse(SendResult.NotFound) + } + + } +} diff --git a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala deleted file mode 100644 index 97feed6b..00000000 --- a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 Eike K. & Contributors - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package docspell.backend.ops - -import docspell.common._ - -sealed trait SendResult - -object SendResult { - - /** Mail was successfully sent and stored to db. */ - case class Success(id: Ident) extends SendResult - - /** There was a failure sending the mail. The mail is then not saved to db. */ - case class SendFailure(ex: Throwable) extends SendResult - - /** The mail was successfully sent, but storing to db failed. */ - case class StoreFailure(ex: Throwable) extends SendResult - - /** Something could not be found required for sending (mail configs, items etc). */ - case object NotFound extends SendResult -} diff --git a/modules/common/src/main/scala/docspell/common/AccountId.scala b/modules/common/src/main/scala/docspell/common/AccountId.scala index bd8f1fb1..e8479966 100644 --- a/modules/common/src/main/scala/docspell/common/AccountId.scala +++ b/modules/common/src/main/scala/docspell/common/AccountId.scala @@ -8,6 +8,7 @@ package docspell.common import io.circe._ +/** The collective and user name. */ case class AccountId(collective: Ident, user: Ident) { def asString = if (collective == user) user.id diff --git a/modules/common/src/main/scala/docspell/common/Logger.scala b/modules/common/src/main/scala/docspell/common/Logger.scala index df1dba26..936c9d34 100644 --- a/modules/common/src/main/scala/docspell/common/Logger.scala +++ b/modules/common/src/main/scala/docspell/common/Logger.scala @@ -7,12 +7,13 @@ package docspell.common import cats.effect.Sync +import fs2.Stream import docspell.common.syntax.all._ import org.log4s.{Logger => Log4sLogger} -trait Logger[F[_]] { +trait Logger[F[_]] { self => def trace(msg: => String): F[Unit] def debug(msg: => String): F[Unit] @@ -21,6 +22,25 @@ trait Logger[F[_]] { def error(ex: Throwable)(msg: => String): F[Unit] def error(msg: => String): F[Unit] + final def s: Logger[Stream[F, *]] = new Logger[Stream[F, *]] { + def trace(msg: => String): Stream[F, Unit] = + Stream.eval(self.trace(msg)) + + def debug(msg: => String): Stream[F, Unit] = + Stream.eval(self.debug(msg)) + + def info(msg: => String): Stream[F, Unit] = + Stream.eval(self.info(msg)) + + def warn(msg: => String): Stream[F, Unit] = + Stream.eval(self.warn(msg)) + + def error(msg: => String): Stream[F, Unit] = + Stream.eval(self.error(msg)) + + def error(ex: Throwable)(msg: => String): Stream[F, Unit] = + Stream.eval(self.error(ex)(msg)) + } } object Logger { diff --git a/modules/common/src/main/scala/docspell/common/Password.scala b/modules/common/src/main/scala/docspell/common/Password.scala index da83364c..7c2daeb0 100644 --- a/modules/common/src/main/scala/docspell/common/Password.scala +++ b/modules/common/src/main/scala/docspell/common/Password.scala @@ -6,18 +6,29 @@ package docspell.common +import java.nio.charset.StandardCharsets + import cats.effect.Sync import cats.implicits._ import io.circe.{Decoder, Encoder} +import scodec.bits.ByteVector final class Password(val pass: String) extends AnyVal { def isEmpty: Boolean = pass.isEmpty + def nonEmpty: Boolean = pass.nonEmpty + def length: Int = pass.length + + def asByteVector: ByteVector = + ByteVector.view(pass.getBytes(StandardCharsets.UTF_8)) override def toString: String = if (pass.isEmpty) "" else "***" + def compare(other: Password): Boolean = + this.pass.zip(other.pass).forall { case (a, b) => a == b } && + this.nonEmpty && this.length == other.length } object Password { diff --git a/modules/common/src/main/scala/docspell/common/Pools.scala b/modules/common/src/main/scala/docspell/common/Pools.scala index 20d2771c..a704bdbb 100644 --- a/modules/common/src/main/scala/docspell/common/Pools.scala +++ b/modules/common/src/main/scala/docspell/common/Pools.scala @@ -10,7 +10,7 @@ import scala.concurrent.ExecutionContext /** Captures thread pools to use in an application. */ case class Pools( - connectEC: ExecutionContext, - httpClientEC: ExecutionContext, - restEC: ExecutionContext + connectEC: ExecutionContext +// httpClientEC: ExecutionContext, +// restEC: ExecutionContext ) diff --git a/modules/common/src/main/scala/docspell/common/Timestamp.scala b/modules/common/src/main/scala/docspell/common/Timestamp.scala index d55e5fc4..c056c2a8 100644 --- a/modules/common/src/main/scala/docspell/common/Timestamp.scala +++ b/modules/common/src/main/scala/docspell/common/Timestamp.scala @@ -51,6 +51,9 @@ case class Timestamp(value: Instant) { def <(other: Timestamp): Boolean = this.value.isBefore(other.value) + + def >(other: Timestamp): Boolean = + this.value.isAfter(other.value) } object Timestamp { @@ -67,6 +70,9 @@ object Timestamp { def atUtc(ldt: LocalDateTime): Timestamp = from(ldt.atZone(UTC)) + def ofMillis(ms: Long): Timestamp = + Timestamp(Instant.ofEpochMilli(ms)) + def daysBetween(ts0: Timestamp, ts1: Timestamp): Long = ChronoUnit.DAYS.between(ts0.toUtcDate, ts1.toUtcDate) diff --git a/modules/config/src/main/scala/docspell/config/ConfigFactory.scala b/modules/config/src/main/scala/docspell/config/ConfigFactory.scala new file mode 100644 index 00000000..27661f99 --- /dev/null +++ b/modules/config/src/main/scala/docspell/config/ConfigFactory.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.config + +import scala.reflect.ClassTag + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ +import fs2.io.file.{Files, Path} + +import docspell.common.Logger + +import pureconfig.{ConfigReader, ConfigSource} + +object ConfigFactory { + + /** Reads the configuration trying the following in order: + * 1. if 'args' contains at least one element, the first is interpreted as a config + * file + * 1. otherwise check the system property 'config.file' for an existing file and use + * it if it does exist; ignore if it doesn't exist + * 1. if no file is found, read the config from environment variables falling back to + * the default config + */ + def default[F[_]: Async, C: ClassTag: ConfigReader](logger: Logger[F], atPath: String)( + args: List[String] + ): F[C] = + findFileFromArgs(args).flatMap { + case Some(file) => + logger.info(s"Using config file: $file") *> + readFile[F, C](file, atPath) + case None => + checkSystemProperty.value.flatMap { + case Some(file) => + logger.info(s"Using config file from system property: $file") *> + readConfig(atPath) + case None => + logger.info("Using config from environment variables!") *> + readEnv(atPath) + } + } + + /** Reads the configuration from the given file. */ + private def readFile[F[_]: Sync, C: ClassTag: ConfigReader]( + file: Path, + at: String + ): F[C] = + Sync[F].delay { + System.setProperty( + "config.file", + file.toNioPath.toAbsolutePath.normalize.toString + ) + ConfigSource.default.at(at).loadOrThrow[C] + } + + /** Reads the config as specified in typesafe's config library; usually loading the file + * given as system property 'config.file'. + */ + private def readConfig[F[_]: Sync, C: ClassTag: ConfigReader]( + at: String + ): F[C] = + Sync[F].delay(ConfigSource.default.at(at).loadOrThrow[C]) + + /** Reads the configuration from environment variables. */ + private def readEnv[F[_]: Sync, C: ClassTag: ConfigReader](at: String): F[C] = + Sync[F].delay(ConfigSource.fromConfig(EnvConfig.get).at(at).loadOrThrow[C]) + + /** Uses the first argument as a path to the config file. If it is specified but the + * file doesn't exist, an exception is thrown. + */ + private def findFileFromArgs[F[_]: Async](args: List[String]): F[Option[Path]] = + args.headOption + .map(Path.apply) + .traverse(p => + Files[F].exists(p).flatMap { + case true => p.pure[F] + case false => Async[F].raiseError(new Exception(s"File not found: $p")) + } + ) + + /** If the system property 'config.file' is set, it is checked whether the file exists. + * If it doesn't exist, the property is removed to not raise any exception. In contrast + * to giving the file as argument, it is not an error to specify a non-existing file + * via a system property. + */ + private def checkSystemProperty[F[_]: Async]: OptionT[F, Path] = + for { + cf <- OptionT( + Sync[F].delay( + Option(System.getProperty("config.file")).map(_.trim).filter(_.nonEmpty) + ) + ) + cp = Path(cf) + exists <- OptionT.liftF(Files[F].exists(cp)) + file <- + if (exists) OptionT.pure[F](cp) + else + OptionT + .liftF(Sync[F].delay(System.clearProperty("config.file"))) + .flatMap(_ => OptionT.none[F, Path]) + } yield file + +} diff --git a/modules/config/src/main/scala/docspell/config/EnvConfig.scala b/modules/config/src/main/scala/docspell/config/EnvConfig.scala new file mode 100644 index 00000000..37e05596 --- /dev/null +++ b/modules/config/src/main/scala/docspell/config/EnvConfig.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.config + +import java.util.Properties + +import scala.collection.{MapView, mutable} +import scala.jdk.CollectionConverters._ + +import com.typesafe.config.{Config, ConfigFactory} + +/** Creates a config from environment variables. + * + * The env variables are expected to be in same form as the config keys with the + * following mangling: a dot is replaced by an underscore character, because this is the + * standard separator for env variables. In order to represent dashes, two underscores + * are needed (and for one underscore use three underscores in the env variable). + * + * For example, the config key + * {{{ + * docspell.server.app-name + * }}} + * can be given as env variable + * {{{ + * DOCSPELL_SERVER_APP__NAME + * }}} + */ +object EnvConfig { + + /** The config from current environment. */ + lazy val get: Config = + loadFrom(System.getenv().asScala.view) + + def loadFrom(env: MapView[String, String]): Config = { + val cfg = new Properties() + for (key <- env.keySet if key.startsWith("DOCSPELL_")) + cfg.setProperty(envToProp(key), env(key)) + + ConfigFactory + .parseProperties(cfg) + .withFallback(ConfigFactory.defaultReference()) + .resolve() + } + + /** Docspell has all lowercase key names and uses snake case. + * + * So this converts to lowercase and then replaces underscores (like + * [[com.typesafe.config.ConfigFactory.systemEnvironmentOverrides()]] + * + * - 3 underscores -> `_` (underscore) + * - 2 underscores -> `-` (dash) + * - 1 underscore -> `.` (dot) + */ + private[config] def envToProp(v: String): String = { + val len = v.length + val buffer = new mutable.StringBuilder() + val underscoreMapping = Map(3 -> '_', 2 -> '-', 1 -> '.').withDefault(_ => '_') + @annotation.tailrec + def go(current: Int, underscores: Int): String = + if (current >= len) buffer.toString() + else + v.charAt(current) match { + case '_' => go(current + 1, underscores + 1) + case c => + if (underscores > 0) { + buffer.append(underscoreMapping(underscores)) + } + buffer.append(c.toLower) + go(current + 1, 0) + } + + go(0, 0) + } +} diff --git a/modules/common/src/main/scala/docspell/common/config/Implicits.scala b/modules/config/src/main/scala/docspell/config/Implicits.scala similarity index 97% rename from modules/common/src/main/scala/docspell/common/config/Implicits.scala rename to modules/config/src/main/scala/docspell/config/Implicits.scala index 81ff0a4c..77136cbc 100644 --- a/modules/common/src/main/scala/docspell/common/config/Implicits.scala +++ b/modules/config/src/main/scala/docspell/config/Implicits.scala @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package docspell.common.config +package docspell.config import java.nio.file.{Path => JPath} @@ -15,12 +15,11 @@ import fs2.io.file.Path import docspell.common._ import com.github.eikek.calev.CalEvent -import pureconfig._ +import pureconfig.ConfigReader import pureconfig.error.{CannotConvert, FailureReason} import scodec.bits.ByteVector object Implicits { - implicit val accountIdReader: ConfigReader[AccountId] = ConfigReader[String].emap(reason(AccountId.parse)) diff --git a/modules/config/src/test/resources/reference.conf b/modules/config/src/test/resources/reference.conf new file mode 100644 index 00000000..9a1a79c8 --- /dev/null +++ b/modules/config/src/test/resources/reference.conf @@ -0,0 +1,5 @@ +docspell.server { + bind { + port = 7880 + } +} \ No newline at end of file diff --git a/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala b/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala new file mode 100644 index 00000000..044e0e57 --- /dev/null +++ b/modules/config/src/test/scala/docspell/config/EnvConfigTest.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.config + +import munit.FunSuite + +class EnvConfigTest extends FunSuite { + + test("convert underscores") { + assertEquals(EnvConfig.envToProp("A_B_C"), "a.b.c") + assertEquals(EnvConfig.envToProp("A_B__C"), "a.b-c") + assertEquals(EnvConfig.envToProp("AA_BB__CC___D"), "aa.bb-cc_d") + } + + test("insert docspell keys") { + val cfg = EnvConfig.loadFrom( + Map( + "DOCSPELL_SERVER_APP__NAME" -> "Hello!", + "DOCSPELL_JOEX_BIND_PORT" -> "1234" + ).view + ) + + assertEquals(cfg.getString("docspell.server.app-name"), "Hello!") + assertEquals(cfg.getInt("docspell.joex.bind.port"), 1234) + } + + test("find default values from reference.conf") { + val cfg = EnvConfig.loadFrom( + Map( + "DOCSPELL_SERVER_APP__NAME" -> "Hello!", + "DOCSPELL_JOEX_BIND_PORT" -> "1234" + ).view + ) + assertEquals(cfg.getInt("docspell.server.bind.port"), 7880) + } + + test("discard non docspell keys") { + val cfg = EnvConfig.loadFrom(Map("A_B_C" -> "12").view) + assert(!cfg.hasPath("a.b.c")) + } +} diff --git a/modules/convert/src/main/scala/docspell/convert/Conversion.scala b/modules/convert/src/main/scala/docspell/convert/Conversion.scala index 54ee526a..b1a05aa4 100644 --- a/modules/convert/src/main/scala/docspell/convert/Conversion.scala +++ b/modules/convert/src/main/scala/docspell/convert/Conversion.scala @@ -33,6 +33,7 @@ object Conversion { def create[F[_]: Async]( cfg: ConvertConfig, sanitizeHtml: SanitizeHtml, + additionalPasswords: List[Password], logger: Logger[F] ): Resource[F, Conversion[F]] = Resource.pure[F, Conversion[F]](new Conversion[F] { @@ -42,8 +43,16 @@ object Conversion { ): F[A] = TikaMimetype.resolve(dataType, in).flatMap { case MimeType.PdfMatch(_) => + val allPass = cfg.decryptPdf.passwords ++ additionalPasswords + val pdfStream = + if (cfg.decryptPdf.enabled) { + logger.s + .debug(s"Trying to read the PDF using ${allPass.size} passwords") + .drain ++ + in.through(RemovePdfEncryption(logger, allPass)) + } else in OcrMyPdf - .toPDF(cfg.ocrmypdf, lang, cfg.chunkSize, logger)(in, handler) + .toPDF(cfg.ocrmypdf, lang, cfg.chunkSize, logger)(pdfStream, handler) case MimeType.HtmlMatch(mt) => val cs = mt.charsetOrUtf8 diff --git a/modules/convert/src/main/scala/docspell/convert/ConvertConfig.scala b/modules/convert/src/main/scala/docspell/convert/ConvertConfig.scala index fc4e4cd6..a4f3c224 100644 --- a/modules/convert/src/main/scala/docspell/convert/ConvertConfig.scala +++ b/modules/convert/src/main/scala/docspell/convert/ConvertConfig.scala @@ -6,11 +6,13 @@ package docspell.convert +import docspell.common.Password +import docspell.convert.ConvertConfig.DecryptPdf import docspell.convert.extern.OcrMyPdfConfig import docspell.convert.extern.{TesseractConfig, UnoconvConfig, WkHtmlPdfConfig} import docspell.convert.flexmark.MarkdownConfig -case class ConvertConfig( +final case class ConvertConfig( chunkSize: Int, convertedFilenamePart: String, maxImageSize: Int, @@ -18,5 +20,11 @@ case class ConvertConfig( wkhtmlpdf: WkHtmlPdfConfig, tesseract: TesseractConfig, unoconv: UnoconvConfig, - ocrmypdf: OcrMyPdfConfig + ocrmypdf: OcrMyPdfConfig, + decryptPdf: DecryptPdf ) + +object ConvertConfig { + + final case class DecryptPdf(enabled: Boolean, passwords: List[Password]) +} diff --git a/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala b/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala new file mode 100644 index 00000000..4d7a469f --- /dev/null +++ b/modules/convert/src/main/scala/docspell/convert/RemovePdfEncryption.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.convert + +import java.io.ByteArrayOutputStream + +import cats.effect._ +import fs2.{Chunk, Pipe, Stream} + +import docspell.common._ + +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException + +/** Using PDFBox, the incoming pdf is loaded while trying the given passwords. */ +object RemovePdfEncryption { + + def apply[F[_]: Sync]( + logger: Logger[F], + passwords: List[Password] + ): Pipe[F, Byte, Byte] = + apply(logger, Stream.emits(passwords)) + + def apply[F[_]: Sync]( + logger: Logger[F], + passwords: Stream[F, Password] + ): Pipe[F, Byte, Byte] = { + val pws = passwords.cons1(Password.empty) + in => + pws + .flatMap(pw => in.through(openPdf[F](logger, pw))) + .head + .flatMap { doc => + if (doc.isEncrypted) { + logger.s.debug("Removing protection/encryption from PDF").drain ++ + Stream.eval(Sync[F].delay(doc.setAllSecurityToBeRemoved(true))).drain ++ + toStream[F](doc) + } else { + in + } + } + .ifEmpty( + logger.s + .info( + s"None of the passwords helped to read the given PDF!" + ) + .drain ++ in + ) + } + + private def openPdf[F[_]: Sync]( + logger: Logger[F], + pw: Password + ): Pipe[F, Byte, PDDocument] = { + def alloc(bytes: Array[Byte]): F[Option[PDDocument]] = + Sync[F].delay(load(bytes, pw)) + + def free(doc: Option[PDDocument]): F[Unit] = + Sync[F].delay(doc.foreach(_.close())) + + val log = + if (pw.isEmpty) Stream.empty + else logger.s.debug(s"Try opening PDF with password: ${pw.pass.take(2)}***").drain + + in => + Stream + .eval(in.compile.to(Array)) + .flatMap(bytes => log ++ Stream.bracket(alloc(bytes))(free)) + .flatMap(opt => opt.map(Stream.emit).getOrElse(Stream.empty)) + } + + private def load(bytes: Array[Byte], pw: Password): Option[PDDocument] = + try Option(PDDocument.load(bytes, pw.pass)) + catch { + case _: InvalidPasswordException => + None + } + + private def toStream[F[_]](doc: PDDocument): Stream[F, Byte] = { + val baos = new ByteArrayOutputStream() + doc.save(baos) + Stream.chunk(Chunk.array(baos.toByteArray)) + } +} diff --git a/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala b/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala index fa4360e8..8f9f191f 100644 --- a/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala +++ b/modules/convert/src/test/scala/docspell/convert/ConversionTest.scala @@ -74,11 +74,12 @@ class ConversionTest extends FunSuite with FileChecks { Duration.seconds(20) ), target - ) + ), + ConvertConfig.DecryptPdf(true, Nil) ) val conversion = - Conversion.create[IO](convertConfig, SanitizeHtml.none, logger) + Conversion.create[IO](convertConfig, SanitizeHtml.none, Nil, logger) val bombs = List( ExampleFiles.bombs_20K_gray_jpeg, diff --git a/modules/convert/src/test/scala/docspell/convert/FileChecks.scala b/modules/convert/src/test/scala/docspell/convert/FileChecks.scala index a6a62462..96f251ff 100644 --- a/modules/convert/src/test/scala/docspell/convert/FileChecks.scala +++ b/modules/convert/src/test/scala/docspell/convert/FileChecks.scala @@ -9,6 +9,8 @@ package docspell.convert import java.nio.charset.StandardCharsets import java.nio.file.Files +import scala.util.Try + import cats.data.Kleisli import cats.effect.IO import cats.effect.unsafe.implicits.global @@ -19,6 +21,9 @@ import docspell.common._ import docspell.convert.ConversionResult.Handler import docspell.files.TikaMimetype +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException + trait FileChecks { implicit class FileCheckOps(p: Path) { @@ -34,15 +39,46 @@ trait FileChecks { def isPlainText: Boolean = isType(MimeType.text("plain")) + + def isUnencryptedPDF: Boolean = + Try(PDDocument.load(p.toNioPath.toFile)).map(_.close()).isSuccess + } + + implicit class ByteStreamOps(delegate: Stream[IO, Byte]) { + def isNonEmpty: IO[Boolean] = + delegate.head.compile.last.map(_.isDefined) + + def isType(mime: MimeType): IO[Boolean] = + TikaMimetype.detect(delegate, MimeTypeHint.none).map(_ == mime) + + def isPDF: IO[Boolean] = + isType(MimeType.pdf) + + def isUnencryptedPDF: IO[Boolean] = + delegate.compile + .to(Array) + .map(PDDocument.load(_)) + .map(_.close()) + .map(_ => true) + + def isEncryptedPDF: IO[Boolean] = + delegate.compile + .to(Array) + .map(PDDocument.load(_)) + .attempt + .map(e => + e.fold( + _.isInstanceOf[InvalidPasswordException], + doc => { + doc.close(); + false + } + ) + ) } def storeFile(file: Path): Pipe[IO, Byte, Path] = - in => - Stream - .eval( - in.compile.to(Array).flatMap(bytes => IO(Files.write(file.toNioPath, bytes))) - ) - .map(p => File.path(p)) + fs2.io.file.Files[IO].writeAll(file).andThen(s => s ++ Stream.emit(file)) def storePdfHandler(file: Path): Handler[IO, Path] = storePdfTxtHandler(file, file.resolveSibling("unexpected.txt")).map(_._1) diff --git a/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala b/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala new file mode 100644 index 00000000..803f3174 --- /dev/null +++ b/modules/convert/src/test/scala/docspell/convert/RemovePdfEncryptionTest.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.convert + +import cats.effect.IO +import fs2.Stream + +import docspell.common._ +import docspell.files.ExampleFiles + +import munit.CatsEffectSuite + +class RemovePdfEncryptionTest extends CatsEffectSuite with FileChecks { + val logger: Logger[IO] = Logger.log4s(org.log4s.getLogger) + + private val protectedPdf = + ExampleFiles.secured_protected_test123_pdf.readURL[IO](16 * 1024) + private val encryptedPdf = + ExampleFiles.secured_encrypted_test123_pdf.readURL[IO](16 * 1024) + private val plainPdf = ExampleFiles.letter_en_pdf.readURL[IO](16 * 1024) + + test("have encrypted pdfs") { + for { + _ <- assertIO(encryptedPdf.isEncryptedPDF, true) + _ <- assertIO(encryptedPdf.isEncryptedPDF, true) + } yield () + } + + test("decrypt pdf") { + encryptedPdf + .through(RemovePdfEncryption(logger, List(Password("test123")))) + .isUnencryptedPDF + .map(assert(_)) + } + + test("decrypt pdf with multiple passwords") { + encryptedPdf + .through( + RemovePdfEncryption( + logger, + List("xy123", "123xy", "test123", "abc123").map(Password(_)) + ) + ) + .isUnencryptedPDF + .map(assert(_)) + } + + test("remove protection") { + protectedPdf + .through(RemovePdfEncryption(logger, Nil)) + .isUnencryptedPDF + .map(assert(_)) + } + + test("read unprotected pdf") { + plainPdf + .through(RemovePdfEncryption(logger, Nil)) + .isUnencryptedPDF + .map(assert(_)) + } + + test("decrypt with multiple passwords, stop on first") { + val passwords: Stream[IO, String] = + Stream("test123") ++ Stream.raiseError[IO](new Exception("is not called")) + val decrypt = RemovePdfEncryption(logger, passwords.map(Password(_))) + encryptedPdf + .through(decrypt) + .isUnencryptedPDF + .map(assert(_)) + } + + test("return input stream if nothing helps") { + encryptedPdf + .through(RemovePdfEncryption(logger, List("a", "b").map(Password(_)))) + .isEncryptedPDF + .map(assert(_)) + } +} diff --git a/modules/files/src/test/resources/secured/encrypted-test123.pdf b/modules/files/src/test/resources/secured/encrypted-test123.pdf new file mode 100644 index 00000000..2750d634 Binary files /dev/null and b/modules/files/src/test/resources/secured/encrypted-test123.pdf differ diff --git a/modules/files/src/test/resources/secured/protected-test123.pdf b/modules/files/src/test/resources/secured/protected-test123.pdf new file mode 100644 index 00000000..6261e2e8 Binary files /dev/null and b/modules/files/src/test/resources/secured/protected-test123.pdf differ diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf index c6ad2cdd..80348f57 100644 --- a/modules/joex/src/main/resources/reference.conf +++ b/modules/joex/src/main/resources/reference.conf @@ -20,14 +20,19 @@ docspell.joex { # The database connection. # - # By default a H2 file-based database is configured. You can provide - # a postgresql or mariadb connection here. When using H2 use the - # PostgreSQL compatibility mode and AUTO_SERVER feature. - # # It must be the same connection as the rest server is using. jdbc { + + # The JDBC url to the database. By default a H2 file-based + # database is configured. You can provide a postgresql or mariadb + # connection here. When using H2 use the PostgreSQL compatibility + # mode and AUTO_SERVER feature. url = "jdbc:h2://"${java.io.tmpdir}"/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE" + + # The database user. user = "sa" + + # The database password. password = "" } @@ -586,6 +591,25 @@ Docpell Update Check } working-dir = ${java.io.tmpdir}"/docspell-convert" } + + # Allows to try to decrypt a PDF with encryption or protection. If + # enabled, a PDFs encryption or protection will be removed during + # conversion. + # + # For encrypted PDFs, this is necessary to be processed, because + # docspell needs to read it. It also requires to specify a + # password here. All passwords are tried when reading a PDF. + # + # This is enabled by default with an empty password list. This + # removes protection from PDFs, which is better for processing. + # + # Passwords can be given here and each collective can maintain + # their passwords as well. But if the `enabled` setting below is + # `false`, then no attempt at decrypting is done. + decrypt-pdf = { + enabled = true + passwords = [] + } } # The same section is also present in the rest-server config. It is diff --git a/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala b/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala index 24ef9322..4b3aaf4d 100644 --- a/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala +++ b/modules/joex/src/main/scala/docspell/joex/ConfigFile.scala @@ -8,9 +8,12 @@ package docspell.joex import cats.data.Validated import cats.data.ValidatedNec +import cats.effect.Async import cats.implicits._ -import docspell.common.config.Implicits._ +import docspell.common.Logger +import docspell.config.ConfigFactory +import docspell.config.Implicits._ import docspell.joex.scheduler.CountingScheme import emil.MailAddress @@ -22,8 +25,12 @@ import yamusca.imports._ object ConfigFile { import Implicits._ - def loadConfig: Config = - validOrThrow(ConfigSource.default.at("docspell.joex").loadOrThrow[Config]) + def loadConfig[F[_]: Async](args: List[String]): F[Config] = { + val logger = Logger.log4s[F](org.log4s.getLogger) + ConfigFactory + .default[F, Config](logger, "docspell.joex")(args) + .map(cfg => validOrThrow(cfg)) + } private def validOrThrow(cfg: Config): Config = validate(cfg).fold(err => sys.error(err.toList.mkString("- ", "\n", "")), identity) diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 0f13bab4..86c65efc 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -116,11 +116,10 @@ object JoexAppImpl { def create[F[_]: Async]( cfg: Config, termSignal: SignallingRef[F, Boolean], - connectEC: ExecutionContext, - clientEC: ExecutionContext + connectEC: ExecutionContext ): Resource[F, JoexApp[F]] = for { - httpClient <- BlazeClientBuilder[F](clientEC).resource + httpClient <- BlazeClientBuilder[F].resource client = JoexClient(httpClient) store <- Store.create(cfg.jdbc, cfg.files.chunkSize, connectEC) queue <- JobQueue(store) diff --git a/modules/joex/src/main/scala/docspell/joex/JoexServer.scala b/modules/joex/src/main/scala/docspell/joex/JoexServer.scala index 202c830f..8c4773dc 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexServer.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexServer.scala @@ -33,9 +33,7 @@ object JoexServer { val app = for { signal <- Resource.eval(SignallingRef[F, Boolean](false)) exitCode <- Resource.eval(Ref[F].of(ExitCode.Success)) - joexApp <- - JoexAppImpl - .create[F](cfg, signal, pools.connectEC, pools.httpClientEC) + joexApp <- JoexAppImpl.create[F](cfg, signal, pools.connectEC) httpApp = Router( "/api/info" -> InfoRoutes(cfg), @@ -50,7 +48,7 @@ object JoexServer { Stream .resource(app) .flatMap(app => - BlazeServerBuilder[F](pools.restEC) + BlazeServerBuilder[F] .bindHttp(cfg.bind.port, cfg.bind.address) .withHttpApp(app.httpApp) .withoutBanner diff --git a/modules/joex/src/main/scala/docspell/joex/Main.scala b/modules/joex/src/main/scala/docspell/joex/Main.scala index a288c0b5..6f96e1e0 100644 --- a/modules/joex/src/main/scala/docspell/joex/Main.scala +++ b/modules/joex/src/main/scala/docspell/joex/Main.scala @@ -6,71 +6,45 @@ package docspell.joex -import java.nio.file.{Files, Paths} - import cats.effect._ -import cats.implicits._ import docspell.common._ -import org.log4s._ +import org.log4s.getLogger object Main extends IOApp { - private[this] val logger = getLogger - val blockingEC = - ThreadFactories.cached[IO](ThreadFactories.ofName("docspell-joex-blocking")) - val connectEC = + private val logger: Logger[IO] = Logger.log4s[IO](getLogger) + + private val connectEC = ThreadFactories.fixed[IO](5, ThreadFactories.ofName("docspell-joex-dbconnect")) - val restserverEC = - ThreadFactories.workSteal[IO](ThreadFactories.ofNameFJ("docspell-joex-server")) - def run(args: List[String]) = { - args match { - case file :: Nil => - val path = Paths.get(file).toAbsolutePath.normalize - logger.info(s"Using given config file: $path") - System.setProperty("config.file", file) - case _ => - Option(System.getProperty("config.file")) match { - case Some(f) if f.nonEmpty => - val path = Paths.get(f).toAbsolutePath.normalize - if (!Files.exists(path)) { - logger.info(s"Not using config file '$f' because it doesn't exist") - System.clearProperty("config.file") - } else - logger.info(s"Using config file from system properties: $f") - case _ => - } - } + def run(args: List[String]): IO[ExitCode] = + for { + cfg <- ConfigFile.loadConfig[IO](args) + banner = Banner( + "JOEX", + BuildInfo.version, + BuildInfo.gitHeadCommit, + cfg.jdbc.url, + Option(System.getProperty("config.file")), + cfg.appId, + cfg.baseUrl, + Some(cfg.fullTextSearch.solr.url).filter(_ => cfg.fullTextSearch.enabled) + ) + _ <- logger.info(s"\n${banner.render("***>")}") + _ <- + if (EnvMode.current.isDev) { + logger.warn(">>>>> Docspell is running in DEV mode! <<<<<") + } else IO(()) - val cfg = ConfigFile.loadConfig - val banner = Banner( - "JOEX", - BuildInfo.version, - BuildInfo.gitHeadCommit, - cfg.jdbc.url, - Option(System.getProperty("config.file")), - cfg.appId, - cfg.baseUrl, - Some(cfg.fullTextSearch.solr.url).filter(_ => cfg.fullTextSearch.enabled) - ) - logger.info(s"\n${banner.render("***>")}") - if (EnvMode.current.isDev) { - logger.warn(">>>>> Docspell is running in DEV mode! <<<<<") - } - - val pools = for { - cec <- connectEC - bec <- blockingEC - rec <- restserverEC - } yield Pools(cec, bec, rec) - pools.use(p => - JoexServer - .stream[IO](cfg, p) - .compile - .drain - .as(ExitCode.Success) - ) - } + pools = connectEC.map(Pools.apply) + rc <- pools.use(p => + JoexServer + .stream[IO](cfg, p) + .compile + .drain + .as(ExitCode.Success) + ) + } yield rc } diff --git a/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala b/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala index ebcf49f5..ecba752b 100644 --- a/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/hk/CheckNodesTask.scala @@ -26,7 +26,7 @@ object CheckNodesTask { for { _ <- ctx.logger.info("Check nodes reachability") ec = scala.concurrent.ExecutionContext.global - _ <- BlazeClientBuilder[F](ec).resource.use { client => + _ <- BlazeClientBuilder[F].withExecutionContext(ec).resource.use { client => checkNodes(ctx, client) } _ <- ctx.logger.info( diff --git a/modules/joex/src/main/scala/docspell/joex/process/ConvertPdf.scala b/modules/joex/src/main/scala/docspell/joex/process/ConvertPdf.scala index 44e2613f..0108ef98 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ConvertPdf.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ConvertPdf.scala @@ -77,17 +77,27 @@ object ConvertPdf { ctx: Context[F, ProcessItemArgs], item: ItemData )(ra: RAttachment, mime: MimeType): F[(RAttachment, Option[RAttachmentMeta])] = - Conversion.create[F](cfg, sanitizeHtml, ctx.logger).use { conv => - mime match { - case mt => - val data = ctx.store.fileStore.getBytes(ra.fileId) - val handler = conversionHandler[F](ctx, cfg, ra, item) - ctx.logger.info(s"Converting file ${ra.name} (${mime.asString}) into a PDF") *> - conv.toPDF(DataType(mt), ctx.args.meta.language, handler)( - data - ) + loadCollectivePasswords(ctx).flatMap(collPass => + Conversion.create[F](cfg, sanitizeHtml, collPass, ctx.logger).use { conv => + mime match { + case mt => + val data = ctx.store.fileStore.getBytes(ra.fileId) + val handler = conversionHandler[F](ctx, cfg, ra, item) + ctx.logger + .info(s"Converting file ${ra.name} (${mime.asString}) into a PDF") *> + conv.toPDF(DataType(mt), ctx.args.meta.language, handler)( + data + ) + } } - } + ) + + private def loadCollectivePasswords[F[_]: Async]( + ctx: Context[F, ProcessItemArgs] + ): F[List[Password]] = + ctx.store + .transact(RCollectivePassword.findAll(ctx.args.meta.collective)) + .map(_.map(_.password).distinct) private def conversionHandler[F[_]: Sync]( ctx: Context[F, ProcessItemArgs], diff --git a/modules/joexapi/src/main/resources/joex-openapi.yml b/modules/joexapi/src/main/resources/joex-openapi.yml index 1fe484aa..7e46e164 100644 --- a/modules/joexapi/src/main/resources/joex-openapi.yml +++ b/modules/joexapi/src/main/resources/joex-openapi.yml @@ -2,7 +2,7 @@ openapi: 3.0.0 info: title: Docspell JOEX - version: 0.27.0 + version: 0.28.0-SNAPSHOT description: | This is the remote API to the job executor component of Docspell. Docspell is a free document management system focused on small diff --git a/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala b/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala index df2e5065..d6a6a5c7 100644 --- a/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala +++ b/modules/joexapi/src/main/scala/docspell/joexapi/client/JoexClient.scala @@ -6,8 +6,6 @@ package docspell.joexapi.client -import scala.concurrent.ExecutionContext - import cats.effect._ import cats.implicits._ @@ -69,6 +67,6 @@ object JoexClient { Uri.unsafeFromString(u.asString) } - def resource[F[_]: Async](ec: ExecutionContext): Resource[F, JoexClient[F]] = - BlazeClientBuilder[F](ec).resource.map(apply[F]) + def resource[F[_]: Async]: Resource[F, JoexClient[F]] = + BlazeClientBuilder[F].resource.map(apply[F]) } diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index 0fc73acb..c9466ac0 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -123,9 +123,11 @@ object ItemQuery { final case class ChecksumMatch(checksum: String) extends Expr final case class AttachId(id: String) extends Expr - final case object ValidItemStates extends Expr - final case object Trashed extends Expr - final case object ValidItemsOrTrashed extends Expr + /** A "private" expression is only visible in code, but cannot be parsed. */ + sealed trait PrivateExpr extends Expr + final case object ValidItemStates extends PrivateExpr + final case object Trashed extends PrivateExpr + final case object ValidItemsOrTrashed extends PrivateExpr // things that can be expressed with terms above sealed trait MacroExpr extends Expr { @@ -186,6 +188,10 @@ object ItemQuery { def date(op: Operator, attr: DateAttr, value: Date): SimpleExpr = SimpleExpr(op, Property(attr, value)) + + def itemIdEq(itemId1: String, moreIds: String*): Expr = + if (moreIds.isEmpty) string(Operator.Eq, Attr.ItemId, itemId1) + else InExpr(Attr.ItemId, Nel(itemId1, moreIds.toList)) } } diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala index c4c17801..d571cf63 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala @@ -8,12 +8,23 @@ package docspell.query import cats.data.NonEmptyList -import docspell.query.internal.ExprParser -import docspell.query.internal.ExprUtil +import docspell.query.internal.{ExprParser, ExprString, ExprUtil} object ItemQueryParser { + val PrivateExprError = ExprString.PrivateExprError + type PrivateExprError = ExprString.PrivateExprError + def parse(input: String): Either[ParseFailure, ItemQuery] = + parse0(input, expandMacros = true) + + def parseKeepMacros(input: String): Either[ParseFailure, ItemQuery] = + parse0(input, expandMacros = false) + + private def parse0( + input: String, + expandMacros: Boolean + ): Either[ParseFailure, ItemQuery] = if (input.isEmpty) Left( ParseFailure("", 0, NonEmptyList.of(ParseFailure.SimpleMessage(0, "No input."))) @@ -24,9 +35,16 @@ object ItemQueryParser { .parseQuery(in) .left .map(ParseFailure.fromError(in)) - .map(q => q.copy(expr = ExprUtil.reduce(q.expr))) + .map(q => q.copy(expr = ExprUtil.reduce(expandMacros)(q.expr))) } def parseUnsafe(input: String): ItemQuery = parse(input).fold(m => sys.error(m.render), identity) + + def asString(q: ItemQuery.Expr): Either[PrivateExprError, String] = + ExprString(q) + + def unsafeAsString(q: ItemQuery.Expr): String = + asString(q).fold(f => sys.error(s"Cannot expose private query part: $f"), identity) + } diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala index fd2d9207..48bd98df 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala @@ -24,7 +24,7 @@ object BasicParser { ) private[this] val identChars: Set[Char] = - (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet + (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.@").toSet val parenAnd: P[Unit] = P.stringIn(List("(&", "(and")).void <* ws0 diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala new file mode 100644 index 00000000..d3c5def4 --- /dev/null +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprString.scala @@ -0,0 +1,244 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.query.internal + +import java.time.Period + +import docspell.query.Date +import docspell.query.Date.DateLiteral +import docspell.query.ItemQuery.Attr._ +import docspell.query.ItemQuery.Expr._ +import docspell.query.ItemQuery._ +import docspell.query.internal.{Constants => C} + +/** Creates the string representation for a given expression. The returned string can be + * parsed back to the expression using `ExprParser`. Note that expressions obtained from + * the `ItemQueryParser` have macros already expanded. + * + * It may fail when the expression contains non-public parts. Every expression that has + * been created by parsing a string, can be transformed back to a string. But an + * expression created via code may contain parts that cannot be transformed to a string. + */ +object ExprString { + + final case class PrivateExprError(expr: Expr.PrivateExpr) + type Result = Either[PrivateExprError, String] + + def apply(expr: Expr): Result = + expr match { + case AndExpr(inner) => + val es = inner.traverse(ExprString.apply) + es.map(_.toList.mkString(" ")).map(els => s"(& $els )") + + case OrExpr(inner) => + val es = inner.traverse(ExprString.apply) + es.map(_.toList.mkString(" ")).map(els => s"(| $els )") + + case NotExpr(inner) => + inner match { + case NotExpr(inner2) => + apply(inner2) + case _ => + apply(inner).map(n => s"!$n") + } + + case m: MacroExpr => + Right(macroStr(m)) + + case DirectionExpr(v) => + Right(s"${C.incoming}${C.like}${v}") + + case InboxExpr(v) => + Right(s"${C.inbox}${C.like}${v}") + + case InExpr(attr, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${attrStr(attr)}${C.in}$els") + + case InDateExpr(attr, values) => + val els = values.map(dateStr).toList.mkString(",") + Right(s"${attrStr(attr)}${C.in}$els") + + case TagsMatch(op, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${C.tag}${tagOpStr(op)}$els") + + case TagIdsMatch(op, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${C.tagId}${tagOpStr(op)}$els") + + case Exists(field) => + Right(s"${C.exist}${C.like}${attrStr(field)}") + + case Fulltext(v) => + Right(s"${C.content}${C.like}${quote(v)}") + + case SimpleExpr(op, prop) => + prop match { + case Property.StringProperty(attr, value) => + Right(s"${stringAttr(attr)}${opStr(op)}${quote(value)}") + case Property.DateProperty(attr, value) => + Right(s"${dateAttr(attr)}${opStr(op)}${dateStr(value)}") + case Property.IntProperty(attr, value) => + Right(s"${attrStr(attr)}${opStr(op)}$value") + } + + case TagCategoryMatch(op, values) => + val els = values.map(quote).toList.mkString(",") + Right(s"${C.cat}${tagOpStr(op)}$els") + + case CustomFieldMatch(name, op, value) => + Right(s"${C.customField}:$name${opStr(op)}${quote(value)}") + + case CustomFieldIdMatch(id, op, value) => + Right(s"${C.customFieldId}:$id${opStr(op)}${quote(value)}") + + case ChecksumMatch(cs) => + Right(s"${C.checksum}${C.like}$cs") + + case AttachId(aid) => + Right(s"${C.attachId}${C.eqs}$aid") + + case pe: PrivateExpr => + // There is no parser equivalent for this + Left(PrivateExprError(pe)) + } + + private[internal] def macroStr(expr: Expr.MacroExpr): String = + expr match { + case Expr.NamesMacro(name) => + s"${C.names}:${quote(name)}" + case Expr.YearMacro(_, year) => + s"${C.year}:$year" //currently, only for Attr.Date + case Expr.ConcMacro(term) => + s"${C.conc}:${quote(term)}" + case Expr.CorrMacro(term) => + s"${C.corr}:${quote(term)}" + case Expr.DateRangeMacro(attr, left, right) => + val name = attr match { + case Attr.CreatedDate => + C.createdIn + case Attr.Date => + C.dateIn + case Attr.DueDate => + C.dueIn + } + (left, right) match { + case (_: Date.DateLiteral, Date.Calc(date, calc, period)) => + s"$name:${dateStr(date)};${calcStr(calc)}${periodStr(period)}" + + case (Date.Calc(date, calc, period), _: DateLiteral) => + s"$name:${dateStr(date)};${calcStr(calc)}${periodStr(period)}" + + case (Date.Calc(d1, _, p1), Date.Calc(_, _, _)) => + s"$name:${dateStr(d1)};/${periodStr(p1)}" + + case (_: DateLiteral, _: DateLiteral) => + sys.error("Invalid date range") + } + } + + private[internal] def dateStr(date: Date): String = + date match { + case Date.Today => + "today" + case Date.Local(ld) => + f"${ld.getYear}-${ld.getMonthValue}%02d-${ld.getDayOfMonth}%02d" + + case Date.Millis(ms) => + s"ms$ms" + + case Date.Calc(date, calc, period) => + val ds = dateStr(date) + s"$ds;${calcStr(calc)}${periodStr(period)}" + } + + private[internal] def calcStr(c: Date.CalcDirection): String = + c match { + case Date.CalcDirection.Plus => "+" + case Date.CalcDirection.Minus => "-" + } + + private[internal] def periodStr(p: Period): String = + if (p.toTotalMonths == 0) s"${p.getDays}d" + else s"${p.toTotalMonths}m" + + private[internal] def attrStr(attr: Attr): String = + attr match { + case a: StringAttr => stringAttr(a) + case a: DateAttr => dateAttr(a) + case a: IntAttr => intAttr(a) + } + + private[internal] def intAttr(attr: IntAttr): String = + attr match { + case AttachCount => + Constants.attachCount + } + + private[internal] def dateAttr(attr: DateAttr): String = + attr match { + case Attr.Date => + Constants.date + case DueDate => + Constants.due + case CreatedDate => + Constants.created + } + + private[internal] def stringAttr(attr: StringAttr): String = + attr match { + case Attr.ItemName => + Constants.name + case Attr.ItemId => + Constants.id + case Attr.ItemSource => + Constants.source + case Attr.ItemNotes => + Constants.notes + case Correspondent.OrgId => + Constants.corrOrgId + case Correspondent.OrgName => + Constants.corrOrgName + case Correspondent.PersonId => + Constants.corrPersId + case Correspondent.PersonName => + Constants.corrPersName + case Concerning.EquipId => + Constants.concEquipId + case Concerning.EquipName => + Constants.concEquipName + case Concerning.PersonId => + Constants.concPersId + case Concerning.PersonName => + Constants.concPersName + case Folder.FolderName => + Constants.folder + case Folder.FolderId => + Constants.folderId + } + + private[internal] def opStr(op: Operator): String = + op match { + case Operator.Like => Constants.like.toString + case Operator.Gte => Constants.gte + case Operator.Lte => Constants.lte + case Operator.Eq => Constants.eqs.toString + case Operator.Lt => Constants.lt.toString + case Operator.Gt => Constants.gt.toString + case Operator.Neq => Constants.neq + } + + private[internal] def tagOpStr(op: TagOperator): String = + op match { + case TagOperator.AllMatch => C.eqs.toString + case TagOperator.AnyMatch => C.like.toString + } + + private def quote(s: String): String = + s"\"$s\"" +} diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala index 4f985be5..6b22ef96 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -13,35 +13,42 @@ import docspell.query.ItemQuery._ object ExprUtil { + def reduce(expr: Expr): Expr = + reduce(expandMacros = true)(expr) + /** Does some basic transformation, like unfolding nested and trees containing one value * etc. */ - def reduce(expr: Expr): Expr = + def reduce(expandMacros: Boolean)(expr: Expr): Expr = expr match { case AndExpr(inner) => val nodes = spliceAnd(inner) - if (nodes.tail.isEmpty) reduce(nodes.head) - else AndExpr(nodes.map(reduce)) + if (nodes.tail.isEmpty) reduce(expandMacros)(nodes.head) + else AndExpr(nodes.map(reduce(expandMacros))) case OrExpr(inner) => val nodes = spliceOr(inner) - if (nodes.tail.isEmpty) reduce(nodes.head) - else OrExpr(nodes.map(reduce)) + if (nodes.tail.isEmpty) reduce(expandMacros)(nodes.head) + else OrExpr(nodes.map(reduce(expandMacros))) case NotExpr(inner) => inner match { case NotExpr(inner2) => - reduce(inner2) + reduce(expandMacros)(inner2) case InboxExpr(flag) => InboxExpr(!flag) case DirectionExpr(flag) => DirectionExpr(!flag) case _ => - NotExpr(reduce(inner)) + NotExpr(reduce(expandMacros)(inner)) } case m: MacroExpr => - reduce(m.body) + if (expandMacros) { + reduce(expandMacros)(m.body) + } else { + m + } case DirectionExpr(_) => expr diff --git a/modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala b/modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala new file mode 100644 index 00000000..4f6a6982 --- /dev/null +++ b/modules/query/shared/src/test/scala/docspell/query/ItemQueryGen.scala @@ -0,0 +1,287 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.query + +import java.time.{Instant, Period, ZoneOffset} + +import cats.data.NonEmptyList + +import docspell.query.ItemQuery.Expr.TagIdsMatch +import docspell.query.ItemQuery._ + +import org.scalacheck.Gen + +/** Generator for syntactically valid queries. */ +object ItemQueryGen { + + def exprGen: Gen[Expr] = + Gen.oneOf( + simpleExprGen, + existsExprGen, + inExprGen, + inDateExprGen, + inboxExprGen, + directionExprGen, + tagIdsMatchExprGen, + tagMatchExprGen, + tagCatMatchExpr, + customFieldMatchExprGen, + customFieldIdMatchExprGen, + fulltextExprGen, + checksumMatchExprGen, + attachIdExprGen, + namesMacroGen, + corrMacroGen, + concMacroGen, + yearMacroGen, + dateRangeMacro, + Gen.lzy(andExprGen(exprGen)), + Gen.lzy(orExprGen(exprGen)), + Gen.lzy(notExprGen(exprGen)) + ) + + def andExprGen(g: Gen[Expr]): Gen[Expr.AndExpr] = + nelGen(g).map(Expr.AndExpr) + + def orExprGen(g: Gen[Expr]): Gen[Expr.OrExpr] = + nelGen(g).map(Expr.OrExpr) + + // avoid generating nested not expressions, they are already flattened by the parser + // and only occur artificially + def notExprGen(g: Gen[Expr]): Gen[Expr] = + g.map { + case Expr.NotExpr(inner) => inner + case e => Expr.NotExpr(e) + } + + val opGen: Gen[Operator] = + Gen.oneOf( + Operator.Like, + Operator.Gte, + Operator.Lt, + Operator.Gt, + Operator.Lte, + Operator.Eq, + Operator.Neq + ) + + val tagOpGen: Gen[TagOperator] = + Gen.oneOf(TagOperator.AllMatch, TagOperator.AnyMatch) + + val stringAttrGen: Gen[Attr.StringAttr] = + Gen.oneOf( + Attr.Concerning.EquipName, + Attr.Concerning.EquipId, + Attr.Concerning.PersonName, + Attr.Concerning.PersonId, + Attr.Correspondent.OrgName, + Attr.Correspondent.OrgId, + Attr.Correspondent.PersonName, + Attr.Correspondent.PersonId, + Attr.ItemId, + Attr.ItemName, + Attr.ItemSource, + Attr.ItemNotes, + Attr.Folder.FolderId, + Attr.Folder.FolderName + ) + + val dateAttrGen: Gen[Attr.DateAttr] = + Gen.oneOf(Attr.Date, Attr.DueDate, Attr.CreatedDate) + + val intAttrGen: Gen[Attr.IntAttr] = + Gen.const(Attr.AttachCount) + + val attrGen: Gen[Attr] = + Gen.oneOf(stringAttrGen, dateAttrGen, intAttrGen) + + private val valueChars = + Gen.oneOf(Gen.alphaNumChar, Gen.oneOf(" /{}*?-:@#$~+%…_[]^!ß")) + + private val stringValueGen: Gen[String] = + Gen.choose(1, 20).flatMap(n => Gen.stringOfN(n, valueChars)) + + private val intValueGen: Gen[Int] = + Gen.choose(1900, 9999) + + private val identGen: Gen[String] = + Gen + .choose(3, 12) + .flatMap(n => + Gen.stringOfN( + n, + Gen.oneOf((('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.@").toSet) + ) + ) + + private def nelGen[T](gen: Gen[T]): Gen[NonEmptyList[T]] = + for { + head <- gen + tail <- Gen.choose(0, 9).flatMap(n => Gen.listOfN(n, gen)) + } yield NonEmptyList(head, tail) + + private val dateMillisGen: Gen[Long] = + Gen.choose(0, Instant.parse("2100-12-24T20:00:00Z").toEpochMilli) + + val localDateGen: Gen[Date.Local] = + dateMillisGen + .map(ms => Instant.ofEpochMilli(ms).atOffset(ZoneOffset.UTC).toLocalDate) + .map(Date.Local) + + val millisDateGen: Gen[Date.Millis] = + dateMillisGen.map(Date.Millis) + + val dateLiteralGen: Gen[Date.DateLiteral] = + Gen.oneOf( + localDateGen, + millisDateGen, + Gen.const(Date.Today) + ) + + val periodGen: Gen[Period] = + for { + mOrD <- Gen.oneOf(a => Period.ofDays(a), a => Period.ofMonths(a)) + num <- Gen.choose(1, 30) + } yield mOrD(num) + + val calcGen: Gen[Date.CalcDirection] = + Gen.oneOf(Date.CalcDirection.Plus, Date.CalcDirection.Minus) + + val dateCalcGen: Gen[Date.Calc] = + for { + dl <- dateLiteralGen + calc <- calcGen + period <- periodGen + } yield Date.Calc(dl, calc, period) + + val dateValueGen: Gen[Date] = + Gen.oneOf(dateLiteralGen, dateCalcGen) + + val stringPropGen: Gen[Property.StringProperty] = + for { + attr <- stringAttrGen + sval <- stringValueGen + } yield Property.StringProperty(attr, sval) + + val intPropGen: Gen[Property.IntProperty] = + for { + attr <- intAttrGen + ival <- intValueGen + } yield Property.IntProperty(attr, ival) + + val datePropGen: Gen[Property.DateProperty] = + for { + attr <- dateAttrGen + dv <- dateValueGen + } yield Property.DateProperty(attr, dv) + + val propertyGen: Gen[Property] = + Gen.oneOf(stringPropGen, datePropGen, intPropGen) + + val simpleExprGen: Gen[Expr.SimpleExpr] = + for { + op <- opGen + prop <- propertyGen + } yield Expr.SimpleExpr(op, prop) + + val existsExprGen: Gen[Expr.Exists] = + attrGen.map(Expr.Exists) + + val inExprGen: Gen[Expr.InExpr] = + for { + attr <- stringAttrGen + vals <- nelGen(stringValueGen) + } yield Expr.InExpr(attr, vals) + + val inDateExprGen: Gen[Expr.InDateExpr] = + for { + attr <- dateAttrGen + vals <- nelGen(dateValueGen) + } yield Expr.InDateExpr(attr, vals) + + val inboxExprGen: Gen[Expr.InboxExpr] = + Gen.oneOf(true, false).map(Expr.InboxExpr) + + val directionExprGen: Gen[Expr.DirectionExpr] = + Gen.oneOf(true, false).map(Expr.DirectionExpr) + + val tagIdsMatchExprGen: Gen[Expr.TagIdsMatch] = + for { + op <- tagOpGen + vals <- nelGen(stringValueGen) + } yield TagIdsMatch(op, vals) + + val tagMatchExprGen: Gen[Expr.TagsMatch] = + for { + op <- tagOpGen + vals <- nelGen(stringValueGen) + } yield Expr.TagsMatch(op, vals) + + val tagCatMatchExpr: Gen[Expr.TagCategoryMatch] = + for { + op <- tagOpGen + vals <- nelGen(stringValueGen) + } yield Expr.TagCategoryMatch(op, vals) + + val customFieldMatchExprGen: Gen[Expr.CustomFieldMatch] = + for { + name <- identGen + op <- opGen + value <- stringValueGen + } yield Expr.CustomFieldMatch(name, op, value) + + val customFieldIdMatchExprGen: Gen[Expr.CustomFieldIdMatch] = + for { + name <- identGen + op <- opGen + value <- identGen + } yield Expr.CustomFieldIdMatch(name, op, value) + + val fulltextExprGen: Gen[Expr.Fulltext] = + Gen + .choose(3, 20) + .flatMap(n => Gen.stringOfN(n, valueChars)) + .map(Expr.Fulltext) + + val checksumMatchExprGen: Gen[Expr.ChecksumMatch] = + Gen.stringOfN(64, Gen.hexChar).map(Expr.ChecksumMatch) + + val attachIdExprGen: Gen[Expr.AttachId] = + identGen.map(Expr.AttachId) + + val namesMacroGen: Gen[Expr.NamesMacro] = + stringValueGen.map(Expr.NamesMacro) + + val concMacroGen: Gen[Expr.ConcMacro] = + stringValueGen.map(Expr.ConcMacro) + + val corrMacroGen: Gen[Expr.CorrMacro] = + stringValueGen.map(Expr.CorrMacro) + + val yearMacroGen: Gen[Expr.YearMacro] = + Gen.choose(1900, 9999).map(Expr.YearMacro(Attr.Date, _)) + + val dateRangeMacro: Gen[Expr.DateRangeMacro] = + for { + attr <- dateAttrGen + dl <- dateLiteralGen + p <- periodGen + calc <- Gen.option(calcGen) + range = calc match { + case Some(c @ Date.CalcDirection.Plus) => + Expr.DateRangeMacro(attr, dl, Date.Calc(dl, c, p)) + case Some(c @ Date.CalcDirection.Minus) => + Expr.DateRangeMacro(attr, Date.Calc(dl, c, p), dl) + case None => + Expr.DateRangeMacro( + attr, + Date.Calc(dl, Date.CalcDirection.Minus, p), + Date.Calc(dl, Date.CalcDirection.Plus, p) + ) + } + } yield range +} diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala new file mode 100644 index 00000000..99fe673f --- /dev/null +++ b/modules/query/shared/src/test/scala/docspell/query/internal/ExprStringTest.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.query.internal + +import java.time.{LocalDate, Period} + +import docspell.query.ItemQuery._ +import docspell.query.{Date, ItemQueryGen, ParseFailure} + +import munit.{FunSuite, ScalaCheckSuite} +import org.scalacheck.Prop.forAll + +class ExprStringTest extends FunSuite with ScalaCheckSuite { + + // parses the query without reducing and expanding macros + def singleParse(s: String): Expr = + ExprParser + .parseQuery(s) + .left + .map(ParseFailure.fromError(s)) + .fold(f => sys.error(f.render), _.expr) + + def exprString(expr: Expr): String = + ExprString(expr).fold(f => sys.error(f.toString), identity) + + test("macro: name") { + val str = exprString(Expr.NamesMacro("test")) + val q = singleParse(str) + assertEquals(str, "names:\"test\"") + assertEquals(q, Expr.NamesMacro("test")) + } + + test("macro: year") { + val str = exprString(Expr.YearMacro(Attr.Date, 1990)) + val q = singleParse(str) + assertEquals(str, "year:1990") + assertEquals(q, Expr.YearMacro(Attr.Date, 1990)) + } + + test("macro: daterange") { + val range = Expr.DateRangeMacro( + attr = Attr.Date, + left = Date.Calc( + date = Date.Local( + date = LocalDate.of(2076, 12, 9) + ), + calc = Date.CalcDirection.Minus, + period = Period.ofMonths(27) + ), + right = Date.Local(LocalDate.of(2076, 12, 9)) + ) + val str = exprString(range) + val q = singleParse(str) + assertEquals(str, "dateIn:2076-12-09;-27m") + assertEquals(q, range) + } + + property("generate expr and parse it") { + forAll(ItemQueryGen.exprGen) { expr => + val str = exprString(expr) + val q = singleParse(str) + assertEquals(q, expr) + } + } +} diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala index 37de9db0..3326a764 100644 --- a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala +++ b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala @@ -8,7 +8,7 @@ package docspell.query.internal import cats.implicits._ -import docspell.query.ItemQueryParser +import docspell.query.{ItemQuery, ItemQueryParser} import munit._ @@ -64,4 +64,14 @@ class ItemQueryParserTest extends FunSuite { ItemQueryParser.parseUnsafe("(| name:hello date:2021-02 name:world name:hello )") assertEquals(expect.copy(raw = raw.some), q) } + + test("f.id:name=value") { + val raw = "f.id:QsuGW@=\"dAHBstXJd0\"" + val q = ItemQueryParser.parseUnsafe(raw) + val expect = + ItemQuery.Expr.CustomFieldIdMatch("QsuGW@", ItemQuery.Operator.Eq, "dAHBstXJd0") + + assertEquals(q.expr, expect) + + } } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index b1846d1a..f6714ac4 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -2,7 +2,7 @@ openapi: 3.0.0 info: title: Docspell - version: 0.27.0 + version: 0.28.0-SNAPSHOT description: | This is the remote API to Docspell. Docspell is a free document management system focused on small groups or families. @@ -538,6 +538,37 @@ paths: application/json: schema: $ref: "#/components/schemas/InviteResult" + + /open/share/verify: + post: + operationId: "open-share-verify" + tags: [ Share ] + summary: Verify a secret for a share + description: | + Given the share id and optionally a password, it verifies the + correctness of the given data. As a result, a token is + returned that must be used with all `share/*` routes. If the + password is missing, but required, the response indicates + this. Then the requests needs to be replayed with the correct + password to retrieve the token. + + The token is also added as a session cookie to the response. + + The token is used to avoid passing the user define password + with every request. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ShareSecret" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ShareVerifyResult" + /sec/auth/session: post: operationId: "sec-auth-session" @@ -1527,6 +1558,187 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /share/search/query: + post: + operationId: "share-search-query" + tags: [Share] + summary: Performs a search in a share. + description: | + Allows to run a search query in the shared documents. The + input data structure is the same as with a standard query. The + `searchMode` parameter is ignored here. + security: + - shareTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemQuery" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ItemLightList" + /share/search/stats: + post: + operationId: "share-search-stats" + tags: [ Share ] + summary: Get basic statistics about search results. + description: | + Instead of returning the results of a query, uses it to return + a summary, constraint to the share. + security: + - shareTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemQuery" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/SearchStats" + /share/item/{id}: + get: + operationId: "share-item-get" + tags: [ Share ] + summary: Get details about an item. + description: | + Get detailed information about an item. + security: + - shareTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ItemDetail" + /share/attachment/{id}: + head: + operationId: "share-attach-head" + tags: [ Share ] + summary: Get headers to an attachment file. + description: | + Get information about the binary file belonging to the + attachment with the given id. + security: + - shareTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + headers: + Content-Type: + schema: + type: string + Content-Length: + schema: + type: integer + format: int64 + ETag: + schema: + type: string + Content-Disposition: + schema: + type: string + get: + operationId: "share-attach-get" + tags: [ Share ] + summary: Get an attachment file. + description: | + Get the binary file belonging to the attachment with the given + id. The binary is a pdf file. If conversion failed, then the + original file is returned. + security: + - shareTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + content: + application/octet-stream: + schema: + type: string + format: binary + /share/attachment/{id}/view: + get: + operationId: "share-attach-show-viewerjs" + tags: [ Share ] + summary: A javascript rendered view of the pdf attachment + description: | + This provides a preview of the attachment rendered in a + browser. + + It currently uses a third-party javascript library (viewerjs) + to display the preview. This works by redirecting to the + viewerjs url with the attachment url as parameter. Note that + the resulting url that is redirected to is not stable. It may + change from version to version. This route, however, is meant + to provide a stable url for the preview. + security: + - shareTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 303: + description: See Other + 200: + description: Ok + /share/attachment/{id}/preview: + head: + operationId: "share-attach-check-preview" + tags: [ Attachment ] + summary: Get the headers to a preview image of an attachment file. + description: | + Checks if an image file showing a preview of the attachment is + available. If not available, a 404 is returned. + security: + - shareTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + 404: + description: NotFound + get: + operationId: "share-attach-get-preview" + tags: [ Attachment ] + summary: Get a preview image of an attachment file. + description: | + Gets a image file showing a preview of the attachment. Usually + it is a small image of the first page of the document.If not + available, a 404 is returned. However, if the query parameter + `withFallback` is `true`, a fallback preview image is + returned. You can also use the `HEAD` method to check for + existence. + + The attachment must be in the search results of the current + share. + security: + - shareTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/withFallback" + responses: + 200: + description: Ok + content: + application/octet-stream: + schema: + type: string + format: binary + /admin/user/resetPassword: post: operationId: "admin-user-reset-password" @@ -1711,6 +1923,125 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/share: + get: + operationId: "sec-share-get-all" + tags: [ Share ] + summary: Get a list of shares + description: | + Return a list of all shares for this collective. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" + - $ref: "#/components/parameters/owningShare" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ShareList" + post: + operationId: "sec-share-new" + tags: [ Share ] + summary: Create a new share. + description: | + Create a new share. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ShareData" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/IdResult" + /sec/share/email/send/{name}: + post: + operationId: "sec-share-email-send" + tags: [ Share, E-Mail ] + summary: Send an email. + description: | + Sends an email as specified in the body of the request. + + An existing shareId must be given with the request, no matter + the content of the mail. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/name" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SimpleShareMail" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/share/{shareId}: + parameters: + - $ref: "#/components/parameters/shareId" + get: + operationId: "sec-share-get" + tags: [Share] + summary: Get details to a single share. + description: | + Given the id of a share, returns some details about it. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ShareDetail" + put: + operationId: "sec-share-update" + tags: [ Share ] + summary: Update an existing share. + description: | + Updates an existing share. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ShareData" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + operationId: "sec-share-delete-by-id" + tags: [ Share ] + summary: Delete a share. + description: | + Deletes a share + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/item/search: get: operationId: "sec-item-search-by-get" @@ -4096,6 +4427,126 @@ paths: components: schemas: + ShareSecret: + description: | + The secret (the share id + optional password) to access a + share. + required: + - shareId + properties: + shareId: + type: string + format: ident + password: + type: string + format: password + + ShareVerifyResult: + description: | + The data returned when verifying a `ShareSecret`. + required: + - success + - token + - passwordRequired + - message + properties: + success: + type: boolean + token: + type: string + passwordRequired: + type: boolean + message: + type: string + name: + type: string + description: | + The name of the share if it exists. Only valid to use when + `success` is `true`. + + ShareData: + description: | + Editable data for a share. + required: + - query + - enabled + - publishUntil + properties: + name: + type: string + query: + type: string + format: itemquery + enabled: + type: boolean + password: + type: string + format: password + publishUntil: + type: integer + format: date-time + removePassword: + type: boolean + description: | + For an update request, this can control whether to delete + the password. Otherwise if the password is not set, it + will not be changed. When adding a new share, this has no + effect. + + ShareDetail: + description: | + Details for an existing share. + required: + - id + - query + - owner + - enabled + - publishAt + - publishUntil + - password + - views + - expired + properties: + id: + type: string + format: ident + query: + type: string + format: itemquery + owner: + $ref: "#/components/schemas/IdName" + name: + type: string + enabled: + type: boolean + publishAt: + type: integer + format: date-time + publishUntil: + type: integer + format: date-time + expired: + type: boolean + password: + type: boolean + views: + type: integer + format: int32 + lastAccess: + type: integer + format: date-time + + ShareList: + description: | + A list of shares. + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/ShareDetail" + DeleteUserData: description: | An excerpt of data that would be deleted when deleting the @@ -4103,6 +4554,7 @@ components: required: - folders - sentMails + - shares properties: folders: type: array @@ -4112,6 +4564,9 @@ components: sentMails: type: integer format: int32 + shares: + type: integer + format: int32 SecondFactor: description: | @@ -4864,6 +5319,36 @@ components: items: type: string format: ident + SimpleShareMail: + description: | + A simple e-mail related to a share. + required: + - shareId + - recipients + - cc + - bcc + - subject + - body + properties: + shareId: + type: string + format: ident + recipients: + type: array + items: + type: string + cc: + type: array + items: + type: string + bcc: + type: array + items: + type: string + subject: + type: string + body: + type: string EmailSettingsList: description: | A list of user email settings. @@ -4977,6 +5462,10 @@ components: - tagCategoryCloud - fieldStats - folderStats + - corrOrgStats + - corrPersStats + - concPersStats + - concEquipStats properties: count: type: integer @@ -4993,6 +5482,23 @@ components: type: array items: $ref: "#/components/schemas/FolderStats" + corrOrgStats: + type: array + items: + $ref: "#/components/schemas/IdRefStats" + corrPersStats: + type: array + items: + $ref: "#/components/schemas/IdRefStats" + concPersStats: + type: array + items: + $ref: "#/components/schemas/IdRefStats" + concEquipStats: + type: array + items: + $ref: "#/components/schemas/IdRefStats" + ItemInsights: description: | Information about the items in docspell. @@ -5126,6 +5632,19 @@ components: type: integer format: int32 + IdRefStats: + description: | + Counting some objects that have an id and a name. + required: + - ref + - count + properties: + ref: + $ref: "#/components/schemas/IdName" + count: + type: integer + format: int32 + AttachmentMeta: description: | Extracted meta data of an attachment. @@ -5635,6 +6154,7 @@ components: - integrationEnabled - classifier - emptyTrash + - passwords properties: language: type: string @@ -5648,6 +6168,11 @@ components: $ref: "#/components/schemas/ClassifierSetting" emptyTrash: $ref: "#/components/schemas/EmptyTrashSetting" + passwords: + type: array + items: + type: string + format: password EmptyTrashSetting: description: | @@ -6115,8 +6640,8 @@ components: type: string IdResult: description: | - Some basic result of an operation with an ID as payload. If - success if `false` the id is not usable. + Some basic result of an operation with an ID as payload, if + success is true. If success is `false` the id is not usable. required: - success - message @@ -6236,6 +6761,10 @@ components: type: apiKey in: header name: Docspell-Admin-Secret + shareTokenHeader: + type: apiKey + in: header + name: Docspell-Share-Auth parameters: id: name: id @@ -6251,6 +6780,13 @@ components: required: true schema: type: string + shareId: + name: shareId + in: path + description: An identifier for a share + required: true + schema: + type: string username: name: username in: path @@ -6279,6 +6815,13 @@ components: required: false schema: type: boolean + owningShare: + name: owning + in: query + description: Return my own shares only + required: false + schema: + type: boolean checksum: name: checksum in: path diff --git a/modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala b/modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala new file mode 100644 index 00000000..096c0ba5 --- /dev/null +++ b/modules/restapi/src/main/scala/docspell/restapi/codec/ItemQueryJson.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restapi.codec + +import docspell.query.{ItemQuery, ItemQueryParser} + +import io.circe.{Decoder, Encoder} + +trait ItemQueryJson { + + implicit val itemQueryDecoder: Decoder[ItemQuery] = + Decoder.decodeString.emap(str => ItemQueryParser.parse(str).left.map(_.render)) + + implicit val itemQueryEncoder: Encoder[ItemQuery] = + Encoder.encodeString.contramap(q => + q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr)) + ) +} + +object ItemQueryJson extends ItemQueryJson diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 29b63dc1..961d7d46 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -47,8 +47,9 @@ docspell.server { # The secret for this server that is used to sign the authenicator # tokens. If multiple servers are running, all must share the same # secret. You can use base64 or hex strings (prefix with b64: and - # hex:, respectively). - server-secret = "hex:caffee" + # hex:, respectively). If empty, a random secret is generated. + # Example: b64:YRx77QujCGkHSvll0TVEmtTaw3Z5eXr+nWMsEJowgKg= + server-secret = "" # How long an authentication token is valid. The web application # will get a new one periodically. @@ -279,6 +280,7 @@ docspell.server { # Configuration for the backend. backend { + # Enable or disable debugging for e-mail related functionality. This # applies to both sending and receiving mails. For security reasons # logging is not very extensive on authentication failures. Setting @@ -286,13 +288,17 @@ docspell.server { mail-debug = false # The database connection. - # - # By default a H2 file-based database is configured. You can - # provide a postgresql or mariadb connection here. When using H2 - # use the PostgreSQL compatibility mode and AUTO_SERVER feature. jdbc { + # The JDBC url to the database. By default a H2 file-based + # database is configured. You can provide a postgresql or + # mariadb connection here. When using H2 use the PostgreSQL + # compatibility mode and AUTO_SERVER feature. url = "jdbc:h2://"${java.io.tmpdir}"/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE" + + # The database user. user = "sa" + + # The database password. password = "" } diff --git a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala index c321ee71..3dff82e9 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala @@ -6,23 +6,34 @@ package docspell.restserver +import java.security.SecureRandom + import cats.Semigroup import cats.data.{Validated, ValidatedNec} +import cats.effect.Async import cats.implicits._ import docspell.backend.signup.{Config => SignupConfig} -import docspell.common.config.Implicits._ +import docspell.common.Logger +import docspell.config.ConfigFactory +import docspell.config.Implicits._ import docspell.oidc.{ProviderConfig, SignatureAlgo} import docspell.restserver.auth.OpenId import pureconfig._ import pureconfig.generic.auto._ +import scodec.bits.ByteVector object ConfigFile { + private[this] val unsafeLogger = org.log4s.getLogger import Implicits._ - def loadConfig: Config = - Validate(ConfigSource.default.at("docspell.server").loadOrThrow[Config]) + def loadConfig[F[_]: Async](args: List[String]): F[Config] = { + val logger = Logger.log4s(unsafeLogger) + ConfigFactory + .default[F, Config](logger, "docspell.server")(args) + .map(cfg => Validate(cfg)) + } object Implicits { implicit val signupModeReader: ConfigReader[SignupConfig.Mode] = @@ -50,12 +61,25 @@ object ConfigFile { def all(cfg: Config) = List( duplicateOpenIdProvider(cfg), - signKeyVsUserUrl(cfg) + signKeyVsUserUrl(cfg), + generateSecretIfEmpty(cfg) ) private def valid(cfg: Config): ValidatedNec[String, Config] = Validated.validNec(cfg) + def generateSecretIfEmpty(cfg: Config): ValidatedNec[String, Config] = + if (cfg.auth.serverSecret.isEmpty) { + unsafeLogger.warn( + "No serverSecret specified. Generating a random one. It is recommended to add a server-secret in the config file." + ) + val random = new SecureRandom() + val buffer = new Array[Byte](32) + random.nextBytes(buffer) + val secret = ByteVector.view(buffer) + valid(cfg.copy(auth = cfg.auth.copy(serverSecret = secret))) + } else valid(cfg) + def duplicateOpenIdProvider(cfg: Config): ValidatedNec[String, Config] = { val dupes = cfg.openid diff --git a/modules/restserver/src/main/scala/docspell/restserver/Main.scala b/modules/restserver/src/main/scala/docspell/restserver/Main.scala index d8d838ac..5907ff41 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Main.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Main.scala @@ -6,46 +6,21 @@ package docspell.restserver -import java.nio.file.{Files, Paths} - import cats.effect._ -import cats.implicits._ import docspell.common._ -import org.log4s._ +import org.log4s.getLogger object Main extends IOApp { - private[this] val logger = getLogger + private[this] val logger: Logger[IO] = Logger.log4s(getLogger) - val blockingEC = - ThreadFactories.cached[IO](ThreadFactories.ofName("docspell-restserver-blocking")) - val connectEC = + private val connectEC = ThreadFactories.fixed[IO](5, ThreadFactories.ofName("docspell-dbconnect")) - val restserverEC = - ThreadFactories.workSteal[IO](ThreadFactories.ofNameFJ("docspell-restserver")) - def run(args: List[String]) = { - args match { - case file :: Nil => - val path = Paths.get(file).toAbsolutePath.normalize - logger.info(s"Using given config file: $path") - System.setProperty("config.file", file) - case _ => - Option(System.getProperty("config.file")) match { - case Some(f) if f.nonEmpty => - val path = Paths.get(f).toAbsolutePath.normalize - if (!Files.exists(path)) { - logger.info(s"Not using config file '$f' because it doesn't exist") - System.clearProperty("config.file") - } else - logger.info(s"Using config file from system properties: $f") - case _ => - } - } - - val cfg = ConfigFile.loadConfig - val banner = Banner( + def run(args: List[String]) = for { + cfg <- ConfigFile.loadConfig[IO](args) + banner = Banner( "REST Server", BuildInfo.version, BuildInfo.gitHeadCommit, @@ -55,23 +30,20 @@ object Main extends IOApp { cfg.baseUrl, Some(cfg.fullTextSearch.solr.url).filter(_ => cfg.fullTextSearch.enabled) ) - val pools = for { - cec <- connectEC - bec <- blockingEC - rec <- restserverEC - } yield Pools(cec, bec, rec) + _ <- logger.info(s"\n${banner.render("***>")}") + _ <- + if (EnvMode.current.isDev) { + logger.warn(">>>>> Docspell is running in DEV mode! <<<<<") + } else IO(()) - logger.info(s"\n${banner.render("***>")}") - if (EnvMode.current.isDev) { - logger.warn(">>>>> Docspell is running in DEV mode! <<<<<") - } - - pools.use(p => - RestServer - .stream[IO](cfg, p) - .compile - .drain - .as(ExitCode.Success) - ) - } + pools = connectEC.map(Pools.apply) + rc <- + pools.use(p => + RestServer + .stream[IO](cfg, p) + .compile + .drain + .as(ExitCode.Success) + ) + } yield rc } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala index fbfbd0e1..74b6a303 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala @@ -32,11 +32,10 @@ object RestAppImpl { def create[F[_]: Async]( cfg: Config, - connectEC: ExecutionContext, - httpClientEc: ExecutionContext + connectEC: ExecutionContext ): Resource[F, RestApp[F]] = for { - backend <- BackendApp(cfg.backend, connectEC, httpClientEc)( + backend <- BackendApp(cfg.backend, connectEC)( createFtsClient[F](cfg) ) app = new RestAppImpl[F](cfg, backend) diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 3cc244fb..f64f0a93 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -10,7 +10,7 @@ import cats.effect._ import cats.implicits._ import fs2.Stream -import docspell.backend.auth.AuthToken +import docspell.backend.auth.{AuthToken, ShareToken} import docspell.common._ import docspell.oidc.CodeFlowRoutes import docspell.restserver.auth.OpenId @@ -34,19 +34,20 @@ object RestServer { val templates = TemplateRoutes[F](cfg) val app = for { - restApp <- - RestAppImpl - .create[F](cfg, pools.connectEC, pools.httpClientEC) - httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource + restApp <- RestAppImpl.create[F](cfg, pools.connectEC) + httpClient <- BlazeClientBuilder[F].resource httpApp = Router( "/api/info" -> routes.InfoRoutes(), "/api/v1/open/" -> openRoutes(cfg, httpClient, restApp), "/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token => securedRoutes(cfg, restApp, token) }, - "/api/v1/admin" -> AdminRoutes(cfg.adminEndpoint) { + "/api/v1/admin" -> AdminAuth(cfg.adminEndpoint) { adminRoutes(cfg, restApp) }, + "/api/v1/share" -> ShareAuth(restApp.backend.share, cfg.auth) { token => + shareRoutes(cfg, restApp, token) + }, "/api/doc" -> templates.doc, "/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]), "/app" -> EnvMiddleware(templates.app), @@ -61,7 +62,7 @@ object RestServer { Stream .resource(app) .flatMap(httpApp => - BlazeServerBuilder[F](pools.restEC) + BlazeServerBuilder[F] .bindHttp(cfg.bind.port, cfg.bind.address) .withHttpApp(httpApp) .withoutBanner @@ -94,6 +95,7 @@ object RestServer { "email/send" -> MailSendRoutes(restApp.backend, token), "email/settings" -> MailSettingsRoutes(restApp.backend, token), "email/sent" -> SentMailRoutes(restApp.backend, token), + "share" -> ShareRoutes.manage(restApp.backend, token), "usertask/notifydueitems" -> NotifyDueItemsRoutes(cfg, restApp.backend, token), "usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token), "calevent/check" -> CalEventCheckRoutes(), @@ -119,7 +121,8 @@ object RestServer { "signup" -> RegisterRoutes(restApp.backend, cfg), "upload" -> UploadRoutes.open(restApp.backend, cfg), "checkfile" -> CheckFileRoutes.open(restApp.backend), - "integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg) + "integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg), + "share" -> ShareRoutes.verify(restApp.backend, cfg) ) def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = @@ -131,6 +134,17 @@ object RestServer { "attachments" -> AttachmentRoutes.admin(restApp.backend) ) + def shareRoutes[F[_]: Async]( + cfg: Config, + restApp: RestApp[F], + token: ShareToken + ): HttpRoutes[F] = + Router( + "search" -> ShareSearchRoutes(restApp.backend, cfg, token), + "attachment" -> ShareAttachmentRoutes(restApp.backend, token), + "item" -> ShareItemRoutes(restApp.backend, token) + ) + def redirectTo[F[_]: Async](path: String): HttpRoutes[F] = { val dsl = new Http4sDsl[F] {} import dsl._ diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/ShareCookieData.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/ShareCookieData.scala new file mode 100644 index 00000000..0c3b0bdf --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/ShareCookieData.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.auth + +import docspell.backend.auth.ShareToken +import docspell.common._ + +import org.http4s._ +import org.typelevel.ci.CIString + +final case class ShareCookieData(token: ShareToken) { + def asString: String = token.asString + + def asCookie(baseUrl: LenientUri): ResponseCookie = { + val sec = baseUrl.scheme.exists(_.endsWith("s")) + val path = baseUrl.path / "api" / "v1" + ResponseCookie( + name = ShareCookieData.cookieName, + content = asString, + domain = None, + path = Some(path.asString), + httpOnly = true, + secure = sec, + maxAge = None, + expires = None + ) + } + + def addCookie[F[_]](baseUrl: LenientUri)( + resp: Response[F] + ): Response[F] = + resp.addCookie(asCookie(baseUrl)) +} + +object ShareCookieData { + val cookieName = "docspell_share" + val headerName = "Docspell-Share-Auth" + + def fromCookie[F[_]](req: Request[F]): Option[String] = + for { + header <- req.headers.get[headers.Cookie] + cookie <- header.values.toList.find(_.name == cookieName) + } yield cookie.content + + def fromHeader[F[_]](req: Request[F]): Option[String] = + req.headers + .get(CIString(headerName)) + .map(_.head.value) + + def fromRequest[F[_]](req: Request[F]): Option[String] = + fromCookie(req).orElse(fromHeader(req)) + + def delete(baseUrl: LenientUri): ResponseCookie = + ResponseCookie( + name = cookieName, + content = "", + domain = None, + path = Some(baseUrl.path / "api" / "v1").map(_.asString), + httpOnly = true, + secure = baseUrl.scheme.exists(_.endsWith("s")), + maxAge = None, + expires = None + ) + +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 7cc03c6b..03142eaf 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -22,7 +22,7 @@ import docspell.common.syntax.all._ import docspell.ftsclient.FtsResult import docspell.restapi.model._ import docspell.restserver.conv.Conversions._ -import docspell.store.queries.{AttachmentLight => QAttachmentLight} +import docspell.store.queries.{AttachmentLight => QAttachmentLight, IdRefCount} import docspell.store.records._ import docspell.store.{AddResult, UpdateResult} @@ -38,9 +38,16 @@ trait Conversions { mkTagCloud(sum.tags), mkTagCategoryCloud(sum.cats), sum.fields.map(mkFieldStats), - sum.folders.map(mkFolderStats) + sum.folders.map(mkFolderStats), + sum.corrOrgs.map(mkIdRefStats), + sum.corrPers.map(mkIdRefStats), + sum.concPers.map(mkIdRefStats), + sum.concEquip.map(mkIdRefStats) ) + def mkIdRefStats(s: IdRefCount): IdRefStats = + IdRefStats(mkIdName(s.ref), s.count) + def mkFolderStats(fs: docspell.store.queries.FolderCount): FolderStats = FolderStats(fs.id, fs.name, mkIdName(fs.owner), fs.count) diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala index e97eda8f..7ebdb9b3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala @@ -11,7 +11,10 @@ import cats.data.OptionT import cats.effect._ import cats.implicits._ +import docspell.backend.ops.OItemSearch.{AttachmentData, AttachmentPreviewData} import docspell.backend.ops._ +import docspell.restapi.model.BasicResult +import docspell.restserver.http4s.{QueryParam => QP} import docspell.store.records.RFileMeta import org.http4s._ @@ -23,6 +26,68 @@ import org.typelevel.ci.CIString object BinaryUtil { + def respond[F[_]: Async](dsl: Http4sDsl[F], req: Request[F])( + fileData: Option[AttachmentData[F]] + ): F[Response[F]] = { + import dsl._ + + val inm = req.headers.get[`If-None-Match`].flatMap(_.tags) + val matches = BinaryUtil.matchETag(fileData.map(_.meta), inm) + fileData + .map { data => + if (matches) withResponseHeaders(dsl, NotModified())(data) + else makeByteResp(dsl)(data) + } + .getOrElse(NotFound(BasicResult(false, "Not found"))) + } + + def respondHead[F[_]: Async](dsl: Http4sDsl[F])( + fileData: Option[AttachmentData[F]] + ): F[Response[F]] = { + import dsl._ + + fileData + .map(data => withResponseHeaders(dsl, Ok())(data)) + .getOrElse(NotFound(BasicResult(false, "Not found"))) + } + + def respondPreview[F[_]: Async](dsl: Http4sDsl[F], req: Request[F])( + fileData: Option[AttachmentPreviewData[F]] + ): F[Response[F]] = { + import dsl._ + def notFound = + NotFound(BasicResult(false, "Not found")) + + QP.WithFallback.unapply(req.multiParams) match { + case Some(bool) => + val fallback = bool.getOrElse(false) + val inm = req.headers.get[`If-None-Match`].flatMap(_.tags) + val matches = matchETag(fileData.map(_.meta), inm) + + fileData + .map { data => + if (matches) withResponseHeaders(dsl, NotModified())(data) + else makeByteResp(dsl)(data) + } + .getOrElse( + if (fallback) BinaryUtil.noPreview(req.some).getOrElseF(notFound) + else notFound + ) + + case None => + BadRequest(BasicResult(false, "Invalid query parameter 'withFallback'")) + } + } + + def respondPreviewHead[F[_]: Async]( + dsl: Http4sDsl[F] + )(fileData: Option[AttachmentPreviewData[F]]): F[Response[F]] = { + import dsl._ + fileData + .map(data => withResponseHeaders(dsl, Ok())(data)) + .getOrElse(NotFound(BasicResult(false, "Not found"))) + } + def withResponseHeaders[F[_]: Sync](dsl: Http4sDsl[F], resp: F[Response[F]])( data: OItemSearch.BinaryData[F] ): F[Response[F]] = { diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index 102325da..041814cf 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -16,7 +16,7 @@ import docspell.common.SearchMode import org.http4s.ParseFailure import org.http4s.QueryParamDecoder -import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher +import org.http4s.dsl.impl.{FlagQueryParamMatcher, OptionalQueryParamDecoderMatcher} object QueryParam { case class QueryString(q: String) @@ -67,6 +67,7 @@ object QueryParam { object FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full") object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning") + object OwningFlag extends FlagQueryParamMatcher("owning") object ContactKindOpt extends OptionalQueryParamDecoderMatcher[ContactKind]("kind") diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/AdminRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/AdminAuth.scala similarity index 92% rename from modules/restserver/src/main/scala/docspell/restserver/routes/AdminRoutes.scala rename to modules/restserver/src/main/scala/docspell/restserver/routes/AdminAuth.scala index 59491091..333f8d10 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/AdminRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/AdminAuth.scala @@ -10,6 +10,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect._ import cats.implicits._ +import docspell.common.Password import docspell.restserver.Config import docspell.restserver.http4s.Responses @@ -19,7 +20,7 @@ import org.http4s.dsl.Http4sDsl import org.http4s.server._ import org.typelevel.ci.CIString -object AdminRoutes { +object AdminAuth { private val adminHeader = CIString("Docspell-Admin-Secret") def apply[F[_]: Async](cfg: Config.AdminEndpoint)( @@ -55,6 +56,5 @@ object AdminRoutes { req.headers.get(adminHeader).map(_.head.value) private def compareSecret(s1: String)(s2: String): Boolean = - s1.length > 0 && s1.length == s2.length && - s1.zip(s2).forall { case (a, b) => a == b } + Password(s1).compare(Password(s2)) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala index 9b5d52aa..a7bc4b82 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala @@ -17,7 +17,6 @@ import docspell.common.MakePreviewArgs import docspell.restapi.model._ import docspell.restserver.conv.Conversions import docspell.restserver.http4s.BinaryUtil -import docspell.restserver.http4s.{QueryParam => QP} import docspell.restserver.webapp.Webjars import org.http4s._ @@ -47,24 +46,13 @@ object AttachmentRoutes { case HEAD -> Root / Ident(id) => for { fileData <- backend.itemSearch.findAttachment(id, user.account.collective) - resp <- - fileData - .map(data => withResponseHeaders(Ok())(data)) - .getOrElse(NotFound(BasicResult(false, "Not found"))) + resp <- BinaryUtil.respondHead(dsl)(fileData) } yield resp case req @ GET -> Root / Ident(id) => for { fileData <- backend.itemSearch.findAttachment(id, user.account.collective) - inm = req.headers.get[`If-None-Match`].flatMap(_.tags) - matches = BinaryUtil.matchETag(fileData.map(_.meta), inm) - resp <- - fileData - .map { data => - if (matches) withResponseHeaders(NotModified())(data) - else makeByteResp(data) - } - .getOrElse(NotFound(BasicResult(false, "Not found"))) + resp <- BinaryUtil.respond[F](dsl, req)(fileData) } yield resp case HEAD -> Root / Ident(id) / "original" => @@ -115,35 +103,18 @@ object AttachmentRoutes { .getOrElse(NotFound(BasicResult(false, "Not found"))) } yield resp - case req @ GET -> Root / Ident(id) / "preview" :? QP.WithFallback(flag) => - def notFound = - NotFound(BasicResult(false, "Not found")) + case req @ GET -> Root / Ident(id) / "preview" => for { fileData <- backend.itemSearch.findAttachmentPreview(id, user.account.collective) - inm = req.headers.get[`If-None-Match`].flatMap(_.tags) - matches = BinaryUtil.matchETag(fileData.map(_.meta), inm) - fallback = flag.getOrElse(false) - resp <- - fileData - .map { data => - if (matches) withResponseHeaders(NotModified())(data) - else makeByteResp(data) - } - .getOrElse( - if (fallback) BinaryUtil.noPreview(req.some).getOrElseF(notFound) - else notFound - ) + resp <- BinaryUtil.respondPreview(dsl, req)(fileData) } yield resp case HEAD -> Root / Ident(id) / "preview" => for { fileData <- backend.itemSearch.findAttachmentPreview(id, user.account.collective) - resp <- - fileData - .map(data => withResponseHeaders(Ok())(data)) - .getOrElse(NotFound(BasicResult(false, "Not found"))) + resp <- BinaryUtil.respondPreviewHead(dsl)(fileData) } yield resp case POST -> Root / Ident(id) / "preview" => diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index 75690d64..f1cd03d8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -12,8 +12,7 @@ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OCollective -import docspell.common.EmptyTrashArgs -import docspell.common.ListType +import docspell.common._ import docspell.restapi.model._ import docspell.restserver.conv.Conversions import docspell.restserver.http4s._ @@ -62,7 +61,8 @@ object CollectiveRoutes { settings.emptyTrash.schedule, settings.emptyTrash.minAge ) - ) + ), + settings.passwords.map(Password.apply) ) res <- backend.collective @@ -89,7 +89,8 @@ object CollectiveRoutes { EmptyTrashSetting( trash.schedule, trash.minAge - ) + ), + settDb.map(_.passwords).getOrElse(Nil).map(_.pass) ) ) resp <- sett.toResponse() diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 8ea503bd..5db15101 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -28,11 +28,11 @@ import docspell.restserver.http4s.BinaryUtil import docspell.restserver.http4s.Responses import docspell.restserver.http4s.{QueryParam => QP} -import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ import org.http4s.dsl.Http4sDsl import org.http4s.headers._ +import org.http4s.{HttpRoutes, Response} import org.log4s._ object ItemRoutes { @@ -415,7 +415,11 @@ object ItemRoutes { def searchItems[F[_]: Sync]( backend: BackendApp[F], dsl: Http4sDsl[F] - )(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { + )( + settings: OSimpleSearch.Settings, + fixQuery: Query.Fix, + itemQuery: ItemQueryString + ): F[Response[F]] = { import dsl._ def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList = @@ -452,14 +456,14 @@ object ItemRoutes { } } - private def searchItemStats[F[_]: Sync]( + def searchItemStats[F[_]: Sync]( backend: BackendApp[F], dsl: Http4sDsl[F] )( settings: OSimpleSearch.StatsSettings, fixQuery: Query.Fix, itemQuery: ItemQueryString - ) = { + ): F[Response[F]] = { import dsl._ backend.simpleSearch @@ -479,7 +483,6 @@ object ItemRoutes { case StringSearchResult.ParseFailed(pf) => BadRequest(BasicResult(false, s"Error reading query: ${pf.render}")) } - } implicit final class OptionString(opt: Option[String]) { diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala index d8591aa0..798dd719 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala @@ -11,8 +11,7 @@ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken -import docspell.backend.ops.OMail.{AttachSelection, ItemMail} -import docspell.backend.ops.SendResult +import docspell.backend.ops.OMail.{AttachSelection, ItemMail, SendResult} import docspell.common._ import docspell.restapi.model._ diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala new file mode 100644 index 00000000..b93a1381 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.ShareToken +import docspell.common._ +import docspell.restserver.http4s.BinaryUtil +import docspell.restserver.webapp.Webjars + +import org.http4s._ +import org.http4s.dsl.Http4sDsl +import org.http4s.headers._ + +object ShareAttachmentRoutes { + + def apply[F[_]: Async]( + backend: BackendApp[F], + token: ShareToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case HEAD -> Root / Ident(id) => + for { + fileData <- backend.share.findAttachment(id, token.id).value + resp <- BinaryUtil.respondHead(dsl)(fileData) + } yield resp + + case req @ GET -> Root / Ident(id) => + for { + fileData <- backend.share.findAttachment(id, token.id).value + resp <- BinaryUtil.respond(dsl, req)(fileData) + } yield resp + + case GET -> Root / Ident(id) / "view" => + // this route exists to provide a stable url + // it redirects currently to viewerjs + val attachUrl = s"/api/v1/share/attachment/${id.id}" + val path = s"/app/assets${Webjars.viewerjs}/index.html#$attachUrl" + SeeOther(Location(Uri(path = Uri.Path.unsafeFromString(path)))) + + case req @ GET -> Root / Ident(id) / "preview" => + for { + fileData <- backend.share.findAttachmentPreview(id, token.id).value + resp <- BinaryUtil.respondPreview(dsl, req)(fileData) + } yield resp + + case HEAD -> Root / Ident(id) / "preview" => + for { + fileData <- backend.share.findAttachmentPreview(id, token.id).value + resp <- BinaryUtil.respondPreviewHead(dsl)(fileData) + } yield resp + } + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAuth.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAuth.scala new file mode 100644 index 00000000..ad2c41ab --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAuth.scala @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import cats.implicits._ + +import docspell.backend.auth.{Login, ShareToken} +import docspell.backend.ops.OShare +import docspell.backend.ops.OShare.VerifyResult +import docspell.restserver.auth.ShareCookieData + +import org.http4s._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import org.http4s.server._ + +object ShareAuth { + + def authenticateRequest[F[_]: Async]( + validate: String => F[VerifyResult] + )(req: Request[F]): F[OShare.VerifyResult] = + ShareCookieData.fromRequest(req) match { + case Some(tokenStr) => + validate(tokenStr) + case None => + VerifyResult.notFound.pure[F] + } + + private def getToken[F[_]: Async]( + auth: String => F[VerifyResult] + ): Kleisli[F, Request[F], Either[String, ShareToken]] = + Kleisli(r => authenticateRequest(auth)(r).map(_.toEither)) + + def of[F[_]: Async](S: OShare[F], cfg: Login.Config)( + pf: PartialFunction[AuthedRequest[F, ShareToken], F[Response[F]]] + ): HttpRoutes[F] = { + val dsl: Http4sDsl[F] = new Http4sDsl[F] {} + import dsl._ + + val authUser = getToken[F](S.verifyToken(cfg.serverSecret)) + + val onFailure: AuthedRoutes[String, F] = + Kleisli(req => OptionT.liftF(Forbidden(req.context))) + + val middleware: AuthMiddleware[F, ShareToken] = + AuthMiddleware(authUser, onFailure) + + middleware(AuthedRoutes.of(pf)) + } + + def apply[F[_]: Async](S: OShare[F], cfg: Login.Config)( + f: ShareToken => HttpRoutes[F] + ): HttpRoutes[F] = { + val dsl: Http4sDsl[F] = new Http4sDsl[F] {} + import dsl._ + + val authUser = getToken[F](S.verifyToken(cfg.serverSecret)) + + val onFailure: AuthedRoutes[String, F] = + Kleisli(req => OptionT.liftF(Forbidden(req.context))) + + val middleware: AuthMiddleware[F, ShareToken] = + AuthMiddleware(authUser, onFailure) + + middleware(AuthedRoutes(authReq => f(authReq.context).run(authReq.req))) + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareItemRoutes.scala new file mode 100644 index 00000000..38c3d041 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareItemRoutes.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.ShareToken +import docspell.common._ +import docspell.restapi.model.BasicResult +import docspell.restserver.conv.Conversions + +import org.http4s._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ShareItemRoutes { + + def apply[F[_]: Async]( + backend: BackendApp[F], + token: ShareToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { case GET -> Root / Ident(id) => + for { + item <- backend.share.findItem(id, token.id).value + result = item.map(Conversions.mkItemDetail) + resp <- + result + .map(r => Ok(r)) + .getOrElse(NotFound(BasicResult(false, "Not found."))) + } yield resp + } + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala new file mode 100644 index 00000000..4106642f --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareRoutes.scala @@ -0,0 +1,178 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.backend.ops.OShare +import docspell.backend.ops.OShare.{SendResult, ShareMail, VerifyResult} +import docspell.common.{Ident, Timestamp} +import docspell.restapi.model._ +import docspell.restserver.Config +import docspell.restserver.auth.ShareCookieData +import docspell.restserver.http4s.{ClientRequestInfo, QueryParam => QP, ResponseGenerator} + +import emil.MailAddress +import emil.javamail.syntax._ +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ShareRoutes { + + def manage[F[_]: Async](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root :? QP.Query(q) :? QP.OwningFlag(owning) => + val login = if (owning) Some(user.account.user) else None + for { + all <- backend.share.findAll(user.account.collective, login, q) + now <- Timestamp.current[F] + res <- Ok(ShareList(all.map(mkShareDetail(now)))) + } yield res + + case req @ POST -> Root => + for { + data <- req.as[ShareData] + share = mkNewShare(data, user) + res <- backend.share.addNew(share) + resp <- Ok(mkIdResult(res, "New share created.")) + } yield resp + + case GET -> Root / Ident(id) => + (for { + share <- backend.share.findOne(id, user.account.collective) + now <- OptionT.liftF(Timestamp.current[F]) + resp <- OptionT.liftF(Ok(mkShareDetail(now)(share))) + } yield resp).getOrElseF(NotFound()) + + case req @ PUT -> Root / Ident(id) => + for { + data <- req.as[ShareData] + share = mkNewShare(data, user) + updated <- backend.share.update(id, share, data.removePassword.getOrElse(false)) + resp <- Ok(mkBasicResult(updated, "Share updated.")) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + del <- backend.share.delete(id, user.account.collective) + resp <- Ok(BasicResult(del, if (del) "Share deleted." else "Deleting failed.")) + } yield resp + + case req @ POST -> Root / "email" / "send" / Ident(name) => + for { + in <- req.as[SimpleShareMail] + mail = convertIn(in) + res <- mail.traverse(m => backend.share.sendMail(user.account, name, m)) + resp <- res.fold( + err => Ok(BasicResult(false, s"Invalid mail data: $err")), + res => Ok(convertOut(res)) + ) + } yield resp + } + } + + def verify[F[_]: Async](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + import dsl._ + + HttpRoutes.of { case req @ POST -> Root / "verify" => + for { + secret <- req.as[ShareSecret] + res <- backend.share + .verify(cfg.auth.serverSecret)(secret.shareId, secret.password) + resp <- res match { + case VerifyResult.Success(token, name) => + val cd = ShareCookieData(token) + Ok(ShareVerifyResult(true, token.asString, false, "Success", name)) + .map(cd.addCookie(ClientRequestInfo.getBaseUrl(cfg, req))) + case VerifyResult.PasswordMismatch => + Ok(ShareVerifyResult(false, "", true, "Failed", None)) + case VerifyResult.NotFound => + Ok(ShareVerifyResult(false, "", false, "Failed", None)) + case VerifyResult.InvalidToken => + Ok(ShareVerifyResult(false, "", false, "Failed", None)) + } + } yield resp + } + } + + def mkNewShare(data: ShareData, user: AuthToken): OShare.NewShare = + OShare.NewShare( + user.account, + data.name, + data.query, + data.enabled, + data.password, + data.publishUntil + ) + + def mkIdResult(r: OShare.ChangeResult, msg: => String): IdResult = + r match { + case OShare.ChangeResult.Success(id) => IdResult(true, msg, id) + case OShare.ChangeResult.PublishUntilInPast => + IdResult(false, "Until date must not be in the past", Ident.unsafe("")) + case OShare.ChangeResult.NotFound => + IdResult( + false, + "Share not found or not owner. Only the owner can update a share.", + Ident.unsafe("") + ) + } + + def mkBasicResult(r: OShare.ChangeResult, msg: => String): BasicResult = + r match { + case OShare.ChangeResult.Success(_) => BasicResult(true, msg) + case OShare.ChangeResult.PublishUntilInPast => + BasicResult(false, "Until date must not be in the past") + case OShare.ChangeResult.NotFound => + BasicResult( + false, + "Share not found or not owner. Only the owner can update a share." + ) + } + + def mkShareDetail(now: Timestamp)(r: OShare.ShareData): ShareDetail = + ShareDetail( + r.share.id, + r.share.query, + IdName(r.user.uid, r.user.login.id), + r.share.name, + r.share.enabled, + r.share.publishAt, + r.share.publishUntil, + now > r.share.publishUntil, + r.share.password.isDefined, + r.share.views, + r.share.lastAccess + ) + + def convertIn(s: SimpleShareMail): Either[String, ShareMail] = + for { + rec <- s.recipients.traverse(MailAddress.parse) + cc <- s.cc.traverse(MailAddress.parse) + bcc <- s.bcc.traverse(MailAddress.parse) + } yield ShareMail(s.shareId, s.subject, rec, cc, bcc, s.body) + + def convertOut(res: SendResult): BasicResult = + res match { + case SendResult.Success(_) => + BasicResult(true, "Mail sent.") + case SendResult.SendFailure(ex) => + BasicResult(false, s"Mail sending failed: ${ex.getMessage}") + case SendResult.NotFound => + BasicResult(false, s"There was no mail-connection or item found.") + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala new file mode 100644 index 00000000..64e14a5e --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.ShareToken +import docspell.backend.ops.OSimpleSearch +import docspell.backend.ops.OSimpleSearch.StringSearchResult +import docspell.common._ +import docspell.query.FulltextExtract.Result.{TooMany, UnsupportedPosition} +import docspell.restapi.model._ +import docspell.restserver.Config +import docspell.restserver.conv.Conversions +import docspell.store.qb.Batch +import docspell.store.queries.{Query, SearchSummary} + +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import org.http4s.{HttpRoutes, Response} + +object ShareSearchRoutes { + + def apply[F[_]: Async]( + backend: BackendApp[F], + cfg: Config, + token: ShareToken + ): HttpRoutes[F] = { + val logger = Logger.log4s[F](org.log4s.getLogger) + + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ POST -> Root / "query" => + backend.share + .findShareQuery(token.id) + .semiflatMap { share => + for { + userQuery <- req.as[ItemQuery] + batch = Batch( + userQuery.offset.getOrElse(0), + userQuery.limit.getOrElse(cfg.maxItemPageSize) + ).restrictLimitTo( + cfg.maxItemPageSize + ) + itemQuery = ItemQueryString(userQuery.query) + settings = OSimpleSearch.Settings( + batch, + cfg.fullTextSearch.enabled, + userQuery.withDetails.getOrElse(false), + cfg.maxNoteLength, + searchMode = SearchMode.Normal + ) + account = share.account + fixQuery = Query.Fix(account, Some(share.query.expr), None) + _ <- logger.debug(s"Searching in share ${share.id.id}: ${userQuery.query}") + resp <- ItemRoutes.searchItems(backend, dsl)(settings, fixQuery, itemQuery) + } yield resp + } + .getOrElseF(NotFound()) + + case req @ POST -> Root / "stats" => + for { + userQuery <- req.as[ItemQuery] + itemQuery = ItemQueryString(userQuery.query) + settings = OSimpleSearch.StatsSettings( + useFTS = cfg.fullTextSearch.enabled, + searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal) + ) + stats <- backend.share.searchSummary(settings)(token.id, itemQuery).value + resp <- stats.map(mkSummaryResponse(dsl)).getOrElse(NotFound()) + } yield resp + } + } + + def mkSummaryResponse[F[_]: Sync]( + dsl: Http4sDsl[F] + )(r: StringSearchResult[SearchSummary]): F[Response[F]] = { + import dsl._ + r match { + case StringSearchResult.Success(summary) => + Ok(Conversions.mkSearchStats(summary)) + case StringSearchResult.FulltextMismatch(TooMany) => + BadRequest(BasicResult(false, "Fulltext search is not possible in this share.")) + case StringSearchResult.FulltextMismatch(UnsupportedPosition) => + BadRequest( + BasicResult( + false, + "Fulltext search must be in root position or inside the first AND." + ) + ) + case StringSearchResult.ParseFailed(pf) => + BadRequest(BasicResult(false, s"Error reading query: ${pf.render}")) + } + } + +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala index 35944fca..ef4d75d8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala @@ -72,7 +72,9 @@ object UserRoutes { data <- backend.collective.getDeleteUserData( AccountId(user.account.collective, username) ) - resp <- Ok(DeleteUserData(data.ownedFolders.map(_.id), data.sentMails)) + resp <- Ok( + DeleteUserData(data.ownedFolders.map(_.id), data.sentMails, data.shares) + ) } yield resp } } diff --git a/modules/restserver/src/main/templates/index.html b/modules/restserver/src/main/templates/index.html index 6ae141fb..2a61f28c 100644 --- a/modules/restserver/src/main/templates/index.html +++ b/modules/restserver/src/main/templates/index.html @@ -43,8 +43,21 @@ // this is required for transitioning; elm fails to parse the account account["requireSecondFactor"] = false; } + + // hack to guess if the browser can display PDFs natively. It + // seems that almost all browsers allow to query the + // navigator.mimeTypes array, except firefox. + var ua = navigator.userAgent.toLowerCase(); + var pdfSupported = false; + if (ua.indexOf("firefox") > -1) { + pdfSupported = ua.indexOf("mobile") == -1; + } else { + pdfSupported = "application/pdf" in navigator.mimeTypes; + } + var elmFlags = { "account": account, + "pdfSupported": pdfSupported, "config": {{{flagsJson}}} }; diff --git a/modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql b/modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql new file mode 100644 index 00000000..223fa8d5 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql @@ -0,0 +1,7 @@ +CREATE TABLE "collective_password" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "pass" varchar(254) not null, + "created" timestamp not null, + foreign key ("cid") references "collective"("cid") on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql b/modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql new file mode 100644 index 00000000..9765afc1 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.27.1__item_share.sql @@ -0,0 +1,13 @@ +CREATE TABLE "item_share" ( + "id" varchar(254) not null primary key, + "user_id" varchar(254) not null, + "name" varchar(254), + "query" varchar(2000) not null, + "enabled" boolean not null, + "pass" varchar(254), + "publish_at" timestamp not null, + "publish_until" timestamp not null, + "views" int not null, + "last_access" timestamp, + foreign key ("user_id") references "user_"("uid") on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql new file mode 100644 index 00000000..2224e560 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql @@ -0,0 +1,7 @@ +CREATE TABLE `collective_password` ( + `id` varchar(254) not null primary key, + `cid` varchar(254) not null, + `pass` varchar(254) not null, + `created` timestamp not null, + foreign key (`cid`) references `collective`(`cid`) on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql new file mode 100644 index 00000000..714aabbb --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.27.1__item_share.sql @@ -0,0 +1,13 @@ +CREATE TABLE `item_share` ( + `id` varchar(254) not null primary key, + `user_id` varchar(254) not null, + `name` varchar(254), + `query` varchar(2000) not null, + `enabled` boolean not null, + `pass` varchar(254), + `publish_at` timestamp not null, + `publish_until` timestamp not null, + `views` int not null, + `last_access` timestamp, + foreign key (`user_id`) references `user_`(`uid`) on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql new file mode 100644 index 00000000..223fa8d5 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql @@ -0,0 +1,7 @@ +CREATE TABLE "collective_password" ( + "id" varchar(254) not null primary key, + "cid" varchar(254) not null, + "pass" varchar(254) not null, + "created" timestamp not null, + foreign key ("cid") references "collective"("cid") on delete cascade +) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql new file mode 100644 index 00000000..9765afc1 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.27.1__item_share.sql @@ -0,0 +1,13 @@ +CREATE TABLE "item_share" ( + "id" varchar(254) not null primary key, + "user_id" varchar(254) not null, + "name" varchar(254), + "query" varchar(2000) not null, + "enabled" boolean not null, + "pass" varchar(254), + "publish_at" timestamp not null, + "publish_until" timestamp not null, + "views" int not null, + "last_access" timestamp, + foreign key ("user_id") references "user_"("uid") on delete cascade +) diff --git a/modules/store/src/main/scala/docspell/store/Store.scala b/modules/store/src/main/scala/docspell/store/Store.scala index f68e353c..338b177a 100644 --- a/modules/store/src/main/scala/docspell/store/Store.scala +++ b/modules/store/src/main/scala/docspell/store/Store.scala @@ -9,6 +9,7 @@ package docspell.store import scala.concurrent.ExecutionContext import cats.effect._ +import cats.~> import fs2._ import docspell.store.file.FileStore @@ -19,6 +20,7 @@ import doobie._ import doobie.hikari.HikariTransactor trait Store[F[_]] { + def transform: ConnectionIO ~> F def transact[A](prg: ConnectionIO[A]): F[A] diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 1d69da70..4c94b1c2 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -11,6 +11,7 @@ import java.time.{Instant, LocalDate} import docspell.common._ import docspell.common.syntax.all._ +import docspell.query.{ItemQuery, ItemQueryParser} import docspell.totp.Key import com.github.eikek.calev.CalEvent @@ -142,6 +143,11 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaByteSize: Meta[ByteSize] = Meta[Long].timap(ByteSize.apply)(_.bytes) + + implicit val metaItemQuery: Meta[ItemQuery] = + Meta[String].timap(s => ItemQueryParser.parseUnsafe(s))(q => + q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr)) + ) } object DoobieMeta extends DoobieMeta { diff --git a/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala b/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala index f0f622bb..50c856b1 100644 --- a/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala +++ b/modules/store/src/main/scala/docspell/store/impl/StoreImpl.scala @@ -6,8 +6,10 @@ package docspell.store.impl +import cats.arrow.FunctionK import cats.effect.Async import cats.implicits._ +import cats.~> import docspell.store.file.FileStore import docspell.store.migrate.FlywayMigrate @@ -22,6 +24,9 @@ final class StoreImpl[F[_]: Async]( xa: Transactor[F] ) extends Store[F] { + def transform: ConnectionIO ~> F = + FunctionK.lift(transact) + def migrate: F[Int] = FlywayMigrate.run[F](jdbc).map(_.migrationsExecuted) diff --git a/modules/store/src/main/scala/docspell/store/queries/IdRefCount.scala b/modules/store/src/main/scala/docspell/store/queries/IdRefCount.scala new file mode 100644 index 00000000..5ac6682d --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/IdRefCount.scala @@ -0,0 +1,11 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.queries + +import docspell.common._ + +final case class IdRefCount(ref: IdRef, count: Int) {} diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 2671fcaa..623b68e0 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -192,7 +192,21 @@ object QItem { cats <- searchTagCategorySummary(today)(q) fields <- searchFieldSummary(today)(q) folders <- searchFolderSummary(today)(q) - } yield SearchSummary(count, tags, cats, fields, folders) + orgs <- searchCorrOrgSummary(today)(q) + corrPers <- searchCorrPersonSummary(today)(q) + concPers <- searchConcPersonSummary(today)(q) + concEquip <- searchConcEquipSummary(today)(q) + } yield SearchSummary( + count, + tags, + cats, + fields, + folders, + orgs, + corrPers, + concPers, + concEquip + ) def searchTagCategorySummary( today: LocalDate @@ -251,6 +265,40 @@ object QItem { .query[Int] .unique + def searchCorrOrgSummary(today: LocalDate)(q: Query): ConnectionIO[List[IdRefCount]] = + searchIdRefSummary(org.oid, org.name, i.corrOrg, today)(q) + + def searchCorrPersonSummary(today: LocalDate)( + q: Query + ): ConnectionIO[List[IdRefCount]] = + searchIdRefSummary(pers0.pid, pers0.name, i.corrPerson, today)(q) + + def searchConcPersonSummary(today: LocalDate)( + q: Query + ): ConnectionIO[List[IdRefCount]] = + searchIdRefSummary(pers1.pid, pers1.name, i.concPerson, today)(q) + + def searchConcEquipSummary(today: LocalDate)( + q: Query + ): ConnectionIO[List[IdRefCount]] = + searchIdRefSummary(equip.eid, equip.name, i.concEquipment, today)(q) + + private def searchIdRefSummary( + idCol: Column[Ident], + nameCol: Column[String], + fkCol: Column[Ident], + today: LocalDate + )(q: Query): ConnectionIO[List[IdRefCount]] = + findItemsBase(q.fix, today, 0).unwrap + .withSelect(select(idCol, nameCol).append(count(idCol).as("num"))) + .changeWhere(c => + c && fkCol.isNotNull && queryCondition(today, q.fix.account.collective, q.cond) + ) + .groupBy(idCol, nameCol) + .build + .query[IdRefCount] + .to[List] + def searchFolderSummary(today: LocalDate)(q: Query): ConnectionIO[List[FolderCount]] = { val fu = RUser.as("fu") findItemsBase(q.fix, today, 0).unwrap diff --git a/modules/store/src/main/scala/docspell/store/queries/QUser.scala b/modules/store/src/main/scala/docspell/store/queries/QUser.scala index c261b670..6000016a 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QUser.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QUser.scala @@ -20,7 +20,8 @@ object QUser { final case class UserData( ownedFolders: List[Ident], - sentMails: Int + sentMails: Int, + shares: Int ) def getUserData(accountId: AccountId): ConnectionIO[UserData] = { @@ -28,6 +29,7 @@ object QUser { val mail = RSentMail.as("m") val mitem = RSentMailItem.as("mi") val user = RUser.as("u") + val share = RShare.as("s") for { uid <- loadUserId(accountId).map(_.getOrElse(Ident.unsafe(""))) @@ -43,7 +45,13 @@ object QUser { .innerJoin(user, user.uid === mail.uid), user.login === accountId.user && user.cid === accountId.collective ).query[Int].unique - } yield UserData(folders, mails) + shares <- run( + select(count(share.id)), + from(share) + .innerJoin(user, user.uid === share.userId), + user.login === accountId.user && user.cid === accountId.collective + ).query[Int].unique + } yield UserData(folders, mails, shares) } def deleteUserAndData(accountId: AccountId): ConnectionIO[Int] = diff --git a/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala b/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala index 0b6a1b1c..c6bff383 100644 --- a/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala +++ b/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala @@ -11,5 +11,23 @@ case class SearchSummary( tags: List[TagCount], cats: List[CategoryCount], fields: List[FieldStats], - folders: List[FolderCount] -) + folders: List[FolderCount], + corrOrgs: List[IdRefCount], + corrPers: List[IdRefCount], + concPers: List[IdRefCount], + concEquip: List[IdRefCount] +) { + + def onlyExisting: SearchSummary = + SearchSummary( + count, + tags.filter(_.count > 0), + cats.filter(_.count > 0), + fields.filter(_.count > 0), + folders.filter(_.count > 0), + corrOrgs = corrOrgs.filter(_.count > 0), + corrPers = corrPers.filter(_.count > 0), + concPers = concPers.filter(_.count > 0), + concEquip = concEquip.filter(_.count > 0) + ) +} diff --git a/modules/store/src/main/scala/docspell/store/records/RCollective.scala b/modules/store/src/main/scala/docspell/store/records/RCollective.scala index dd0afce2..906277c6 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCollective.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCollective.scala @@ -89,7 +89,8 @@ object RCollective { case None => REmptyTrashSetting.delete(cid) } - } yield n1 + n2 + n3 + n4 <- RCollectivePassword.replaceAll(cid, settings.passwords) + } yield n1 + n2 + n3 + n4 // this hides categories that have been deleted in the meantime // they are finally removed from the json array once the learn classifier task is run @@ -99,10 +100,12 @@ object RCollective { prev <- OptionT.fromOption[ConnectionIO](sett.classifier) cats <- OptionT.liftF(RTag.listCategories(coll)) next = prev.copy(categories = prev.categories.intersect(cats)) - } yield sett.copy(classifier = Some(next))).value + pws <- OptionT.liftF(RCollectivePassword.findAll(coll)) + } yield sett.copy(classifier = Some(next), passwords = pws.map(_.password))).value private def getRawSettings(coll: Ident): ConnectionIO[Option[Settings]] = { import RClassifierSetting.stringListMeta + val c = RCollective.as("c") val cs = RClassifierSetting.as("cs") val es = REmptyTrashSetting.as("es") @@ -116,7 +119,8 @@ object RCollective { cs.categories.s, cs.listType.s, es.schedule.s, - es.minAge.s + es.minAge.s, + const(0) //dummy value to load Nil as list of passwords ), from(c).leftJoin(cs, cs.cid === c.id).leftJoin(es, es.cid === c.id), c.id === coll @@ -170,7 +174,11 @@ object RCollective { language: Language, integrationEnabled: Boolean, classifier: Option[RClassifierSetting.Classifier], - emptyTrash: Option[REmptyTrashSetting.EmptyTrash] + emptyTrash: Option[REmptyTrashSetting.EmptyTrash], + passwords: List[Password] ) + implicit val passwordListMeta: Read[List[Password]] = + Read[Int].map(_ => Nil: List[Password]) + } diff --git a/modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala b/modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala new file mode 100644 index 00000000..c7931d20 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.effect._ +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RCollectivePassword( + id: Ident, + cid: Ident, + password: Password, + created: Timestamp +) {} + +object RCollectivePassword { + final case class Table(alias: Option[String]) extends TableDef { + val tableName: String = "collective_password" + + val id = Column[Ident]("id", this) + val cid = Column[Ident]("cid", this) + val password = Column[Password]("pass", this) + val created = Column[Timestamp]("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, cid, password, created) + } + + val T = Table(None) + def as(alias: String): Table = + Table(Some(alias)) + + def createNew[F[_]: Sync](cid: Ident, pw: Password): F[RCollectivePassword] = + for { + id <- Ident.randomId[F] + time <- Timestamp.current[F] + } yield RCollectivePassword(id, cid, pw, time) + + def insert(v: RCollectivePassword): ConnectionIO[Int] = + DML.insert( + T, + T.all, + fr"${v.id}, ${v.cid},${v.password},${v.created}" + ) + + def upsert(v: RCollectivePassword): ConnectionIO[Int] = + for { + k <- deleteByPassword(v.cid, v.password) + n <- insert(v) + } yield n + k + + def deleteById(id: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id) + + def deleteByPassword(cid: Ident, pw: Password): ConnectionIO[Int] = + DML.delete(T, T.password === pw && T.cid === cid) + + def findAll(cid: Ident): ConnectionIO[List[RCollectivePassword]] = + Select(select(T.all), from(T), T.cid === cid).build + .query[RCollectivePassword] + .to[List] + + def replaceAll(cid: Ident, pws: List[Password]): ConnectionIO[Int] = + for { + k <- DML.delete(T, T.cid === cid) + pw <- pws.traverse(p => createNew[ConnectionIO](cid, p)) + n <- + if (pws.isEmpty) 0.pure[ConnectionIO] + else + DML.insertMany( + T, + T.all, + pw.map(p => fr"${p.id},${p.cid},${p.password},${p.created}") + ) + } yield k + n +} diff --git a/modules/store/src/main/scala/docspell/store/records/RShare.scala b/modules/store/src/main/scala/docspell/store/records/RShare.scala new file mode 100644 index 00000000..5ddfdb6b --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RShare.scala @@ -0,0 +1,167 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.{NonEmptyList, OptionT} + +import docspell.common._ +import docspell.query.ItemQuery +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RShare( + id: Ident, + userId: Ident, + name: Option[String], + query: ItemQuery, + enabled: Boolean, + password: Option[Password], + publishAt: Timestamp, + publishUntil: Timestamp, + views: Int, + lastAccess: Option[Timestamp] +) {} + +object RShare { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "item_share"; + + val id = Column[Ident]("id", this) + val userId = Column[Ident]("user_id", this) + val name = Column[String]("name", this) + val query = Column[ItemQuery]("query", this) + val enabled = Column[Boolean]("enabled", this) + val password = Column[Password]("pass", this) + val publishedAt = Column[Timestamp]("publish_at", this) + val publishedUntil = Column[Timestamp]("publish_until", this) + val views = Column[Int]("views", this) + val lastAccess = Column[Timestamp]("last_access", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of( + id, + userId, + name, + query, + enabled, + password, + publishedAt, + publishedUntil, + views, + lastAccess + ) + } + + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + + def insert(r: RShare): ConnectionIO[Int] = + DML.insert( + T, + T.all, + fr"${r.id},${r.userId},${r.name},${r.query},${r.enabled},${r.password},${r.publishAt},${r.publishUntil},${r.views},${r.lastAccess}" + ) + + def incAccess(id: Ident): ConnectionIO[Int] = + for { + curTime <- Timestamp.current[ConnectionIO] + n <- DML.update( + T, + T.id === id, + DML.set(T.views.increment(1), T.lastAccess.setTo(curTime)) + ) + } yield n + + def updateData(r: RShare, removePassword: Boolean): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id && T.userId === r.userId, + DML.set( + T.name.setTo(r.name), + T.query.setTo(r.query), + T.enabled.setTo(r.enabled), + T.publishedUntil.setTo(r.publishUntil) + ) ++ (if (r.password.isDefined || removePassword) + List(T.password.setTo(r.password)) + else Nil) + ) + + def findOne(id: Ident, cid: Ident): OptionT[ConnectionIO, (RShare, RUser)] = { + val s = RShare.as("s") + val u = RUser.as("u") + + OptionT( + Select( + select(s.all, u.all), + from(s).innerJoin(u, u.uid === s.userId), + s.id === id && u.cid === cid + ).build + .query[(RShare, RUser)] + .option + ) + } + + private def activeCondition(t: Table, id: Ident, current: Timestamp): Condition = + t.id === id && t.enabled === true && t.publishedUntil > current + + def findActive( + id: Ident, + current: Timestamp + ): OptionT[ConnectionIO, (RShare, RUser)] = { + val s = RShare.as("s") + val u = RUser.as("u") + + OptionT( + Select( + select(s.all, u.all), + from(s).innerJoin(u, s.userId === u.uid), + activeCondition(s, id, current) + ).build.query[(RShare, RUser)].option + ) + } + + def findCurrentActive(id: Ident): OptionT[ConnectionIO, (RShare, RUser)] = + OptionT.liftF(Timestamp.current[ConnectionIO]).flatMap(now => findActive(id, now)) + + def findActivePassword(id: Ident): OptionT[ConnectionIO, Option[Password]] = + OptionT(Timestamp.current[ConnectionIO].flatMap { now => + Select(select(T.password), from(T), activeCondition(T, id, now)).build + .query[Option[Password]] + .option + }) + + def findAllByCollective( + cid: Ident, + ownerLogin: Option[Ident], + q: Option[String] + ): ConnectionIO[List[(RShare, RUser)]] = { + val s = RShare.as("s") + val u = RUser.as("u") + + val ownerQ = ownerLogin.map(name => u.login === name) + val nameQ = q.map(n => s.name.like(s"%$n%")) + + Select( + select(s.all, u.all), + from(s).innerJoin(u, u.uid === s.userId), + u.cid === cid &&? ownerQ &&? nameQ + ) + .orderBy(s.publishedAt.desc) + .build + .query[(RShare, RUser)] + .to[List] + } + + def deleteByIdAndCid(id: Ident, cid: Ident): ConnectionIO[Int] = { + val u = RUser.T + DML.delete(T, T.id === id && T.userId.in(Select(u.uid.s, from(u), u.cid === cid))) + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index dc8f66d8..dbd4051c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -26,7 +26,13 @@ case class RUser( loginCount: Int, lastLogin: Option[Timestamp], created: Timestamp -) {} +) { + def accountId: AccountId = + AccountId(cid, login) + + def idRef: IdRef = + IdRef(uid, login.id) +} object RUser { diff --git a/modules/webapp/elm.json b/modules/webapp/elm.json index 047c5cda..b1071ec9 100644 --- a/modules/webapp/elm.json +++ b/modules/webapp/elm.json @@ -16,12 +16,13 @@ "elm/html": "1.0.0", "elm/http": "2.0.0", "elm/json": "1.1.3", + "elm/svg": "1.0.1", "elm/time": "1.0.0", "elm/url": "1.0.0", "elm-explorations/markdown": "1.0.0", "justinmimbs/date": "3.1.2", "norpan/elm-html5-drag-drop": "3.1.4", - "pablohirafuji/elm-qrcode": "3.3.1", + "pablohirafuji/elm-qrcode": "4.0.1", "ryannhg/date-format": "2.3.0", "truqu/elm-base64": "2.0.4", "ursi/elm-scroll": "1.0.0", @@ -33,7 +34,6 @@ "elm/bytes": "1.0.8", "elm/parser": "1.1.0", "elm/regex": "1.0.0", - "elm/svg": "1.0.1", "elm/virtual-dom": "1.0.2", "elm-community/list-extra": "8.2.4", "folkertdev/elm-flate": "2.0.4", diff --git a/modules/webapp/package-lock.json b/modules/webapp/package-lock.json index e0a47ae7..d9b88b4e 100644 --- a/modules/webapp/package-lock.json +++ b/modules/webapp/package-lock.json @@ -5,91 +5,147 @@ "requires": true, "dependencies": { "@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "version": "7.15.8", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.15.8.tgz", + "integrity": "sha512-2IAnmn8zbvC/jKYhq5Ki9I+DwjlrtMPUCH/CpHvqI4dNnlwHwsxoIhlc8WcYY5LSYknXQtAlFYuHfqAFCvQ4Wg==", + "dev": true, "requires": { - "@babel/highlight": "^7.10.4" + "@babel/highlight": "^7.14.5" } }, "@babel/helper-validator-identifier": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", - "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==" + "version": "7.15.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz", + "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==", + "dev": true }, "@babel/highlight": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", - "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", + "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", + "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.10.4", + "@babel/helper-validator-identifier": "^7.14.5", "chalk": "^2.0.0", "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } } }, "@fortawesome/fontawesome-free": { - "version": "5.15.3", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.3.tgz", - "integrity": "sha512-rFnSUN/QOtnOAgqFRooTA3H57JLDm0QEG/jPdk+tLQNL/eWd+Aok8g3qCI+Q1xuDPWpGW/i9JySpJVsq8Q0s9w==" - }, - "@fullhuman/postcss-purgecss": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-3.1.3.tgz", - "integrity": "sha512-kwOXw8fZ0Lt1QmeOOrd+o4Ibvp4UTEBFQbzvWldjlKv5n+G9sXfIPn1hh63IQIL8K8vbvv1oYMJiIUbuy9bGaA==", - "requires": { - "purgecss": "^3.1.3" - } + "version": "5.15.4", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", + "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==", + "dev": true }, "@nodelib/fs.scandir": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", - "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "requires": { - "@nodelib/fs.stat": "2.0.4", + "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "@nodelib/fs.stat": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz", - "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true }, "@nodelib/fs.walk": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz", - "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "requires": { - "@nodelib/fs.scandir": "2.1.4", + "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "@tailwindcss/forms": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.3.2.tgz", - "integrity": "sha512-aj2/rJsGb2whAZ/BQWHWWQRSbhH0r/l1ozOByiv+ZNjBD84GMvb5dhAyfpeasFky+EJrAwX5eaqft8NQMZFWvA==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.3.4.tgz", + "integrity": "sha512-vlAoBifNJUkagB+PAdW4aHMe4pKmSLroH398UPgIogBFc91D2VlHUxe4pjxQhiJl0Nfw53sHSJSQBSTQBZP3vA==", + "dev": true, "requires": { "mini-svg-data-uri": "^1.2.3" } }, "@trysound/sax": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.1.1.tgz", - "integrity": "sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow==" + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true }, "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true }, "acorn-node": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dev": true, "requires": { "acorn": "^7.0.0", "acorn-walk": "^7.0.0", @@ -99,118 +155,89 @@ "acorn-walk": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==" + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true }, "alphanum-sort": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", - "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=" + "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", + "dev": true }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true }, "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "requires": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" } }, "anymatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", - "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" + "arg": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", + "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==", + "dev": true }, - "at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" + "array-union": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", + "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", + "dev": true }, "autoprefixer": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.5.tgz", - "integrity": "sha512-7H4AJZXvSsn62SqZyJCP+1AWwOuoYpUfK6ot9vm0e87XD6mT8lDywc9D9OTJPMULyGcvmIxzTAMeG2Cc+YX+fA==", + "version": "10.3.7", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.3.7.tgz", + "integrity": "sha512-EmGpu0nnQVmMhX8ROoJ7Mx8mKYPlcUHuxkwrRYEYMz85lu7H09v8w6R1P0JPdn/hKU32GjpLBFEOuIlDWCRWvg==", + "dev": true, "requires": { - "browserslist": "^4.16.3", - "caniuse-lite": "^1.0.30001196", - "colorette": "^1.2.2", - "fraction.js": "^4.0.13", + "browserslist": "^4.17.3", + "caniuse-lite": "^1.0.30001264", + "fraction.js": "^4.1.1", "normalize-range": "^0.1.2", + "picocolors": "^0.2.1", "postcss-value-parser": "^4.1.0" - }, - "dependencies": { - "browserslist": { - "version": "4.16.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.5.tgz", - "integrity": "sha512-C2HAjrM1AI/djrpAUU/tr4pml1DqLIzJKSLDBXBrNErl9ZCCTXdhwxdJjYc16953+mBWf7Lw+uUJgpgb8cN71A==", - "requires": { - "caniuse-lite": "^1.0.30001214", - "colorette": "^1.2.2", - "electron-to-chromium": "^1.3.719", - "escalade": "^3.1.1", - "node-releases": "^1.1.71" - }, - "dependencies": { - "caniuse-lite": { - "version": "1.0.30001230", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz", - "integrity": "sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==" - } - } - }, - "caniuse-lite": { - "version": "1.0.30001204", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001204.tgz", - "integrity": "sha512-JUdjWpcxfJ9IPamy2f5JaRDCaqJOxDzOSKtbdx4rH9VivMd1vIzoPumsJa9LoMIi4Fx2BV2KZOxWhNkBjaYivQ==" - }, - "colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==" - }, - "electron-to-chromium": { - "version": "1.3.739", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz", - "integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==" - }, - "node-releases": { - "version": "1.1.72", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz", - "integrity": "sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==" - } } }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -220,58 +247,55 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "requires": { "fill-range": "^7.0.1" } }, "browserslist": { - "version": "4.16.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.5.tgz", - "integrity": "sha512-C2HAjrM1AI/djrpAUU/tr4pml1DqLIzJKSLDBXBrNErl9ZCCTXdhwxdJjYc16953+mBWf7Lw+uUJgpgb8cN71A==", + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.17.5.tgz", + "integrity": "sha512-I3ekeB92mmpctWBoLXe0d5wPS2cBuRvvW0JyyJHMrk9/HmP2ZjrTboNAZ8iuGqaEIlKguljbQY32OkOJIRrgoA==", + "dev": true, "requires": { - "caniuse-lite": "^1.0.30001214", - "colorette": "^1.2.2", - "electron-to-chromium": "^1.3.719", + "caniuse-lite": "^1.0.30001271", + "electron-to-chromium": "^1.3.878", "escalade": "^3.1.1", - "node-releases": "^1.1.71" + "node-releases": "^2.0.1", + "picocolors": "^1.0.0" }, "dependencies": { - "caniuse-lite": { - "version": "1.0.30001230", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz", - "integrity": "sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==" - }, - "colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==" - }, - "electron-to-chromium": { - "version": "1.3.739", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz", - "integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==" - }, - "node-releases": { - "version": "1.1.72", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz", - "integrity": "sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==" + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true } } }, "bytes": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "dev": true + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true }, "camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true }, "caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, "requires": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", @@ -280,49 +304,42 @@ } }, "caniuse-lite": { - "version": "1.0.30001208", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz", - "integrity": "sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA==" + "version": "1.0.30001271", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz", + "integrity": "sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==", + "dev": true }, "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "dependencies": { - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" } }, "chokidar": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", - "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "dev": true, "requires": { - "anymatch": "~3.1.1", + "anymatch": "~3.1.2", "braces": "~3.0.2", - "fsevents": "~2.3.1", - "glob-parent": "~5.1.0", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", - "readdirp": "~3.5.0" + "readdirp": "~3.6.0" } }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -330,55 +347,63 @@ } }, "color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz", - "integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/color/-/color-4.0.1.tgz", + "integrity": "sha512-rpZjOKN5O7naJxkH2Rx1sZzzBgaiWECc6BYXjeCE6kF0kcASJYbUq02u7JqIHwCb/j3NhV+QhRL2683aICeGZA==", + "dev": true, "requires": { - "color-convert": "^1.9.1", - "color-string": "^1.5.4" + "color-convert": "^2.0.1", + "color-string": "^1.6.0" } }, "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { - "color-name": "1.1.3" + "color-name": "~1.1.4" } }, "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "color-string": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz", - "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz", + "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==", + "dev": true, "requires": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, - "colorette": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz", - "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==" + "colord": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.1.tgz", + "integrity": "sha512-4LBMSt09vR0uLnPVkOUBnmxgoaeN4ewRbx801wY/bXcltXfpR/G46OdWn96XpYmCWuYvO46aBZP4NgX8HpNAcw==", + "dev": true }, "commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==" + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "cosmiconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", - "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, "requires": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -390,25 +415,28 @@ "css-color-names": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-1.0.1.tgz", - "integrity": "sha512-/loXYOch1qU1biStIFsHH8SxTmOseh1IJqFvy8IujXOm1h+QjUdDhkzOrR5HG8K8mlxREj0yfi8ewCHx0eMxzA==" + "integrity": "sha512-/loXYOch1qU1biStIFsHH8SxTmOseh1IJqFvy8IujXOm1h+QjUdDhkzOrR5HG8K8mlxREj0yfi8ewCHx0eMxzA==", + "dev": true }, "css-declaration-sorter": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.0.0.tgz", - "integrity": "sha512-S0TE4E0ha5+tBHdLWPc5n+S8E4dFBS5xScPvgHkLNZwWvX4ISoFGhGeerLC9uS1cKA/sC+K2wHq6qEbcagT/fg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.1.3.tgz", + "integrity": "sha512-SvjQjNRZgh4ULK1LDJ2AduPKUKxIqmtU7ZAyi47BTV+M90Qvxr9AB6lKlLbDUfXqI9IQeYA8LbAsCZPpJEV3aA==", + "dev": true, "requires": { "timsort": "^0.3.0" } }, "css-select": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-3.1.2.tgz", - "integrity": "sha512-qmss1EihSuBNWNNhHjxzxSfJoFBM/lERB/Q4EnsJQQC62R2evJDW481091oAdOr9uh46/0n4nrg0It5cAnj1RA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.1.3.tgz", + "integrity": "sha512-gT3wBNd9Nj49rAbmtFHj1cljIAOLYSX1nZ8CB7TBO3INYckygm5B7LISU/szY//YmdiSLbJvDLOx9VnMVpMBxA==", + "dev": true, "requires": { "boolbase": "^1.0.0", - "css-what": "^4.0.0", - "domhandler": "^4.0.0", - "domutils": "^2.4.3", + "css-what": "^5.0.0", + "domhandler": "^4.2.0", + "domutils": "^2.6.0", "nth-check": "^2.0.0" } }, @@ -416,6 +444,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "requires": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -424,74 +453,81 @@ "css-unit-converter": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz", - "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==" + "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==", + "dev": true }, "css-what": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-4.0.0.tgz", - "integrity": "sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A==" + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz", + "integrity": "sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==", + "dev": true }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true }, "cssnano": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.0.0.tgz", - "integrity": "sha512-beaqJEU9aI0B2PpKWXy+UJdtw+Q2J2c2f2nHVphL/gb2wvkuQV+Zxf5Q5SsNXiPUb9Djo/+ja+UOelQWhHnVow==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.0.8.tgz", + "integrity": "sha512-Lda7geZU0Yu+RZi2SGpjYuQz4HI4/1Y+BhdD0jL7NXAQ5larCzVn+PUGuZbDMYz904AXXCOgO5L1teSvgu7aFg==", + "dev": true, "requires": { - "cosmiconfig": "^7.0.0", - "cssnano-preset-default": "^5.0.0", + "cssnano-preset-default": "^5.1.4", "is-resolvable": "^1.1.0", - "opencollective-postinstall": "^2.0.2" + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" } }, "cssnano-preset-default": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.0.0.tgz", - "integrity": "sha512-zsLppqF7PxY6Tk+ghVx8djf4o1jIOu2GNufqy9lMxldt7gGpSy3FQ6jn7FCd5DZWCaBa7A/1/HVh8CK3BdFSJg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.1.4.tgz", + "integrity": "sha512-sPpQNDQBI3R/QsYxQvfB4mXeEcWuw0wGtKtmS5eg8wudyStYMgKOQT39G07EbW1LB56AOYrinRS9f0ig4Y3MhQ==", + "dev": true, "requires": { - "css-declaration-sorter": "6.0.0", - "cssnano-utils": "^2.0.0", + "css-declaration-sorter": "^6.0.3", + "cssnano-utils": "^2.0.1", "postcss-calc": "^8.0.0", - "postcss-colormin": "^5.0.0", - "postcss-convert-values": "^5.0.0", - "postcss-discard-comments": "^5.0.0", - "postcss-discard-duplicates": "^5.0.0", - "postcss-discard-empty": "^5.0.0", - "postcss-discard-overridden": "^5.0.0", - "postcss-merge-longhand": "^5.0.0", - "postcss-merge-rules": "^5.0.0", - "postcss-minify-font-values": "^5.0.0", - "postcss-minify-gradients": "^5.0.0", - "postcss-minify-params": "^5.0.0", - "postcss-minify-selectors": "^5.0.0", - "postcss-normalize-charset": "^5.0.0", - "postcss-normalize-display-values": "^5.0.0", - "postcss-normalize-positions": "^5.0.0", - "postcss-normalize-repeat-style": "^5.0.0", - "postcss-normalize-string": "^5.0.0", - "postcss-normalize-timing-functions": "^5.0.0", - "postcss-normalize-unicode": "^5.0.0", - "postcss-normalize-url": "^5.0.0", - "postcss-normalize-whitespace": "^5.0.0", - "postcss-ordered-values": "^5.0.0", - "postcss-reduce-initial": "^5.0.0", - "postcss-reduce-transforms": "^5.0.0", - "postcss-svgo": "^5.0.0", - "postcss-unique-selectors": "^5.0.0" + "postcss-colormin": "^5.2.0", + "postcss-convert-values": "^5.0.1", + "postcss-discard-comments": "^5.0.1", + "postcss-discard-duplicates": "^5.0.1", + "postcss-discard-empty": "^5.0.1", + "postcss-discard-overridden": "^5.0.1", + "postcss-merge-longhand": "^5.0.2", + "postcss-merge-rules": "^5.0.2", + "postcss-minify-font-values": "^5.0.1", + "postcss-minify-gradients": "^5.0.2", + "postcss-minify-params": "^5.0.1", + "postcss-minify-selectors": "^5.1.0", + "postcss-normalize-charset": "^5.0.1", + "postcss-normalize-display-values": "^5.0.1", + "postcss-normalize-positions": "^5.0.1", + "postcss-normalize-repeat-style": "^5.0.1", + "postcss-normalize-string": "^5.0.1", + "postcss-normalize-timing-functions": "^5.0.1", + "postcss-normalize-unicode": "^5.0.1", + "postcss-normalize-url": "^5.0.2", + "postcss-normalize-whitespace": "^5.0.1", + "postcss-ordered-values": "^5.0.2", + "postcss-reduce-initial": "^5.0.1", + "postcss-reduce-transforms": "^5.0.1", + "postcss-svgo": "^5.0.2", + "postcss-unique-selectors": "^5.0.1" } }, "cssnano-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-2.0.0.tgz", - "integrity": "sha512-xvxmTszdrvSyTACdPe8VU5J6p4sm3egpgw54dILvNqt5eBUv6TFjACLhSxtRuEsxYrgy8uDy269YjScO5aKbGA==" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-2.0.1.tgz", + "integrity": "sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ==", + "dev": true }, "csso": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, "requires": { "css-tree": "^1.1.2" } @@ -499,17 +535,20 @@ "defined": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true }, "dependency-graph": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.9.0.tgz", - "integrity": "sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w==" + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true }, "detective": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", + "dev": true, "requires": { "acorn-node": "^1.6.1", "defined": "^1.0.0", @@ -517,14 +556,16 @@ } }, "didyoumean": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.1.tgz", - "integrity": "sha1-6S7f2tplN9SE1zwBcv0eugxJdv8=" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, "requires": { "path-type": "^4.0.0" } @@ -532,94 +573,111 @@ "dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true }, "dom-serializer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz", - "integrity": "sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", + "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==", + "dev": true, "requires": { "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", + "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "domelementtype": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true }, "domhandler": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz", - "integrity": "sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz", + "integrity": "sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==", + "dev": true, "requires": { "domelementtype": "^2.2.0" } }, "domutils": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.2.tgz", - "integrity": "sha512-MHTthCb1zj8f1GVfRpeZUbohQf/HdBos0oX5gZcQFepOZPLLRyj6Wn7XS7EMnY7CVpwv8863u2vyE83Hfu28HQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, "requires": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", - "domhandler": "^4.1.0" + "domhandler": "^4.2.0" } }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "requires": { - "is-obj": "^2.0.0" - } + "electron-to-chromium": { + "version": "1.3.878", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.878.tgz", + "integrity": "sha512-O6yxWCN9ph2AdspAIszBnd9v8s11hQx8ub9w4UGApzmNRnoKhbulOWqbO8THEQec/aEHtvy+donHZMlh6l1rbA==", + "dev": true }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, "requires": { "is-arrayish": "^0.2.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + } } }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true }, "fast-glob": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", - "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", + "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", + "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", + "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.2", - "picomatch": "^2.2.1" + "micromatch": "^4.0.4" } }, "fastq": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.10.1.tgz", - "integrity": "sha512-AWuv6Ery3pM+dY7LYS8YIaCiQvUaos9OB1RyNgaOWnaX+Tik7Onvcsf8x8c+YtDeT0maYLniBip2hox5KtEXXA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, "requires": { "reusify": "^1.0.4" } @@ -628,6 +686,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "requires": { "to-regex-range": "^5.0.1" } @@ -635,19 +694,21 @@ "flag-icon-css": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/flag-icon-css/-/flag-icon-css-3.5.0.tgz", - "integrity": "sha512-pgJnJLrtb0tcDgU1fzGaQXmR8h++nXvILJ+r5SmOXaaL/2pocunQo2a8TAXhjQnBpRLPtZ1KCz/TYpqeNuE2ew==" + "integrity": "sha512-pgJnJLrtb0tcDgU1fzGaQXmR8h++nXvILJ+r5SmOXaaL/2pocunQo2a8TAXhjQnBpRLPtZ1KCz/TYpqeNuE2ew==", + "dev": true }, "fraction.js": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.13.tgz", - "integrity": "sha512-E1fz2Xs9ltlUp+qbiyx9wmt2n9dRzPsS11Jtdb8D2o+cC7wr9xkkKsVKJuBX0ST+LVS+LhLO+SbLJNtfWcJvXA==" + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.1.tgz", + "integrity": "sha512-MHOhvvxHTfRFpF1geTK9czMIZ6xclsEor2wkIGYYq+PxcQqT7vStJqjhe6S1TenZrMZzo+wlqOufBDVepUEgPg==", + "dev": true }, "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz", + "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==", + "dev": true, "requires": { - "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" @@ -656,33 +717,39 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "fsevents": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.1.tgz", - "integrity": "sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "optional": true }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true }, "get-stdin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", - "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==" + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", + "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", + "dev": true }, "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -692,106 +759,85 @@ "path-is-absolute": "^1.0.0" } }, - "glob-base": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", - "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", - "requires": { - "glob-parent": "^2.0.0", - "is-glob": "^2.0.0" - }, - "dependencies": { - "glob-parent": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", - "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", - "requires": { - "is-glob": "^2.0.0" - } - }, - "is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=" - }, - "is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", - "requires": { - "is-extglob": "^1.0.0" - } - } - } - }, "glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "requires": { "is-glob": "^4.0.1" } }, "globby": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz", - "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-12.0.2.tgz", + "integrity": "sha512-lAsmb/5Lww4r7MM9nCCliDZVIKbZTavrsunAsHLr9oHthrZP1qi7/gAnHOsUs9bLvEt2vKVJhHmxuL7QbDuPdQ==", + "dev": true, "requires": { - "array-union": "^2.1.0", + "array-union": "^3.0.1", "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" + "fast-glob": "^3.2.7", + "ignore": "^5.1.8", + "merge2": "^1.4.1", + "slash": "^4.0.0" } }, "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", + "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==", + "dev": true }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "requires": { "function-bind": "^1.1.1" } }, "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "hex-color-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", - "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" + "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", + "dev": true }, "hsl-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", - "integrity": "sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=" + "integrity": "sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=", + "dev": true }, "hsla-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz", - "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=" + "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", + "dev": true }, "html-tags": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.1.0.tgz", - "integrity": "sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==" + "integrity": "sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==", + "dev": true }, "ignore": { "version": "5.1.8", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", - "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true }, "import-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", + "dev": true, "requires": { "import-from": "^3.0.0" } @@ -800,35 +846,34 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + } } }, "import-from": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", + "dev": true, "requires": { "resolve-from": "^5.0.0" - }, - "dependencies": { - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" - } } }, - "indexes-of": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", - "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=" - }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -837,22 +882,26 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "is-absolute-url": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", - "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==" + "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==", + "dev": true }, "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "requires": { "binary-extensions": "^2.0.0" } @@ -861,6 +910,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz", "integrity": "sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=", + "dev": true, "requires": { "css-color-names": "^0.0.4", "hex-color-regex": "^1.1.0", @@ -873,37 +923,37 @@ "css-color-names": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", - "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=" + "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=", + "dev": true } } }, "is-core-module": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", - "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.0.tgz", + "integrity": "sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==", + "dev": true, "requires": { "has": "^1.0.3" } }, - "is-dotfile": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", - "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=" - }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true }, "is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -911,120 +961,136 @@ "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true }, "is-resolvable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", - "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==" + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true }, "jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, "requires": { "graceful-fs": "^4.1.6", "universalify": "^2.0.0" } }, + "lilconfig": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz", + "integrity": "sha512-EHKqr/+ZvdKCifpNrJCKxBTgk5XupZA3y/aCPY9mxfgBzmgh93Mt/WqjjQ38oMxXuvDokaKiM3lAgvSH2sjtHg==", + "dev": true + }, "lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", - "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=" + "integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=", + "dev": true }, "lodash.forown": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.forown/-/lodash.forown-4.4.0.tgz", - "integrity": "sha1-hRFc8E9z75ZuztUlEdOJPMRmg68=" + "integrity": "sha1-hRFc8E9z75ZuztUlEdOJPMRmg68=", + "dev": true }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true }, "lodash.groupby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", - "integrity": "sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E=" + "integrity": "sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E=", + "dev": true }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" - }, - "lodash.toarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz", - "integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE=" + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true }, "lodash.topath": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", - "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=" + "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=", + "dev": true }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", + "dev": true }, "mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true }, "micromatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", - "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, "requires": { "braces": "^3.0.1", - "picomatch": "^2.0.5" + "picomatch": "^2.2.3" } }, "mini-svg-data-uri": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.2.3.tgz", - "integrity": "sha512-zd6KCAyXgmq6FV1mR10oKXYtvmA9vRoB6xPSTUJTbFApCtkefDnYueVR1gkof3KcdLZo1Y8mjF2DFmQMIxsHNQ==" + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz", + "integrity": "sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA==", + "dev": true }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1032,117 +1098,98 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true }, "modern-normalize": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.0.0.tgz", - "integrity": "sha512-1lM+BMLGuDfsdwf3rsgBSrxJwAZHFIrQ8YR61xIqdHo0uNKI9M52wNpHSrliZATJp51On6JD0AfRxd4YGSU0lw==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.1.0.tgz", + "integrity": "sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==", + "dev": true + }, + "nanocolors": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/nanocolors/-/nanocolors-0.1.12.tgz", + "integrity": "sha512-2nMHqg1x5PU+unxX7PGY7AuYxl2qDx7PSrTRjizr8sxdd3l/3hBuWWaki62qmtYm2U5i4Z5E7GbjlyDFhs9/EQ==", + "dev": true }, "nanoid": { - "version": "3.1.22", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", - "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==" + "version": "3.1.30", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz", + "integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==", + "dev": true }, "node-emoji": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz", - "integrity": "sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, "requires": { - "lodash.toarray": "^4.4.0" + "lodash": "^4.17.21" } }, + "node-releases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", + "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", + "dev": true + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true }, "normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=" + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true }, "normalize-url": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", - "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true }, "nth-check": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", + "dev": true, "requires": { "boolbase": "^1.0.0" } }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, "object-hash": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz", - "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "dev": true }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "requires": { "wrappy": "1" } }, - "opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==" - }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "requires": { "callsites": "^3.0.0" - }, - "dependencies": { - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - } - } - }, - "parse-glob": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", - "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", - "requires": { - "glob-base": "^0.3.0", - "is-dotfile": "^1.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.0" - }, - "dependencies": { - "is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=" - }, - "is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", - "requires": { - "is-extglob": "^1.0.0" - } - } } }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -1153,42 +1200,55 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true }, "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true }, "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true }, "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true }, "postcss": { - "version": "8.2.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.10.tgz", - "integrity": "sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw==", + "version": "8.3.11", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.11.tgz", + "integrity": "sha512-hCmlUAIlUiav8Xdqw3Io4LcpA1DOt7h3LSTAC4G6JGHFFaWzI6qvFt9oilvl8BmkbBRX1IhM90ZAmpk68zccQA==", + "dev": true, "requires": { - "colorette": "^1.2.2", - "nanoid": "^3.1.22", - "source-map": "^0.6.1" + "nanoid": "^3.1.30", + "picocolors": "^1.0.0", + "source-map-js": "^0.6.2" }, "dependencies": { - "colorette": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", - "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==" + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true } } }, @@ -1196,153 +1256,90 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.0.0.tgz", "integrity": "sha512-5NglwDrcbiy8XXfPM11F3HeC6hoT9W7GUH/Zi5U/p7u3Irv4rHhdDcIZwG0llHXV4ftsBjpfWMXAnXNl4lnt8g==", + "dev": true, "requires": { "postcss-selector-parser": "^6.0.2", "postcss-value-parser": "^4.0.2" } }, "postcss-cli": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-8.3.1.tgz", - "integrity": "sha512-leHXsQRq89S3JC9zw/tKyiVV2jAhnfQe0J8VI4eQQbUjwIe0XxVqLrR+7UsahF1s9wi4GlqP6SJ8ydf44cgF2Q==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-9.0.1.tgz", + "integrity": "sha512-zO160OBaAZBFUWO+QZIzEKMjnPIc5c61dMg1d7xafblh9cxbNb6s16ahJuP91PcVsu//gqr7BKllJxRiRDsSYw==", + "dev": true, "requires": { - "chalk": "^4.0.0", "chokidar": "^3.3.0", - "dependency-graph": "^0.9.0", - "fs-extra": "^9.0.0", - "get-stdin": "^8.0.0", - "globby": "^11.0.0", + "dependency-graph": "^0.11.0", + "fs-extra": "^10.0.0", + "get-stdin": "^9.0.0", + "globby": "^12.0.0", + "nanocolors": "^0.2.11", "postcss-load-config": "^3.0.0", "postcss-reporter": "^7.0.0", "pretty-hrtime": "^1.0.3", "read-cache": "^1.0.0", - "slash": "^3.0.0", - "yargs": "^16.0.0" + "slash": "^4.0.0", + "yargs": "^17.0.0" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } + "nanocolors": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/nanocolors/-/nanocolors-0.2.13.tgz", + "integrity": "sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==", + "dev": true } } }, "postcss-colormin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.0.0.tgz", - "integrity": "sha512-Yt84+5V6CgS/AhK7d7MA58vG8dSZ7+ytlRtWLaQhag3HXOncTfmYpuUOX4cDoXjvLfw1sHRCHMiBjYhc35CymQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.2.0.tgz", + "integrity": "sha512-+HC6GfWU3upe5/mqmxuqYZ9B2Wl4lcoUUNkoaX59nEWV4EtADCMiBqui111Bu8R8IvaZTmqmxrqOAqjbHIwXPw==", + "dev": true, "requires": { - "browserslist": "^4.16.0", - "color": "^3.1.1", + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-convert-values": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.0.0.tgz", - "integrity": "sha512-V5kmYm4xoBAjNs+eHY/6XzXJkkGeg4kwNf2ocfqhLb1WBPEa4oaSmoi1fnVO7Dkblqvus9h+AenDvhCKUCK7uQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.0.1.tgz", + "integrity": "sha512-C3zR1Do2BkKkCgC0g3sF8TS0koF2G+mN8xxayZx3f10cIRmTaAnpgpRQZjNekTZxM2ciSPoh2IWJm0VZx8NoQg==", + "dev": true, "requires": { "postcss-value-parser": "^4.1.0" } }, "postcss-discard-comments": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.0.0.tgz", - "integrity": "sha512-Umig6Gxs8m20RihiXY6QkePd6mp4FxkA1Dg+f/Kd6uw0gEMfKRjDeQOyFkLibexbJJGHpE3lrN/Q0R9SMrUMbQ==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.0.1.tgz", + "integrity": "sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg==", + "dev": true }, "postcss-discard-duplicates": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.0.tgz", - "integrity": "sha512-vEJJ+Y3pFUnO1FyCBA6PSisGjHtnphL3V6GsNvkASq/VkP3OX5/No5RYXXLxHa2QegStNzg6HYrYdo71uR4caQ==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.1.tgz", + "integrity": "sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA==", + "dev": true }, "postcss-discard-empty": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.0.0.tgz", - "integrity": "sha512-+wigy099Y1xZxG36WG5L1f2zeH1oicntkJEW4TDIqKKDO2g9XVB3OhoiHTu08rDEjLnbcab4rw0BAccwi2VjiQ==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.0.1.tgz", + "integrity": "sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw==", + "dev": true }, "postcss-discard-overridden": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.0.0.tgz", - "integrity": "sha512-hybnScTaZM2iEA6kzVQ6Spozy7kVdLw+lGw8hftLlBEzt93uzXoltkYp9u0tI8xbfhxDLTOOzHsHQCkYdmzRUg==" - }, - "postcss-functions": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-functions/-/postcss-functions-3.0.0.tgz", - "integrity": "sha1-DpTQFERwCkgd4g3k1V+yZAVkJQ4=", - "requires": { - "glob": "^7.1.2", - "object-assign": "^4.1.1", - "postcss": "^6.0.9", - "postcss-value-parser": "^3.3.0" - }, - "dependencies": { - "postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "requires": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - } - }, - "postcss-value-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", - "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.0.1.tgz", + "integrity": "sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q==", + "dev": true }, "postcss-import": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.0.1.tgz", - "integrity": "sha512-Xn2+z++vWObbEPhiiKO1a78JiyhqipyrXHBb3AHpv0ks7Cdg+GxQQJ24ODNMTanldf7197gSP3axppO9yaG0lA==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.0.2.tgz", + "integrity": "sha512-BJ2pVK4KhUyMcqjuKs9RijV5tatNzNa73e/32aBVE/ejYPe37iH+6vAu9WvqUkB5OAYgLHzbSvzHnorybJCm9g==", + "dev": true, "requires": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -1353,323 +1350,439 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-3.0.3.tgz", "integrity": "sha512-gWnoWQXKFw65Hk/mi2+WTQTHdPD5UJdDXZmX073EY/B3BWnYjO4F4t0VneTCnCGQ5E5GsCdMkzPaTXwl3r5dJw==", + "dev": true, "requires": { "camelcase-css": "^2.0.1", "postcss": "^8.1.6" } }, "postcss-load-config": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.0.0.tgz", - "integrity": "sha512-lErrN8imuEF1cSiHBV8MiR7HeuzlDpCGNtaMyYHlOBuJHHOGw6S4xOMZp8BbXPr7AGQp14L6PZDlIOpfFJ6f7w==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.0.tgz", + "integrity": "sha512-ipM8Ds01ZUophjDTQYSVP70slFSYg3T0/zyfII5vzhN6V57YSxMgG5syXuwi5VtS8wSf3iL30v0uBdoIVx4Q0g==", + "dev": true, "requires": { - "cosmiconfig": "^7.0.0", - "import-cwd": "^3.0.0" - }, - "dependencies": { - "cosmiconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", - "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - } + "import-cwd": "^3.0.0", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" } }, "postcss-merge-longhand": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.0.0.tgz", - "integrity": "sha512-VZNFA40K8BYHzJNA6jHPdg1Nofsz/nK5Dkszrcb5IgWcLroSBZOD6I/iNQzpejSU/3XwpOiZNaYAdBV4KcvxWA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.0.2.tgz", + "integrity": "sha512-BMlg9AXSI5G9TBT0Lo/H3PfUy63P84rVz3BjCFE9e9Y9RXQZD3+h3YO1kgTNsNJy7bBc1YQp8DmSnwLIW5VPcw==", + "dev": true, "requires": { "css-color-names": "^1.0.1", "postcss-value-parser": "^4.1.0", - "stylehacks": "^5.0.0" + "stylehacks": "^5.0.1" } }, "postcss-merge-rules": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.0.0.tgz", - "integrity": "sha512-TfsXbKjNYCGfUPEXGIGPySnMiJbdS+3gcVeV8gwmJP4RajyKZHW8E0FYDL1WmggTj3hi+m+WUCAvqRpX2ut4Kg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.0.2.tgz", + "integrity": "sha512-5K+Md7S3GwBewfB4rjDeol6V/RZ8S+v4B66Zk2gChRqLTCC8yjnHQ601omj9TKftS19OPGqZ/XzoqpzNQQLwbg==", + "dev": true, "requires": { - "browserslist": "^4.16.0", + "browserslist": "^4.16.6", "caniuse-api": "^3.0.0", - "cssnano-utils": "^2.0.0", - "postcss-selector-parser": "^6.0.4", + "cssnano-utils": "^2.0.1", + "postcss-selector-parser": "^6.0.5", "vendors": "^1.0.3" } }, "postcss-minify-font-values": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.0.0.tgz", - "integrity": "sha512-zi2JhFaMOcIaNxhndX5uhsqSY1rexKDp23wV8EOmC9XERqzLbHsoRye3aYF716Zm+hkcR4loqKDt8LZlmihwAg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.0.1.tgz", + "integrity": "sha512-7JS4qIsnqaxk+FXY1E8dHBDmraYFWmuL6cgt0T1SWGRO5bzJf8sUoelwa4P88LEWJZweHevAiDKxHlofuvtIoA==", + "dev": true, "requires": { "postcss-value-parser": "^4.1.0" } }, "postcss-minify-gradients": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.0.0.tgz", - "integrity": "sha512-/jPtNgs6JySMwgsE5dPOq8a2xEopWTW3RyqoB9fLqxgR+mDUNLSi7joKd+N1z7FXWgVkc4l/dEBMXHgNAaUbvg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.0.2.tgz", + "integrity": "sha512-7Do9JP+wqSD6Prittitt2zDLrfzP9pqKs2EcLX7HJYxsxCOwrrcLt4x/ctQTsiOw+/8HYotAoqNkrzItL19SdQ==", + "dev": true, "requires": { - "cssnano-utils": "^2.0.0", - "is-color-stop": "^1.1.0", + "colord": "^2.6", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-minify-params": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.0.0.tgz", - "integrity": "sha512-KvZYIxTPBVKjdd+XgObq9A+Sfv8lMkXTpbZTsjhr42XbfWIeLaTItMlygsDWfjArEc3muUfDaUFgNSeDiJ5jug==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.0.1.tgz", + "integrity": "sha512-4RUC4k2A/Q9mGco1Z8ODc7h+A0z7L7X2ypO1B6V8057eVK6mZ6xwz6QN64nHuHLbqbclkX1wyzRnIrdZehTEHw==", + "dev": true, "requires": { "alphanum-sort": "^1.0.2", "browserslist": "^4.16.0", - "cssnano-utils": "^2.0.0", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0", "uniqs": "^2.0.0" } }, "postcss-minify-selectors": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.0.0.tgz", - "integrity": "sha512-cEM0O0eWwFIvmo6nfB0lH0vO/XFwgqIvymODbfPXZ1gTA3i76FKnb7TGUrEpiTxaXH6tgYQ6DcTHwRiRS+YQLQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.1.0.tgz", + "integrity": "sha512-NzGBXDa7aPsAcijXZeagnJBKBPMYLaJJzB8CQh6ncvyl2sIndLVWfbcDi0SBjRWk5VqEjXvf8tYwzoKf4Z07og==", + "dev": true, "requires": { "alphanum-sort": "^1.0.2", - "postcss-selector-parser": "^3.1.2" - }, - "dependencies": { - "postcss-selector-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", - "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", - "requires": { - "dot-prop": "^5.2.0", - "indexes-of": "^1.0.1", - "uniq": "^1.0.1" - } - } + "postcss-selector-parser": "^6.0.5" } }, "postcss-nested": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.5.tgz", - "integrity": "sha512-GSRXYz5bccobpTzLQZXOnSOfKl6TwVr5CyAQJUPub4nuRJSOECK5AqurxVgmtxP48p0Kc/ndY/YyS1yqldX0Ew==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", + "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", + "dev": true, "requires": { - "postcss-selector-parser": "^6.0.4" + "postcss-selector-parser": "^6.0.6" } }, "postcss-normalize-charset": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.0.0.tgz", - "integrity": "sha512-pqsCkgo9KmQP0ew6DqSA+uP9YN6EfsW20pQ3JU5JoQge09Z6Too4qU0TNDsTNWuEaP8SWsMp+19l15210MsDZQ==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.0.1.tgz", + "integrity": "sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg==", + "dev": true }, "postcss-normalize-display-values": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.0.tgz", - "integrity": "sha512-t4f2d//gH1f7Ns0Jq3eNdnWuPT7TeLuISZ6RQx4j8gpl5XrhkdshdNcOnlrEK48YU6Tcb6jqK7dorME3N4oOGA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.1.tgz", + "integrity": "sha512-uupdvWk88kLDXi5HEyI9IaAJTE3/Djbcrqq8YgjvAVuzgVuqIk3SuJWUisT2gaJbZm1H9g5k2w1xXilM3x8DjQ==", + "dev": true, "requires": { - "cssnano-utils": "^2.0.0", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-positions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.0.0.tgz", - "integrity": "sha512-0o6/qU5ky74X/eWYj/tv4iiKCm3YqJnrhmVADpIMNXxzFZywsSQxl8F7cKs8jQEtF3VrJBgcDHTexZy1zgDoYg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.0.1.tgz", + "integrity": "sha512-rvzWAJai5xej9yWqlCb1OWLd9JjW2Ex2BCPzUJrbaXmtKtgfL8dBMOOMTX6TnvQMtjk3ei1Lswcs78qKO1Skrg==", + "dev": true, "requires": { "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-repeat-style": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.0.0.tgz", - "integrity": "sha512-KRT14JbrXKcFMYuc4q7lh8lvv8u22wLyMrq+UpHKLtbx2H/LOjvWXYdoDxmNrrrJzomAWL+ViEXr48/IhSUJnQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.0.1.tgz", + "integrity": "sha512-syZ2itq0HTQjj4QtXZOeefomckiV5TaUO6ReIEabCh3wgDs4Mr01pkif0MeVwKyU/LHEkPJnpwFKRxqWA/7O3w==", + "dev": true, "requires": { - "cssnano-utils": "^2.0.0", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.0.0.tgz", - "integrity": "sha512-wSO4pf7GNcDZpmelREWYADF1+XZWrAcbFLQCOqoE92ZwYgaP/RLumkUTaamEzdT2YKRZAH8eLLKGWotU/7FNPw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.0.1.tgz", + "integrity": "sha512-Ic8GaQ3jPMVl1OEn2U//2pm93AXUcF3wz+OriskdZ1AOuYV25OdgS7w9Xu2LO5cGyhHCgn8dMXh9bO7vi3i9pA==", + "dev": true, "requires": { "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-timing-functions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.0.0.tgz", - "integrity": "sha512-TwPaDX+wl9wO3MUm23lzGmOzGCGKnpk+rSDgzB2INpakD5dgWR3L6bJq1P1LQYzBAvz8fRIj2NWdnZdV4EV98Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.0.1.tgz", + "integrity": "sha512-cPcBdVN5OsWCNEo5hiXfLUnXfTGtSFiBU9SK8k7ii8UD7OLuznzgNRYkLZow11BkQiiqMcgPyh4ZqXEEUrtQ1Q==", + "dev": true, "requires": { - "cssnano-utils": "^2.0.0", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-unicode": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.0.0.tgz", - "integrity": "sha512-2CpVoz/67rXU5s9tsPZDxG1YGS9OFHwoY9gsLAzrURrCxTAb0H7Vp87/62LvVPgRWTa5ZmvgmqTp2rL8tlm72A==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.0.1.tgz", + "integrity": "sha512-kAtYD6V3pK0beqrU90gpCQB7g6AOfP/2KIPCVBKJM2EheVsBQmx/Iof+9zR9NFKLAx4Pr9mDhogB27pmn354nA==", + "dev": true, "requires": { "browserslist": "^4.16.0", "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.0.0.tgz", - "integrity": "sha512-ICDaGFBqLgA3dlrCIRuhblLl80D13YtgEV9NJPTYJtgR72vu61KgxAHv+z/lKMs1EbwfSQa3ALjOFLSmXiE34A==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.0.2.tgz", + "integrity": "sha512-k4jLTPUxREQ5bpajFQZpx8bCF2UrlqOTzP9kEqcEnOfwsRshWs2+oAFIHfDQB8GO2PaUaSE0NlTAYtbluZTlHQ==", + "dev": true, "requires": { "is-absolute-url": "^3.0.3", - "normalize-url": "^4.5.0", + "normalize-url": "^6.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-normalize-whitespace": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.0.0.tgz", - "integrity": "sha512-KRnxQvQAVkJfaeXSz7JlnD9nBN9sFZF9lrk9452Q2uRoqrRSkinqifF8Iex7wZGei2DZVG/qpmDFDmRvbNAOGA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.0.1.tgz", + "integrity": "sha512-iPklmI5SBnRvwceb/XH568yyzK0qRVuAG+a1HFUsFRf11lEJTiQQa03a4RSCQvLKdcpX7XsI1Gen9LuLoqwiqA==", + "dev": true, "requires": { "postcss-value-parser": "^4.1.0" } }, "postcss-ordered-values": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.0.0.tgz", - "integrity": "sha512-dPr+SRObiHueCIc4IUaG0aOGQmYkuNu50wQvdXTGKy+rzi2mjmPsbeDsheLk5WPb9Zyf2tp8E+I+h40cnivm6g==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.0.2.tgz", + "integrity": "sha512-8AFYDSOYWebJYLyJi3fyjl6CqMEG/UVworjiyK1r573I56kb3e879sCJLGvR3merj+fAdPpVplXKQZv+ey6CgQ==", + "dev": true, "requires": { - "cssnano-utils": "^2.0.0", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, + "postcss-purgecss": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/postcss-purgecss/-/postcss-purgecss-2.0.3.tgz", + "integrity": "sha512-cuQin5PgZzvDe7EjW4S27iM6p4ZNz4iBEPmBrAykXm2WyaBtri1sA4ZVn/zECN7x3uxeADwDq1u4VDY5C9iusg==", + "dev": true, + "requires": { + "postcss": "7.0.26", + "purgecss": "^2.0.3" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "postcss": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.26.tgz", + "integrity": "sha512-IY4oRjpXWYshuTDFxMVkJDtWIk2LhsTlu8bZnbEJA4+bYT16Lvpo8Qv6EvDumhYRgzjZl489pmsY3qVgJQ08nA==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + }, + "purgecss": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-2.3.0.tgz", + "integrity": "sha512-BE5CROfVGsx2XIhxGuZAT7rTH9lLeQx/6M0P7DTXQH4IUc3BBzs9JUzt4yzGf3JrH9enkeq6YJBe9CTtkm1WmQ==", + "dev": true, + "requires": { + "commander": "^5.0.0", + "glob": "^7.0.0", + "postcss": "7.0.32", + "postcss-selector-parser": "^6.0.2" + }, + "dependencies": { + "postcss": { + "version": "7.0.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", + "integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + } + } + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "postcss-reduce-initial": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.0.0.tgz", - "integrity": "sha512-wR6pXUaFbSMG1oCKx8pKVA+rnSXCHlca5jMrlmkmif+uig0HNUTV9oGN5kjKsM3mATQAldv2PF9Tbl2vqLFjnA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.0.1.tgz", + "integrity": "sha512-zlCZPKLLTMAqA3ZWH57HlbCjkD55LX9dsRyxlls+wfuRfqCi5mSlZVan0heX5cHr154Dq9AfbH70LyhrSAezJw==", + "dev": true, "requires": { "browserslist": "^4.16.0", "caniuse-api": "^3.0.0" } }, "postcss-reduce-transforms": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.0.0.tgz", - "integrity": "sha512-iHdGODW4YzM3WjVecBhPQt6fpJC4lGQZxJKjkBNHpp2b8dzmvj0ogKThqya+IRodQEFzjfXgYeESkf172FH5Lw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.0.1.tgz", + "integrity": "sha512-a//FjoPeFkRuAguPscTVmRQUODP+f3ke2HqFNgGPwdYnpeC29RZdCBvGRGTsKpMURb/I3p6jdKoBQ2zI+9Q7kA==", + "dev": true, "requires": { - "cssnano-utils": "^2.0.0", + "cssnano-utils": "^2.0.1", "postcss-value-parser": "^4.1.0" } }, "postcss-reporter": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-7.0.2.tgz", - "integrity": "sha512-JyQ96NTQQsso42y6L1H1RqHfWH1C3Jr0pt91mVv5IdYddZAE9DUZxuferNgk6q0o6vBVOrfVJb10X1FgDzjmDw==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-7.0.4.tgz", + "integrity": "sha512-jY/fnpGSin7kwJeunXbY35STp5O3VIxSFdjee5JkoPQ+FfGH5JW3N+Xe9oAPcL9UkjWjkK+JC72o8XH4XXKdhw==", + "dev": true, "requires": { - "colorette": "^1.2.1", "lodash.difference": "^4.5.0", "lodash.forown": "^4.4.0", "lodash.get": "^4.4.2", "lodash.groupby": "^4.6.0", - "lodash.sortby": "^4.7.0" + "lodash.sortby": "^4.7.0", + "picocolors": "^1.0.0" + }, + "dependencies": { + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + } } }, "postcss-selector-parser": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", - "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz", + "integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==", + "dev": true, "requires": { "cssesc": "^3.0.0", - "indexes-of": "^1.0.1", - "uniq": "^1.0.1", "util-deprecate": "^1.0.2" } }, "postcss-svgo": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.0.0.tgz", - "integrity": "sha512-M3/VS4sFI1Yp9g0bPL+xzzCNz5iLdRUztoFaugMit5a8sMfkVzzhwqbsOlD8IFFymCdJDmXmh31waYHWw1K4BA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.0.2.tgz", + "integrity": "sha512-YzQuFLZu3U3aheizD+B1joQ94vzPfE6BNUcSYuceNxlVnKKsOtdo6hL9/zyC168Q8EwfLSgaDSalsUGa9f2C0A==", + "dev": true, "requires": { "postcss-value-parser": "^4.1.0", "svgo": "^2.3.0" } }, "postcss-unique-selectors": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.0.0.tgz", - "integrity": "sha512-o9l4pF8SRn7aCMTmzb/kNv/kjV7wPZpZ8Nlb1Gq8v/Qvw969K1wanz1RVA0ehHzWe9+wHXaC2DvZlak/gdMJ5w==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.0.1.tgz", + "integrity": "sha512-gwi1NhHV4FMmPn+qwBNuot1sG1t2OmacLQ/AX29lzyggnjd+MnVD5uqQmpXO3J17KGL2WAxQruj1qTd3H0gG/w==", + "dev": true, "requires": { "alphanum-sort": "^1.0.2", - "postcss-selector-parser": "^6.0.2", + "postcss-selector-parser": "^6.0.5", "uniqs": "^2.0.0" } }, "postcss-value-parser": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", - "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==" + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", + "dev": true }, "pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=" + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true }, "purgecss": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-3.1.3.tgz", - "integrity": "sha512-hRSLN9mguJ2lzlIQtW4qmPS2kh6oMnA9RxdIYK8sz18QYqd6ePp4GNDl18oWHA1f2v2NEQIh51CO8s/E3YGckQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-4.0.3.tgz", + "integrity": "sha512-PYOIn5ibRIP34PBU9zohUcCI09c7drPJJtTDAc0Q6QlRz2/CHQ8ywGLdE7ZhxU2VTqB7p5wkvj5Qcm05Rz3Jmw==", + "dev": true, "requires": { "commander": "^6.0.0", "glob": "^7.0.0", "postcss": "^8.2.1", "postcss-selector-parser": "^6.0.2" + }, + "dependencies": { + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true + } } }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, "quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=", + "dev": true, "requires": { "pify": "^2.3.0" } }, "readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "requires": { "picomatch": "^2.2.1" } @@ -1678,6 +1791,7 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz", "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==", + "dev": true, "requires": { "css-unit-converter": "^1.1.1", "postcss-value-parser": "^3.3.0" @@ -1686,101 +1800,127 @@ "postcss-value-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", - "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true } } }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true }, "resolve": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, "requires": { "is-core-module": "^2.2.0", "path-parse": "^1.0.6" } }, "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true }, "rgb-regex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz", - "integrity": "sha1-wODWiC3w4jviVKR16O3UGRX+rrE=" + "integrity": "sha1-wODWiC3w4jviVKR16O3UGRX+rrE=", + "dev": true }, "rgba-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", - "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=" + "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } }, "run-parallel": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", - "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, "requires": { "is-arrayish": "^0.3.1" - }, - "dependencies": { - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - } } }, "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz", + "integrity": "sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==", + "dev": true }, "stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", - "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==" + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true }, "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" + "strip-ansi": "^6.0.1" } }, "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "requires": { - "ansi-regex": "^5.0.0" + "ansi-regex": "^5.0.1" } }, "stylehacks": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.0.0.tgz", - "integrity": "sha512-QOWm6XivDLb+fqffTZP8jrmPmPITVChl2KCY2R05nsCWwLi3VGhCdVc3IVGNwd1zzTt1jPd67zIKjpQfxzQZeA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.0.1.tgz", + "integrity": "sha512-Es0rVnHIqbWzveU1b24kbw92HsebBepxfcqe5iix7t9j0PQqhs0IxXVXv0pY2Bxa08CgMkzD6OWql7kbGOuEdA==", + "dev": true, "requires": { "browserslist": "^4.16.0", "postcss-selector-parser": "^6.0.4" @@ -1790,143 +1930,73 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "requires": { "has-flag": "^4.0.0" - }, - "dependencies": { - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - } } }, "svgo": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.3.0.tgz", - "integrity": "sha512-fz4IKjNO6HDPgIQxu4IxwtubtbSfGEAJUq/IXyTPIkGhWck/faiiwfkvsB8LnBkKLvSoyNNIY6d13lZprJMc9Q==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.7.0.tgz", + "integrity": "sha512-aDLsGkre4fTDCWvolyW+fs8ZJFABpzLXbtdK1y71CKnHzAnpDxKXPj2mNKj+pyOXUCzFHzuxRJ94XOFygOWV3w==", + "dev": true, "requires": { - "@trysound/sax": "0.1.1", - "chalk": "^4.1.0", - "commander": "^7.1.0", - "css-select": "^3.1.2", - "css-tree": "^1.1.2", + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", "csso": "^4.2.0", + "nanocolors": "^0.1.12", "stable": "^0.1.8" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" - } } }, "tailwindcss": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-2.1.1.tgz", - "integrity": "sha512-zZ6axGqpSZOCBS7wITm/WNHkBzDt5CIZlDlx0eCVldwTxFPELCVGbgh7Xpb3/kZp3cUxOmK7bZUjqhuMrbN6xQ==", + "version": "2.2.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-2.2.17.tgz", + "integrity": "sha512-WgRpn+Pxn7eWqlruxnxEbL9ByVRWi3iC10z4b6dW0zSdnkPVC4hPMSWLQkkW8GCyBIv/vbJ0bxIi9dVrl4CfoA==", + "dev": true, "requires": { - "@fullhuman/postcss-purgecss": "^3.1.3", + "arg": "^5.0.1", "bytes": "^3.0.0", - "chalk": "^4.1.0", - "chokidar": "^3.5.1", - "color": "^3.1.3", + "chalk": "^4.1.2", + "chokidar": "^3.5.2", + "color": "^4.0.1", + "cosmiconfig": "^7.0.1", "detective": "^5.2.0", - "didyoumean": "^1.2.1", + "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.2.5", - "fs-extra": "^9.1.0", + "fast-glob": "^3.2.7", + "fs-extra": "^10.0.0", + "glob-parent": "^6.0.1", "html-tags": "^3.1.0", + "is-color-stop": "^1.1.0", + "is-glob": "^4.0.1", "lodash": "^4.17.21", "lodash.topath": "^4.5.2", - "modern-normalize": "^1.0.0", - "node-emoji": "^1.8.1", + "modern-normalize": "^1.1.0", + "node-emoji": "^1.11.0", "normalize-path": "^3.0.0", - "object-hash": "^2.1.1", - "parse-glob": "^3.0.4", - "postcss-functions": "^3", + "object-hash": "^2.2.0", "postcss-js": "^3.0.3", - "postcss-nested": "5.0.5", - "postcss-selector-parser": "^6.0.4", + "postcss-load-config": "^3.1.0", + "postcss-nested": "5.0.6", + "postcss-selector-parser": "^6.0.6", "postcss-value-parser": "^4.1.0", "pretty-hrtime": "^1.0.3", + "purgecss": "^4.0.3", "quick-lru": "^5.1.1", "reduce-css-calc": "^2.1.8", - "resolve": "^1.20.0" + "resolve": "^1.20.0", + "tmp": "^0.2.1" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" + "is-glob": "^4.0.3" } } } @@ -1934,98 +2004,91 @@ "timsort": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", - "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "requires": { + "rimraf": "^3.0.0" + } }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "requires": { "is-number": "^7.0.0" } }, - "uniq": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", - "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=" - }, "uniqs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz", - "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=" + "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=", + "dev": true }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true }, "vendors": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.4.tgz", - "integrity": "sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==" + "integrity": "sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==", + "dev": true }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - } } }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true }, "y18n": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", - "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==" + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true }, "yaml": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz", - "integrity": "sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==" + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true }, "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.2.1.tgz", + "integrity": "sha512-XfR8du6ua4K6uLGm5S6fA+FIJom/MdJcFNVY8geLlp2v8GYbOXD4EB1tPNZsRn4vBzKGMgb5DRZMeWuFc2GO8Q==", + "dev": true, "requires": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -2037,9 +2100,10 @@ } }, "yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==" + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true } } } diff --git a/modules/webapp/package.json b/modules/webapp/package.json index 017a63a5..5b03270e 100644 --- a/modules/webapp/package.json +++ b/modules/webapp/package.json @@ -2,15 +2,17 @@ "name": "docspell-css", "version": "1.0.0", "private": true, - "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.3", - "@tailwindcss/forms": "^0.3.0", - "autoprefixer": "^10.2.5", - "cssnano": "^5.0.0", + "dependencies": {}, + "devDependencies": { + "@fortawesome/fontawesome-free": "^5.15.4", + "@tailwindcss/forms": "^0.3.4", "flag-icon-css": "^3.5.0", - "postcss": "^8.2.9", - "postcss-cli": "^8.3.1", - "postcss-import": "^14.0.1", - "tailwindcss": "^2.1.1" + "postcss-cli": "^9.0.1", + "postcss-import": "^14.0.2", + "autoprefixer": "^10.3.7", + "cssnano": "^5.0.8", + "postcss": "^8.3.11", + "postcss-purgecss": "^2.0.3", + "tailwindcss": "^2.2.17" } } diff --git a/modules/webapp/postcss.config.js b/modules/webapp/postcss.config.js index 049f289e..6976de4b 100644 --- a/modules/webapp/postcss.config.js +++ b/modules/webapp/postcss.config.js @@ -13,7 +13,7 @@ const prodPlugins = require('postcss-import'), tailwindcss("./tailwind.config.js"), require("autoprefixer"), - require("@fullhuman/postcss-purgecss")({ + require("postcss-purgecss")({ content: [ "./src/main/elm/**/*.elm", "./src/main/styles/keep.txt", diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 5ae8945d..bb89794d 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -11,6 +11,7 @@ module Api exposing , addCorrOrg , addCorrPerson , addMember + , addShare , addTag , addTagsMultiple , attachmentPreviewURL @@ -40,6 +41,7 @@ module Api exposing , deleteOrg , deletePerson , deleteScanMailbox + , deleteShare , deleteSource , deleteTag , deleteUser @@ -72,6 +74,8 @@ module Api exposing , getPersonsLight , getScanMailbox , getSentMails + , getShare + , getShares , getSources , getTagCloud , getTags @@ -79,6 +83,7 @@ module Api exposing , initOtp , itemBasePreviewURL , itemDetail + , itemDetailShare , itemIndexSearch , itemSearch , itemSearchStats @@ -109,6 +114,8 @@ module Api exposing , restoreAllItems , restoreItem , saveClientSettings + , searchShare + , searchShareStats , sendMail , setAttachmentName , setCollectiveSettings @@ -136,6 +143,10 @@ module Api exposing , setTags , setTagsMultiple , setUnconfirmed + , shareAttachmentPreviewURL + , shareFileURL + , shareItemBasePreviewURL + , shareSendMail , startClassifier , startEmptyTrash , startOnceNotifyDueItems @@ -147,9 +158,11 @@ module Api exposing , unconfirmMultiple , updateNotifyDueItems , updateScanMailbox + , updateShare , upload , uploadAmend , uploadSingle + , verifyShare , versionInfo ) @@ -215,7 +228,13 @@ import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList) import Api.Model.SearchStats exposing (SearchStats) import Api.Model.SecondFactor exposing (SecondFactor) import Api.Model.SentMails exposing (SentMails) +import Api.Model.ShareData exposing (ShareData) +import Api.Model.ShareDetail exposing (ShareDetail) +import Api.Model.ShareList exposing (ShareList) +import Api.Model.ShareSecret exposing (ShareSecret) +import Api.Model.ShareVerifyResult exposing (ShareVerifyResult) import Api.Model.SimpleMail exposing (SimpleMail) +import Api.Model.SimpleShareMail exposing (SimpleShareMail) import Api.Model.SourceAndTags exposing (SourceAndTags) import Api.Model.SourceList exposing (SourceList) import Api.Model.SourceTagIn @@ -2206,6 +2225,134 @@ disableOtp flags otp receive = +--- Share + + +getShares : Flags -> String -> Bool -> (Result Http.Error ShareList -> msg) -> Cmd msg +getShares flags query owning receive = + Http2.authGet + { url = + flags.config.baseUrl + ++ "/api/v1/sec/share?q=" + ++ Url.percentEncode query + ++ (if owning then + "&owning" + + else + "" + ) + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.ShareList.decoder + } + + +getShare : Flags -> String -> (Result Http.Error ShareDetail -> msg) -> Cmd msg +getShare flags id receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.ShareDetail.decoder + } + + +addShare : Flags -> ShareData -> (Result Http.Error IdResult -> msg) -> Cmd msg +addShare flags share receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/share" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ShareData.encode share) + , expect = Http.expectJson receive Api.Model.IdResult.decoder + } + + +updateShare : Flags -> String -> ShareData -> (Result Http.Error BasicResult -> msg) -> Cmd msg +updateShare flags id share receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ShareData.encode share) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +deleteShare : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteShare flags id receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +verifyShare : Flags -> ShareSecret -> (Result Http.Error ShareVerifyResult -> msg) -> Cmd msg +verifyShare flags secret receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/open/share/verify" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ShareSecret.encode secret) + , expect = Http.expectJson receive Api.Model.ShareVerifyResult.decoder + } + + +searchShare : Flags -> String -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg +searchShare flags token search receive = + Http2.sharePost + { url = flags.config.baseUrl ++ "/api/v1/share/search/query" + , token = token + , body = Http.jsonBody (Api.Model.ItemQuery.encode search) + , expect = Http.expectJson receive Api.Model.ItemLightList.decoder + } + + +searchShareStats : Flags -> String -> ItemQuery -> (Result Http.Error SearchStats -> msg) -> Cmd msg +searchShareStats flags token search receive = + Http2.sharePost + { url = flags.config.baseUrl ++ "/api/v1/share/search/stats" + , token = token + , body = Http.jsonBody (Api.Model.ItemQuery.encode search) + , expect = Http.expectJson receive Api.Model.SearchStats.decoder + } + + +itemDetailShare : Flags -> String -> String -> (Result Http.Error ItemDetail -> msg) -> Cmd msg +itemDetailShare flags token itemId receive = + Http2.shareGet + { url = flags.config.baseUrl ++ "/api/v1/share/item/" ++ itemId + , token = token + , expect = Http.expectJson receive Api.Model.ItemDetail.decoder + } + + +shareSendMail : + Flags + -> { conn : String, mail : SimpleShareMail } + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +shareSendMail flags opts receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/share/email/send/" ++ opts.conn + , account = getAccount flags + , body = Http.jsonBody (Api.Model.SimpleShareMail.encode opts.mail) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +shareAttachmentPreviewURL : String -> String +shareAttachmentPreviewURL id = + "/api/v1/share/attachment/" ++ id ++ "/preview?withFallback=true" + + +shareItemBasePreviewURL : String -> String +shareItemBasePreviewURL itemId = + "/api/v1/share/item/" ++ itemId ++ "/preview?withFallback=true" + + +shareFileURL : String -> String +shareFileURL attachId = + "/api/v1/share/attachment/" ++ attachId + + + --- Helper diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index 055eb456..c64c192a 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -32,6 +32,8 @@ import Page.ManageData.Data import Page.NewInvite.Data import Page.Queue.Data import Page.Register.Data +import Page.Share.Data +import Page.ShareDetail.Data import Page.Upload.Data import Page.UserSettings.Data import Url exposing (Url) @@ -52,6 +54,8 @@ type alias Model = , uploadModel : Page.Upload.Data.Model , newInviteModel : Page.NewInvite.Data.Model , itemDetailModel : Page.ItemDetail.Data.Model + , shareModel : Page.Share.Data.Model + , shareDetailModel : Page.ShareDetail.Data.Model , navMenuOpen : Bool , userMenuOpen : Bool , subs : Sub Msg @@ -85,6 +89,12 @@ init key url flags_ settings = ( loginm, loginc ) = Page.Login.Data.init flags (Page.loginPageReferrer page) + ( shm, shc ) = + Page.Share.Data.init (Page.pageShareId page) flags + + ( sdm, sdc ) = + Page.ShareDetail.Data.init (Page.pageShareDetail page) flags + homeViewMode = if settings.searchMenuVisible then Page.Home.Data.SearchView @@ -106,6 +116,8 @@ init key url flags_ settings = , uploadModel = Page.Upload.Data.emptyModel , newInviteModel = Page.NewInvite.Data.emptyModel , itemDetailModel = Page.ItemDetail.Data.emptyModel + , shareModel = shm + , shareDetailModel = sdm , navMenuOpen = False , userMenuOpen = False , subs = Sub.none @@ -120,6 +132,8 @@ init key url flags_ settings = , Cmd.map ManageDataMsg mdc , Cmd.map CollSettingsMsg csc , Cmd.map LoginMsg loginc + , Cmd.map ShareMsg shc + , Cmd.map ShareDetailMsg sdc ] ) @@ -162,6 +176,8 @@ type Msg | UploadMsg Page.Upload.Data.Msg | NewInviteMsg Page.NewInvite.Data.Msg | ItemDetailMsg Page.ItemDetail.Data.Msg + | ShareMsg Page.Share.Data.Msg + | ShareDetailMsg Page.ShareDetail.Data.Msg | Logout | LogoutResp (Result Http.Error ()) | SessionCheckResp (Result Http.Error AuthResult) diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm index bd760a70..47d7ad9b 100644 --- a/modules/webapp/src/main/elm/App/Update.elm +++ b/modules/webapp/src/main/elm/App/Update.elm @@ -17,6 +17,7 @@ import Browser.Navigation as Nav import Data.Flags import Data.UiSettings exposing (UiSettings) import Data.UiTheme +import Messages exposing (Messages) import Page exposing (Page(..)) import Page.CollectiveSettings.Data import Page.CollectiveSettings.Update @@ -34,6 +35,10 @@ import Page.Queue.Data import Page.Queue.Update import Page.Register.Data import Page.Register.Update +import Page.Share.Data +import Page.Share.Update +import Page.ShareDetail.Data +import Page.ShareDetail.Update import Page.Upload.Data import Page.Upload.Update import Page.UserSettings.Data @@ -55,6 +60,10 @@ update msg model = updateWithSub : Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) updateWithSub msg model = + let + texts = + Messages.get <| App.Data.getUiLanguage model + in case msg of ToggleSidebar -> ( { model | sidebarVisible = not model.sidebarVisible }, Cmd.none, Sub.none ) @@ -94,7 +103,7 @@ updateWithSub msg model = ClientSettingsSaveResp settings (Ok res) -> if res.success then - applyClientSettings model settings + applyClientSettings texts model settings else ( model, Cmd.none, Sub.none ) @@ -112,7 +121,13 @@ updateWithSub msg model = ( { model | anonymousUiLang = lang, langMenuOpen = False }, Cmd.none, Sub.none ) HomeMsg lm -> - updateHome lm model + updateHome texts lm model + + ShareMsg lm -> + updateShare lm model + + ShareDetailMsg lm -> + updateShareDetail lm model LoginMsg lm -> updateLogin lm model @@ -121,10 +136,10 @@ updateWithSub msg model = updateManageData lm model CollSettingsMsg m -> - updateCollSettings m model + updateCollSettings texts m model UserSettingsMsg m -> - updateUserSettings m model + updateUserSettings texts m model QueueMsg m -> updateQueue m model @@ -139,7 +154,7 @@ updateWithSub msg model = updateNewInvite m model ItemDetailMsg m -> - updateItemDetail m model + updateItemDetail texts m model VersionResp (Ok info) -> ( { model | version = info }, Cmd.none, Sub.none ) @@ -281,7 +296,7 @@ updateWithSub msg model = ) GetUiSettings (Ok settings) -> - applyClientSettings model settings + applyClientSettings texts model settings GetUiSettings (Err _) -> ( model, Cmd.none, Sub.none ) @@ -291,11 +306,11 @@ updateWithSub msg model = lm = Page.UserSettings.Data.ReceiveBrowserSettings sett in - updateUserSettings lm model + updateUserSettings texts lm model -applyClientSettings : Model -> UiSettings -> ( Model, Cmd Msg, Sub Msg ) -applyClientSettings model settings = +applyClientSettings : Messages -> Model -> UiSettings -> ( Model, Cmd Msg, Sub Msg ) +applyClientSettings texts model settings = let setTheme = Ports.setUiTheme settings.uiTheme @@ -306,15 +321,49 @@ applyClientSettings model settings = , setTheme , Sub.none ) - , updateUserSettings Page.UserSettings.Data.UpdateSettings - , updateHome Page.Home.Data.UiSettingsUpdated - , updateItemDetail Page.ItemDetail.Data.UiSettingsUpdated + , updateUserSettings texts Page.UserSettings.Data.UpdateSettings + , updateHome texts Page.Home.Data.UiSettingsUpdated + , updateItemDetail texts Page.ItemDetail.Data.UiSettingsUpdated ] { model | uiSettings = settings } -updateItemDetail : Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) -updateItemDetail lmsg model = +updateShareDetail : Page.ShareDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +updateShareDetail lmsg model = + case Page.pageShareDetail model.page of + Just ( shareId, itemId ) -> + let + ( m, c ) = + Page.ShareDetail.Update.update shareId itemId model.flags lmsg model.shareDetailModel + in + ( { model | shareDetailModel = m } + , Cmd.map ShareDetailMsg c + , Sub.none + ) + + Nothing -> + ( model, Cmd.none, Sub.none ) + + +updateShare : Page.Share.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +updateShare lmsg model = + case Page.pageShareId model.page of + Just id -> + let + result = + Page.Share.Update.update model.flags model.uiSettings id lmsg model.shareModel + in + ( { model | shareModel = result.model } + , Cmd.map ShareMsg result.cmd + , Sub.map ShareMsg result.sub + ) + + Nothing -> + ( model, Cmd.none, Sub.none ) + + +updateItemDetail : Messages -> Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +updateItemDetail texts lmsg model = let inav = Page.Home.Data.itemNav model.itemDetailModel.detail.item.id model.homeModel @@ -334,12 +383,12 @@ updateItemDetail lmsg model = } ( hm, hc, hs ) = - updateHome (Page.Home.Data.SetLinkTarget result.linkTarget) model_ + updateHome texts (Page.Home.Data.SetLinkTarget result.linkTarget) model_ ( hm1, hc1, hs1 ) = case result.removedItem of Just removedId -> - updateHome (Page.Home.Data.RemoveItem removedId) hm + updateHome texts (Page.Home.Data.RemoveItem removedId) hm Nothing -> ( hm, hc, hs ) @@ -402,8 +451,8 @@ updateQueue lmsg model = ) -updateUserSettings : Page.UserSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) -updateUserSettings lmsg model = +updateUserSettings : Messages -> Page.UserSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +updateUserSettings texts lmsg model = let result = Page.UserSettings.Update.update model.flags model.uiSettings lmsg model.userSettingsModel @@ -414,7 +463,7 @@ updateUserSettings lmsg model = ( lm2, lc2, s2 ) = case result.newSettings of Just sett -> - applyClientSettings model_ sett + applyClientSettings texts model_ sett Nothing -> ( model_, Cmd.none, Sub.none ) @@ -431,17 +480,18 @@ updateUserSettings lmsg model = ) -updateCollSettings : Page.CollectiveSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) -updateCollSettings lmsg model = +updateCollSettings : Messages -> Page.CollectiveSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +updateCollSettings texts lmsg model = let - ( lm, lc ) = - Page.CollectiveSettings.Update.update model.flags + ( lm, lc, ls ) = + Page.CollectiveSettings.Update.update texts.collectiveSettings + model.flags lmsg model.collSettingsModel in ( { model | collSettingsModel = lm } , Cmd.map CollSettingsMsg lc - , Sub.none + , Sub.map CollSettingsMsg ls ) @@ -464,8 +514,8 @@ updateLogin lmsg model = ) -updateHome : Page.Home.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) -updateHome lmsg model = +updateHome : Messages -> Page.Home.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +updateHome texts lmsg model = let mid = case model.page of @@ -476,7 +526,7 @@ updateHome lmsg model = Nothing result = - Page.Home.Update.update mid model.key model.flags model.uiSettings lmsg model.homeModel + Page.Home.Update.update mid model.key model.flags texts.home model.uiSettings lmsg model.homeModel model_ = { model | homeModel = result.model } @@ -484,7 +534,7 @@ updateHome lmsg model = ( lm, lc, ls ) = case result.newSettings of Just sett -> - applyClientSettings model_ sett + applyClientSettings texts model_ sett Nothing -> ( model_, Cmd.none, Sub.none ) @@ -518,11 +568,14 @@ initPage model_ page = let model = { model_ | page = page } + + texts = + Messages.get <| App.Data.getUiLanguage model in case page of HomePage -> Util.Update.andThen2 - [ updateHome Page.Home.Data.Init + [ updateHome texts Page.Home.Data.Init , updateQueue Page.Queue.Data.StopRefresh ] model @@ -536,7 +589,7 @@ initPage model_ page = CollectiveSettingPage -> Util.Update.andThen2 [ updateQueue Page.Queue.Data.StopRefresh - , updateCollSettings Page.CollectiveSettings.Data.Init + , updateCollSettings texts Page.CollectiveSettings.Data.Init ] model @@ -564,7 +617,33 @@ initPage model_ page = ItemDetailPage id -> Util.Update.andThen2 - [ updateItemDetail (Page.ItemDetail.Data.Init id) + [ updateItemDetail texts (Page.ItemDetail.Data.Init id) , updateQueue Page.Queue.Data.StopRefresh ] model + + SharePage id -> + let + cmd = + Cmd.map ShareMsg (Page.Share.Data.initCmd id model.flags) + + shareModel = + model.shareModel + in + if shareModel.initialized then + ( model, Cmd.none, Sub.none ) + + else + ( { model | shareModel = { shareModel | initialized = True } }, cmd, Sub.none ) + + ShareDetailPage _ _ -> + case model_.page of + SharePage _ -> + let + verifyResult = + model.shareModel.verifyResult + in + updateShareDetail (Page.ShareDetail.Data.VerifyResp (Ok verifyResult)) model + + _ -> + ( model, Cmd.none, Sub.none ) diff --git a/modules/webapp/src/main/elm/App/View2.elm b/modules/webapp/src/main/elm/App/View2.elm index d2b277b7..75159ed1 100644 --- a/modules/webapp/src/main/elm/App/View2.elm +++ b/modules/webapp/src/main/elm/App/View2.elm @@ -27,6 +27,8 @@ import Page.ManageData.View2 as ManageData import Page.NewInvite.View2 as NewInvite import Page.Queue.View2 as Queue import Page.Register.View2 as Register +import Page.Share.View as Share +import Page.ShareDetail.View as ShareDetail import Page.Upload.View2 as Upload import Page.UserSettings.View2 as UserSettings import Styles as S @@ -41,13 +43,9 @@ view model = topNavbar : Model -> Html Msg topNavbar model = - case model.flags.account of + case Data.Flags.getAccount model.flags of Just acc -> - if acc.success then - topNavUser acc model - - else - topNavAnon model + topNavUser acc model Nothing -> topNavAnon model @@ -72,7 +70,7 @@ topNavUser auth model = , baseStyle = "font-bold inline-flex items-center px-4 py-2" , activeStyle = "hover:bg-blue-200 dark:hover:bg-bluegray-800 w-12" } - , headerNavItem model + , headerNavItem True model , div [ class "flex flex-grow justify-end" ] [ userMenu texts.app auth model , dataMenu texts.app auth model @@ -86,7 +84,16 @@ topNavAnon model = [ id "top-nav" , class styleTopNav ] - [ headerNavItem model + [ B.genericButton + { label = "" + , icon = "fa fa-bars" + , handler = onClick ToggleSidebar + , disabled = not (Page.hasSidebar model.page) + , attrs = [ href "#" ] + , baseStyle = "font-bold inline-flex items-center px-4 py-2" + , activeStyle = "hover:bg-blue-200 dark:hover:bg-bluegray-800 w-12" + } + , headerNavItem False model , div [ class "flex flex-grow justify-end" ] [ langMenu model , a @@ -100,11 +107,24 @@ topNavAnon model = ] -headerNavItem : Model -> Html Msg -headerNavItem model = - a - [ class "inline-flex font-bold hover:bg-blue-200 dark:hover:bg-bluegray-800 items-center px-4" - , Page.href HomePage +headerNavItem : Bool -> Model -> Html Msg +headerNavItem authenticated model = + let + tag = + if authenticated then + a + + else + div + in + tag + [ class "inline-flex font-bold items-center px-4" + , classList [ ( "hover:bg-blue-200 dark:hover:bg-bluegray-800", authenticated ) ] + , if authenticated then + Page.href HomePage + + else + href "#" ] [ img [ src (model.flags.config.docspellAssetPath ++ "/img/logo-96.png") @@ -157,6 +177,12 @@ mainContent model = ItemDetailPage id -> viewItemDetail texts id model + + SharePage id -> + viewShare texts id model + + ShareDetailPage shareId itemId -> + viewShareDetail texts shareId itemId model ) @@ -411,6 +437,49 @@ dropdownMenu = " absolute right-0 bg-white dark:bg-bluegray-800 border dark:border-bluegray-700 dark:text-bluegray-300 shadow-lg opacity-1 transition duration-200 min-w-max " +viewShare : Messages -> String -> Model -> List (Html Msg) +viewShare texts shareId model = + [ Html.map ShareMsg + (Share.viewSidebar texts.share + model.sidebarVisible + model.flags + model.uiSettings + model.shareModel + ) + , Html.map ShareMsg + (Share.viewContent texts.share + model.flags + model.version + model.uiSettings + shareId + model.shareModel + ) + ] + + +viewShareDetail : Messages -> String -> String -> Model -> List (Html Msg) +viewShareDetail texts shareId itemId model = + [ Html.map ShareDetailMsg + (ShareDetail.viewSidebar texts.shareDetail + model.sidebarVisible + model.flags + model.uiSettings + shareId + itemId + model.shareDetailModel + ) + , Html.map ShareDetailMsg + (ShareDetail.viewContent texts.shareDetail + model.flags + model.uiSettings + model.version + shareId + itemId + model.shareDetailModel + ) + ] + + viewHome : Messages -> Model -> List (Html Msg) viewHome texts model = [ Html.map HomeMsg diff --git a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm index fee15790..f7994b4a 100644 --- a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm @@ -22,6 +22,7 @@ import Comp.ClassifierSettingsForm import Comp.Dropdown import Comp.EmptyTrashForm import Comp.MenuBar as MB +import Comp.StringListInput import Data.DropdownStyle as DS import Data.Flags exposing (Flags) import Data.Language exposing (Language) @@ -30,6 +31,7 @@ import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) import Http +import Markdown import Messages.Comp.CollectiveSettingsForm exposing (Texts) import Styles as S @@ -44,6 +46,8 @@ type alias Model = , startClassifierResult : ClassifierResult , emptyTrashModel : Comp.EmptyTrashForm.Model , startEmptyTrashResult : EmptyTrashResult + , passwordModel : Comp.StringListInput.Model + , passwords : List String } @@ -96,6 +100,8 @@ init flags settings = , startClassifierResult = ClassifierResultInitial , emptyTrashModel = em , startEmptyTrashResult = EmptyTrashResultInitial + , passwordModel = Comp.StringListInput.init + , passwords = settings.passwords } , Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ] ) @@ -114,6 +120,7 @@ getSettings model = , integrationEnabled = model.intEnabled , classifier = cls , emptyTrash = trash + , passwords = model.passwords } ) (Comp.ClassifierSettingsForm.getSettings model.classifierModel) @@ -133,6 +140,7 @@ type Msg | StartEmptyTrashTask | StartClassifierResp (Result Http.Error BasicResult) | StartEmptyTrashResp (Result Http.Error BasicResult) + | PasswordMsg Comp.StringListInput.Msg update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings ) @@ -285,6 +293,27 @@ update flags msg model = , Nothing ) + PasswordMsg lm -> + let + ( pm, action ) = + Comp.StringListInput.update lm model.passwordModel + + pws = + case action of + Comp.StringListInput.AddAction pw -> + pw :: model.passwords + + Comp.StringListInput.RemoveAction pw -> + List.filter (\e -> e /= pw) model.passwords + + Comp.StringListInput.NoAction -> + model.passwords + in + ( { model | passwordModel = pm, passwords = pws } + , Cmd.none + , Nothing + ) + --- View2 @@ -460,6 +489,18 @@ view2 flags texts settings model = ] ] ] + , div [] + [ h2 [ class S.header2 ] + [ text texts.passwords + ] + , div [ class "mb-4" ] + [ div [ class "opacity-50 text-sm" ] + [ Markdown.toHtml [] texts.passwordsInfo + ] + , Html.map PasswordMsg + (Comp.StringListInput.view2 model.passwords model.passwordModel) + ] + ] ] diff --git a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm index 27d11480..6a60260e 100644 --- a/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm +++ b/modules/webapp/src/main/elm/Comp/CustomFieldMultiInput.elm @@ -16,6 +16,7 @@ module Comp.CustomFieldMultiInput exposing , isEmpty , nonEmpty , reset + , setOptions , setValues , update , updateSearch @@ -125,6 +126,11 @@ setValues values = SetValues values +setOptions : List CustomField -> Msg +setOptions fields = + CustomFieldResp (Ok (CustomFieldList fields)) + + reset : Model -> Model reset model = let diff --git a/modules/webapp/src/main/elm/Comp/DatePicker.elm b/modules/webapp/src/main/elm/Comp/DatePicker.elm index 3804c296..e0383651 100644 --- a/modules/webapp/src/main/elm/Comp/DatePicker.elm +++ b/modules/webapp/src/main/elm/Comp/DatePicker.elm @@ -37,7 +37,7 @@ init = emptyModel : DatePicker emptyModel = - DatePicker.initFromDate (Date.fromCalendarDate 2019 Aug 21) + DatePicker.initFromDate (Date.fromCalendarDate 2021 Oct 31) defaultSettings : Settings diff --git a/modules/webapp/src/main/elm/Comp/ItemCard.elm b/modules/webapp/src/main/elm/Comp/ItemCard.elm index 9fd19ff3..093afbc2 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCard.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCard.elm @@ -22,6 +22,7 @@ import Api.Model.ItemLight exposing (ItemLight) import Comp.LinkTarget exposing (LinkTarget(..)) import Data.Direction import Data.Fields +import Data.Flags exposing (Flags) import Data.Icons as Icons import Data.ItemSelection exposing (ItemSelection) import Data.ItemTemplate as IT @@ -56,6 +57,10 @@ type Msg type alias ViewConfig = { selection : ItemSelection , extraClasses : String + , previewUrl : AttachmentLight -> String + , previewUrlFallback : ItemLight -> String + , attachUrl : AttachmentLight -> String + , detailPage : ItemLight -> Page } @@ -146,8 +151,8 @@ update ddm msg model = --- View2 -view2 : Texts -> ViewConfig -> UiSettings -> Model -> ItemLight -> Html Msg -view2 texts cfg settings model item = +view2 : Texts -> ViewConfig -> UiSettings -> Flags -> Model -> ItemLight -> Html Msg +view2 texts cfg settings flags model item = let isCreated = item.state == "created" @@ -160,7 +165,7 @@ view2 texts cfg settings model item = "text-blue-500 dark:text-lightblue-500" else if isDeleted then - "text-red-600 dark:text-orange-600" + "text-red-600 dark:text-orange-600" else "" @@ -171,7 +176,7 @@ view2 texts cfg settings model item = cardAction = case cfg.selection of Data.ItemSelection.Inactive -> - [ Page.href (ItemDetailPage item.id) + [ Page.href (cfg.detailPage item) ] Data.ItemSelection.Active ids -> @@ -210,14 +215,14 @@ view2 texts cfg settings model item = [] else - [ previewImage2 settings cardAction model item + [ previewImage2 cfg settings cardAction model item ] ) ++ [ mainContent2 texts cardAction cardColor isCreated isDeleted settings cfg item , metaDataContent2 texts settings item , notesContent2 settings item , fulltextResultsContent2 item - , previewMenu2 texts settings model item (currentAttachment model item) + , previewMenu2 texts settings flags cfg model item (currentAttachment model item) , selectedDimmer ] ) @@ -443,16 +448,15 @@ mainTagsAndFields2 settings item = (renderFields ++ renderTags) -previewImage2 : UiSettings -> List (Attribute Msg) -> Model -> ItemLight -> Html Msg -previewImage2 settings cardAction model item = +previewImage2 : ViewConfig -> UiSettings -> List (Attribute Msg) -> Model -> ItemLight -> Html Msg +previewImage2 cfg settings cardAction model item = let mainAttach = currentAttachment model item previewUrl = - Maybe.map .id mainAttach - |> Maybe.map Api.attachmentPreviewURL - |> Maybe.withDefault (Api.itemBasePreviewURL item.id) + Maybe.map cfg.previewUrl mainAttach + |> Maybe.withDefault (cfg.previewUrlFallback item) in a ([ class "overflow-hidden block bg-gray-50 dark:bg-bluegray-700 dark:bg-opacity-40 border-gray-400 dark:hover:border-bluegray-500 rounded-t-lg" @@ -472,8 +476,8 @@ previewImage2 settings cardAction model item = ] -previewMenu2 : Texts -> UiSettings -> Model -> ItemLight -> Maybe AttachmentLight -> Html Msg -previewMenu2 texts settings model item mainAttach = +previewMenu2 : Texts -> UiSettings -> Flags -> ViewConfig -> Model -> ItemLight -> Maybe AttachmentLight -> Html Msg +previewMenu2 texts settings flags cfg model item mainAttach = let pageCount = Maybe.andThen .pageCount mainAttach @@ -485,16 +489,11 @@ previewMenu2 texts settings model item mainAttach = fieldHidden f = Data.UiSettings.fieldHidden settings f - mkAttachUrl id = - if settings.nativePdfPreview then - Api.fileURL id - - else - Api.fileURL id ++ "/view" + mkAttachUrl attach = + Data.UiSettings.pdfUrl settings flags (cfg.attachUrl attach) attachUrl = - Maybe.map .id mainAttach - |> Maybe.map mkAttachUrl + Maybe.map mkAttachUrl mainAttach |> Maybe.withDefault "/api/v1/sec/attachment/none" dueDate = @@ -529,7 +528,7 @@ previewMenu2 texts settings model item mainAttach = , a [ class S.secondaryBasicButtonPlain , class "px-2 py-1 border rounded ml-2" - , Page.href (ItemDetailPage item.id) + , Page.href (cfg.detailPage item) , title texts.gotoDetail ] [ i [ class "fa fa-edit" ] [] diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm index 0d0c97e0..df8b7af4 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm @@ -17,6 +17,7 @@ module Comp.ItemCardList exposing , view2 ) +import Api.Model.AttachmentLight exposing (AttachmentLight) import Api.Model.ItemLight exposing (ItemLight) import Api.Model.ItemLightGroup exposing (ItemLightGroup) import Api.Model.ItemLightList exposing (ItemLightList) @@ -72,13 +73,13 @@ prevItem model id = --- Update -update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, LinkTarget ) update flags msg model = let res = updateDrag DD.init flags msg model in - ( res.model, res.cmd ) + ( res.model, res.cmd, res.linkTarget ) type alias UpdateResult = @@ -161,22 +162,26 @@ updateDrag dm _ msg model = type alias ViewConfig = { current : Maybe String , selection : ItemSelection + , previewUrl : AttachmentLight -> String + , previewUrlFallback : ItemLight -> String + , attachUrl : AttachmentLight -> String + , detailPage : ItemLight -> Page } -view2 : Texts -> ViewConfig -> UiSettings -> Model -> Html Msg -view2 texts cfg settings model = +view2 : Texts -> ViewConfig -> UiSettings -> Flags -> Model -> Html Msg +view2 texts cfg settings flags model = div [ classList [ ( "ds-item-list", True ) , ( "ds-multi-select-mode", isMultiSelectMode cfg ) ] ] - (List.map (viewGroup2 texts model cfg settings) model.results.groups) + (List.map (viewGroup2 texts model cfg settings flags) model.results.groups) -viewGroup2 : Texts -> Model -> ViewConfig -> UiSettings -> ItemLightGroup -> Html Msg -viewGroup2 texts model cfg settings group = +viewGroup2 : Texts -> Model -> ViewConfig -> UiSettings -> Flags -> ItemLightGroup -> Html Msg +viewGroup2 texts model cfg settings flags group = div [ class "ds-item-group" ] [ div [ class "flex py-1 mt-2 mb-2 flex flex-row items-center" @@ -201,12 +206,12 @@ viewGroup2 texts model cfg settings group = [] ] , div [ class "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-2" ] - (List.map (viewItem2 texts model cfg settings) group.items) + (List.map (viewItem2 texts model cfg settings flags) group.items) ] -viewItem2 : Texts -> Model -> ViewConfig -> UiSettings -> ItemLight -> Html Msg -viewItem2 texts model cfg settings item = +viewItem2 : Texts -> Model -> ViewConfig -> UiSettings -> Flags -> ItemLight -> Html Msg +viewItem2 texts model cfg settings flags item = let currentClass = if cfg.current == Just item.id then @@ -216,14 +221,14 @@ viewItem2 texts model cfg settings item = "" vvcfg = - Comp.ItemCard.ViewConfig cfg.selection currentClass + Comp.ItemCard.ViewConfig cfg.selection currentClass cfg.previewUrl cfg.previewUrlFallback cfg.attachUrl cfg.detailPage cardModel = Dict.get item.id model.itemCards |> Maybe.withDefault Comp.ItemCard.init cardHtml = - Comp.ItemCard.view2 texts.itemCard vvcfg settings cardModel item + Comp.ItemCard.view2 texts.itemCard vvcfg settings flags cardModel item in Html.map (ItemCardMsg item) cardHtml diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index 1302791d..d995ef7d 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm @@ -100,7 +100,6 @@ type alias Model = , sentMailsOpen : Bool , attachMeta : Dict String Comp.AttachmentMeta.Model , attachMetaOpen : Bool - , pdfNativeView : Maybe Bool , attachModal : Maybe ConfirmModalValue , addFilesOpen : Bool , addFilesModel : Comp.Dropzone.Model @@ -236,7 +235,6 @@ emptyModel = , sentMailsOpen = False , attachMeta = Dict.empty , attachMetaOpen = False - , pdfNativeView = Nothing , attachModal = Nothing , addFilesOpen = False , addFilesModel = Comp.Dropzone.init [] @@ -316,7 +314,6 @@ type Msg | SentMailsResp (Result Http.Error SentMails) | AttachMetaClick String | AttachMetaMsg String Comp.AttachmentMeta.Msg - | TogglePdfNativeView Bool | RequestDeleteAttachment String | DeleteAttachConfirmed String | RequestDeleteSelected diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/ShowQrCode.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/ShowQrCode.elm index cfd1ae22..a6e3a96f 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/ShowQrCode.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/ShowQrCode.elm @@ -17,6 +17,7 @@ import Html.Attributes exposing (..) import Html.Events exposing (onClick) import QRCode import Styles as S +import Svg.Attributes as SvgA view : Flags -> String -> Model -> UrlId -> Html Msg @@ -111,7 +112,7 @@ type UrlId qrCodeView : String -> Html msg qrCodeView message = - QRCode.encode message - |> Result.map QRCode.toSvg + QRCode.fromString message + |> Result.map (QRCode.toSvg [ SvgA.class "w-64 h-64" ]) |> Result.withDefault (text "Error generating QR code") diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm index 61143847..56d63635 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm @@ -85,12 +85,8 @@ view texts flags settings model pos attach = , style "max-height" "calc(100vh - 140px)" , style "min-height" "500px" ] - [ iframe - [ if Maybe.withDefault settings.nativePdfPreview model.pdfNativeView then - src fileUrl - - else - src (fileUrl ++ "/view") + [ embed + [ src <| Data.UiSettings.pdfUrl settings flags fileUrl , class "absolute h-full w-full top-0 left-0 mx-0 py-0" , id "ds-pdf-view-iframe" ] @@ -157,6 +153,7 @@ attachHeader texts settings model _ attach = [ href "#" , onClick ToggleAttachMenu , class S.secondaryBasicButton + , class "mr-2" , classList [ ( "bg-gray-200 dark:bg-bluegray-600 ", model.attachMenuOpen ) , ( "hidden", not multiAttach ) @@ -164,12 +161,16 @@ attachHeader texts settings model _ attach = , ( "hidden sm:block", multiAttach && not mobile ) ] ] - [ i [ class "fa fa-images font-thin" ] [] + [ if model.attachMenuOpen then + i [ class "fa fa-chevron-up" ] [] + + else + i [ class "fa fa-chevron-down" ] [] ] in div [ class "flex flex-col sm:flex-row items-center w-full" ] [ attachSelectToggle False - , div [ class "ml-2 text-base font-bold flex-grow w-full text-center sm:text-left break-all" ] + , div [ class "text-base font-bold flex-grow w-full text-center sm:text-left break-all" ] [ text attachName , text " (" , text (Util.Size.bytesReadable Util.Size.B (toFloat attach.size)) @@ -254,18 +255,6 @@ attachHeader texts settings model _ attach = , classList [ ( "hidden", not attach.converted ) ] ] } - , { icon = - if Maybe.withDefault settings.nativePdfPreview model.pdfNativeView then - "fa fa-toggle-on" - - else - "fa fa-toggle-off" - , label = texts.renderPdfByBrowser - , attrs = - [ onClick (TogglePdfNativeView settings.nativePdfPreview) - , href "#" - ] - } , { icon = if isAttachMetaOpen model attach.id then "fa fa-toggle-on" diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 4299e632..c6b41490 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -913,19 +913,6 @@ update key flags inav settings msg model = Nothing -> resultModel model - TogglePdfNativeView default -> - resultModel - { model - | pdfNativeView = - case model.pdfNativeView of - Just flag -> - Just (not flag) - - Nothing -> - Just (not default) - , attachmentDropdownOpen = False - } - DeleteAttachConfirmed attachId -> let cmd = diff --git a/modules/webapp/src/main/elm/Comp/ItemMail.elm b/modules/webapp/src/main/elm/Comp/ItemMail.elm index e135c45f..e7a47b80 100644 --- a/modules/webapp/src/main/elm/Comp/ItemMail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemMail.elm @@ -10,9 +10,12 @@ module Comp.ItemMail exposing , Model , Msg , clear + , clearRecipients , emptyModel , init + , setMailInfo , update + , view , view2 ) @@ -28,7 +31,7 @@ import Data.Flags exposing (Flags) import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onClick, onInput) +import Html.Events exposing (onClick, onFocus, onInput) import Http import Messages.Comp.ItemMail exposing (Texts) import Styles as S @@ -46,6 +49,7 @@ type alias Model = , body : String , attachAll : Bool , formError : FormError + , showCC : Bool } @@ -61,6 +65,8 @@ type Msg | CCRecipientMsg Comp.EmailInput.Msg | BCCRecipientMsg Comp.EmailInput.Msg | SetBody String + | SetSubjectBody String String + | ToggleShowCC | ConnMsg (Comp.Dropdown.Msg String) | ConnResp (Result Http.Error EmailSettingsList) | ToggleAttachAll @@ -93,6 +99,7 @@ emptyModel = , body = "" , attachAll = True , formError = FormErrorNone + , showCC = False } @@ -112,12 +119,29 @@ clear model = } +clearRecipients : Model -> Model +clearRecipients model = + { model + | recipients = [] + , ccRecipients = [] + , bccRecipients = [] + } + + +setMailInfo : String -> String -> Msg +setMailInfo subject body = + SetSubjectBody subject body + + update : Flags -> Msg -> Model -> ( Model, Cmd Msg, FormAction ) update flags msg model = case msg of SetSubject str -> ( { model | subject = str }, Cmd.none, FormNone ) + SetSubjectBody subj body -> + ( { model | subject = subj, body = body }, Cmd.none, FormNone ) + RecipientMsg m -> let ( em, ec, rec ) = @@ -168,6 +192,9 @@ update flags msg model = ToggleAttachAll -> ( { model | attachAll = not model.attachAll }, Cmd.none, FormNone ) + ToggleShowCC -> + ( { model | showCC = not model.showCC }, Cmd.none, FormNone ) + ConnResp (Ok list) -> let names = @@ -239,8 +266,27 @@ isValid model = --- View2 +type alias ViewConfig = + { withAttachments : Bool + , textAreaClass : String + , showCancel : Bool + } + + view2 : Texts -> UiSettings -> Model -> Html Msg view2 texts settings model = + let + cfg = + { withAttachments = True + , textAreaClass = "" + , showCancel = True + } + in + view texts settings cfg model + + +view : Texts -> UiSettings -> ViewConfig -> Model -> Html Msg +view texts settings cfg model = let dds = Data.DropdownStyle.mainStyle @@ -284,9 +330,22 @@ view2 texts settings model = , div [ class "mb-4" ] [ label [ class S.inputLabel + , class "flex flex-row" ] [ text texts.recipients , B.inputRequired + , a + [ class S.link + , class "justify-end flex flex-grow" + , onClick ToggleShowCC + , href "#" + ] + [ if model.showCC then + text texts.lessRecipients + + else + text texts.moreRecipients + ] ] , Html.map RecipientMsg (Comp.EmailInput.view2 { style = dds, placeholder = appendDots texts.recipients } @@ -294,7 +353,10 @@ view2 texts settings model = model.recipientsModel ) ] - , div [ class "mb-4" ] + , div + [ class "mb-4" + , classList [ ( "hidden", not model.showCC ) ] + ] [ label [ class S.inputLabel ] [ text texts.ccRecipients ] @@ -304,7 +366,10 @@ view2 texts settings model = model.ccRecipientsModel ) ] - , div [ class "mb-4" ] + , div + [ class "mb-4" + , classList [ ( "hidden", not model.showCC ) ] + ] [ label [ class S.inputLabel ] [ text texts.bccRecipients ] @@ -336,16 +401,21 @@ view2 texts settings model = [ onInput SetBody , value model.body , class S.textAreaInput + , class cfg.textAreaClass ] [] ] - , MB.viewItem <| - MB.Checkbox - { tagger = \_ -> ToggleAttachAll - , label = texts.includeAllAttachments - , value = model.attachAll - , id = "item-send-mail-attach-all" - } + , if cfg.withAttachments then + MB.viewItem <| + MB.Checkbox + { tagger = \_ -> ToggleAttachAll + , label = texts.includeAllAttachments + , value = model.attachAll + , id = "item-send-mail-attach-all" + } + + else + span [ class "hidden" ] [] , div [ class "flex flex-row space-x-2" ] [ B.primaryButton { label = texts.sendLabel @@ -358,7 +428,10 @@ view2 texts settings model = { label = texts.basics.cancel , icon = "fa fa-times" , handler = onClick Cancel - , attrs = [ href "#" ] + , attrs = + [ href "#" + , classList [ ( "hidden", not cfg.showCancel ) ] + ] , disabled = False } ] diff --git a/modules/webapp/src/main/elm/Comp/OtpSetup.elm b/modules/webapp/src/main/elm/Comp/OtpSetup.elm index 46db0f75..c972eb95 100644 --- a/modules/webapp/src/main/elm/Comp/OtpSetup.elm +++ b/modules/webapp/src/main/elm/Comp/OtpSetup.elm @@ -23,6 +23,7 @@ import Markdown import Messages.Comp.OtpSetup exposing (Texts) import QRCode import Styles as S +import Svg.Attributes as SvgA type Model @@ -389,8 +390,8 @@ viewDisabled texts model = qrCodeView : Texts -> String -> Html msg qrCodeView texts message = - QRCode.encode message - |> Result.map QRCode.toSvg + QRCode.fromString message + |> Result.map (QRCode.toSvg [ SvgA.class "w-64 h-64" ]) |> Result.withDefault (Html.text texts.errorGeneratingQR) diff --git a/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm b/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm index e06ef8c9..1036452d 100644 --- a/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm +++ b/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm @@ -11,6 +11,8 @@ module Comp.PowerSearchInput exposing , Msg , ViewSettings , init + , isValid + , setSearchString , update , viewInput , viewResult @@ -43,6 +45,11 @@ init = } +isValid : Model -> Bool +isValid model = + model.input /= Nothing && model.result.success + + type Msg = SetSearch String | KeyUpMsg (Maybe KeyCode) @@ -63,6 +70,11 @@ type alias Result = } +setSearchString : String -> Msg +setSearchString q = + SetSearch q + + --- Update diff --git a/modules/webapp/src/main/elm/Comp/PublishItems.elm b/modules/webapp/src/main/elm/Comp/PublishItems.elm new file mode 100644 index 00000000..5679f80a --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/PublishItems.elm @@ -0,0 +1,373 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.PublishItems exposing + ( Model + , Msg + , Outcome(..) + , init + , initQuery + , update + , view + ) + +import Api +import Api.Model.IdResult exposing (IdResult) +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Basic as B +import Comp.MenuBar as MB +import Comp.ShareForm +import Comp.ShareMail +import Comp.ShareView +import Data.Flags exposing (Flags) +import Data.Icons as Icons +import Data.ItemQuery exposing (ItemQuery) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.PublishItems exposing (Texts) +import Ports +import Styles as S + + + +--- Model + + +type ViewMode + = ViewModeEdit + | ViewModeInfo ShareDetail + + +type FormError + = FormErrorNone + | FormErrorHttp Http.Error + | FormErrorInvalid + | FormErrorSubmit String + + +type alias Model = + { formModel : Comp.ShareForm.Model + , mailModel : Comp.ShareMail.Model + , viewMode : ViewMode + , formError : FormError + , loading : Bool + , mailVisible : Bool + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + let + ( fm, fc ) = + Comp.ShareForm.init + + ( mm, mc ) = + Comp.ShareMail.init flags + in + ( { formModel = fm + , mailModel = mm + , viewMode = ViewModeEdit + , formError = FormErrorNone + , loading = False + , mailVisible = False + } + , Cmd.batch + [ Cmd.map FormMsg fc + , Cmd.map MailMsg mc + ] + ) + + +initQuery : Flags -> ItemQuery -> ( Model, Cmd Msg ) +initQuery flags query = + let + ( fm, fc ) = + Comp.ShareForm.initQuery (Data.ItemQuery.render query) + + ( mm, mc ) = + Comp.ShareMail.init flags + in + ( { formModel = fm + , mailModel = mm + , viewMode = ViewModeEdit + , formError = FormErrorNone + , loading = False + , mailVisible = False + } + , Cmd.batch + [ Cmd.map FormMsg fc + , Cmd.map MailMsg mc + ] + ) + + + +--- Update + + +type Msg + = FormMsg Comp.ShareForm.Msg + | MailMsg Comp.ShareMail.Msg + | CancelPublish + | SubmitPublish + | PublishResp (Result Http.Error IdResult) + | GetShareResp (Result Http.Error ShareDetail) + | ToggleMailVisible + + +type Outcome + = OutcomeDone + | OutcomeInProgress + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , outcome : Outcome + } + + +update : Texts -> Flags -> Msg -> Model -> UpdateResult +update texts flags msg model = + case msg of + CancelPublish -> + { model = model + , cmd = Cmd.none + , sub = Sub.none + , outcome = OutcomeDone + } + + FormMsg lm -> + let + ( fm, fc, fs ) = + Comp.ShareForm.update flags lm model.formModel + in + { model = { model | formModel = fm } + , cmd = Cmd.map FormMsg fc + , sub = Sub.map FormMsg fs + , outcome = OutcomeInProgress + } + + MailMsg lm -> + let + ( mm, mc ) = + Comp.ShareMail.update texts.shareMail flags lm model.mailModel + in + { model = { model | mailModel = mm } + , cmd = Cmd.map MailMsg mc + , sub = Sub.none + , outcome = OutcomeInProgress + } + + SubmitPublish -> + case Comp.ShareForm.getShare model.formModel of + Just ( _, data ) -> + { model = { model | loading = True } + , cmd = Api.addShare flags data PublishResp + , sub = Sub.none + , outcome = OutcomeInProgress + } + + Nothing -> + { model = { model | formError = FormErrorInvalid } + , cmd = Cmd.none + , sub = Sub.none + , outcome = OutcomeInProgress + } + + PublishResp (Ok res) -> + if res.success then + { model = model + , cmd = Api.getShare flags res.id GetShareResp + , sub = Sub.none + , outcome = OutcomeInProgress + } + + else + { model = { model | formError = FormErrorSubmit res.message, loading = False } + , cmd = Cmd.none + , sub = Sub.none + , outcome = OutcomeInProgress + } + + PublishResp (Err err) -> + { model = { model | formError = FormErrorHttp err, loading = False } + , cmd = Cmd.none + , sub = Sub.none + , outcome = OutcomeInProgress + } + + GetShareResp (Ok share) -> + let + ( mm, mc ) = + Comp.ShareMail.update texts.shareMail flags (Comp.ShareMail.setMailInfo share) model.mailModel + in + { model = + { model + | formError = FormErrorNone + , loading = False + , viewMode = ViewModeInfo share + , mailVisible = False + , mailModel = mm + } + , cmd = + Cmd.batch + [ Ports.initClipboard (Comp.ShareView.clipboardData share) + , Cmd.map MailMsg mc + ] + , sub = Sub.none + , outcome = OutcomeInProgress + } + + GetShareResp (Err err) -> + { model = { model | formError = FormErrorHttp err, loading = False } + , cmd = Cmd.none + , sub = Sub.none + , outcome = OutcomeInProgress + } + + ToggleMailVisible -> + { model = { model | mailVisible = not model.mailVisible } + , cmd = Cmd.none + , sub = Sub.none + , outcome = OutcomeInProgress + } + + + +--- View + + +view : Texts -> UiSettings -> Flags -> Model -> Html Msg +view texts settings flags model = + div [] + [ B.loadingDimmer + { active = model.loading + , label = "" + } + , case model.viewMode of + ViewModeEdit -> + viewForm texts model + + ViewModeInfo share -> + viewInfo texts settings flags model share + ] + + +viewInfo : Texts -> UiSettings -> Flags -> Model -> ShareDetail -> Html Msg +viewInfo texts settings flags model share = + let + cfg = + { mainClasses = "" + , showAccessData = False + } + in + div [ class "px-2 mb-4" ] + [ h1 [ class S.header1 ] + [ text texts.title + ] + , div + [ class S.infoMessage + ] + [ text texts.infoText + ] + , MB.view <| + { start = + [ MB.SecondaryButton + { tagger = CancelPublish + , title = texts.cancelPublishTitle + , icon = Just "fa fa-arrow-left" + , label = texts.doneLabel + } + ] + , end = [] + , rootClasses = "my-4" + } + , div [] + [ Comp.ShareView.view cfg texts.shareView flags share + ] + , div + [ class "flex flex-col mt-6" + ] + [ a + [ class S.header2 + , class "inline-block w-full" + , href "#" + , onClick ToggleMailVisible + ] + [ if model.mailVisible then + i [ class "fa fa-caret-down mr-2" ] [] + + else + i [ class "fa fa-caret-right mr-2" ] [] + , text texts.sendViaMail + ] + , div [ classList [ ( "hidden", not model.mailVisible ) ] ] + [ Html.map MailMsg + (Comp.ShareMail.view texts.shareMail flags settings model.mailModel) + ] + ] + ] + + +viewForm : Texts -> Model -> Html Msg +viewForm texts model = + div [ class "px-2 mb-4" ] + [ h1 [ class S.header1 ] + [ text texts.title + ] + , div + [ class S.infoMessage + ] + [ text texts.infoText + ] + , MB.view <| + { start = + [ MB.PrimaryButton + { tagger = SubmitPublish + , title = texts.submitPublishTitle + , icon = Just Icons.share + , label = texts.submitPublish + } + , MB.SecondaryButton + { tagger = CancelPublish + , title = texts.cancelPublishTitle + , icon = Just "fa fa-times" + , label = texts.cancelPublish + } + ] + , end = [] + , rootClasses = "my-4" + } + , div [] + [ Html.map FormMsg (Comp.ShareForm.view texts.shareForm model.formModel) + ] + , div + [ classList + [ ( "hidden", model.formError == FormErrorNone ) + ] + , class "my-2" + , class S.errorMessage + ] + [ case model.formError of + FormErrorNone -> + text "" + + FormErrorHttp err -> + text (texts.httpError err) + + FormErrorInvalid -> + text texts.correctFormErrors + + FormErrorSubmit m -> + text m + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index e29d52aa..019987d7 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -9,11 +9,14 @@ module Comp.SearchMenu exposing ( Model , Msg(..) , NextState + , SearchTab(..) , TextSearchModel , getItemQuery , init , isFulltextSearch , isNamesSearch + , linkTargetMsg + , setFromStats , textSearchString , update , updateDrop @@ -34,6 +37,7 @@ import Comp.CustomFieldMultiInput import Comp.DatePicker import Comp.Dropdown exposing (isDropdownChangeMsg) import Comp.FolderSelect +import Comp.LinkTarget exposing (LinkTarget) import Comp.MenuBar as MB import Comp.Tabs import Comp.TagSelect @@ -57,6 +61,7 @@ import Http import Messages.Comp.SearchMenu exposing (Texts) import Set exposing (Set) import Styles as S +import Util.CustomField import Util.Html exposing (KeyCode(..)) import Util.ItemDragDrop as DD import Util.Maybe @@ -377,6 +382,42 @@ type Msg | ToggleOpenAllAkkordionTabs +setFromStats : SearchStats -> Msg +setFromStats stats = + GetStatsResp (Ok stats) + + +linkTargetMsg : LinkTarget -> Maybe Msg +linkTargetMsg linkTarget = + case linkTarget of + Comp.LinkTarget.LinkNone -> + Nothing + + Comp.LinkTarget.LinkCorrOrg id -> + Just <| SetCorrOrg id + + Comp.LinkTarget.LinkCorrPerson id -> + Just <| SetCorrPerson id + + Comp.LinkTarget.LinkConcPerson id -> + Just <| SetConcPerson id + + Comp.LinkTarget.LinkConcEquip id -> + Just <| SetConcEquip id + + Comp.LinkTarget.LinkFolder id -> + Just <| SetFolder id + + Comp.LinkTarget.LinkTag id -> + Just <| SetTag id.id + + Comp.LinkTarget.LinkCustomField id -> + Just <| SetCustomField id + + Comp.LinkTarget.LinkSource str -> + Just <| ResetToSource str + + type alias NextState = { model : Model , cmd : Cmd Msg @@ -523,7 +564,43 @@ updateDrop ddm flags settings msg model = List.sortBy .count stats.tagCategoryCloud.items selectModel = - Comp.TagSelect.modifyCount model.tagSelectModel tagCount catCount + Comp.TagSelect.modifyCountKeepExisting model.tagSelectModel tagCount catCount + + orgOpts = + Comp.Dropdown.update (Comp.Dropdown.SetOptions (List.map .ref stats.corrOrgStats)) + model.orgModel + |> Tuple.first + + corrPersOpts = + Comp.Dropdown.update (Comp.Dropdown.SetOptions (List.map .ref stats.corrPersStats)) + model.corrPersonModel + |> Tuple.first + + concPersOpts = + Comp.Dropdown.update (Comp.Dropdown.SetOptions (List.map .ref stats.concPersStats)) + model.concPersonModel + |> Tuple.first + + concEquipOpts = + let + mkEquip ref = + Equipment ref.id ref.name 0 Nothing "" + in + Comp.Dropdown.update + (Comp.Dropdown.SetOptions + (List.map (.ref >> mkEquip) stats.concEquipStats) + ) + model.concEquipmentModel + |> Tuple.first + + fields = + Util.CustomField.statsToFields stats + + fieldOpts = + Comp.CustomFieldMultiInput.update flags + (Comp.CustomFieldMultiInput.setOptions fields) + model.customFieldModel + |> .model model_ = { model @@ -532,6 +609,11 @@ updateDrop ddm flags settings msg model = Comp.FolderSelect.modify model.selectedFolder model.folderList stats.folderStats + , orgModel = orgOpts + , corrPersonModel = corrPersOpts + , concPersonModel = concPersOpts + , concEquipmentModel = concEquipOpts + , customFieldModel = fieldOpts } in { model = model_ @@ -963,15 +1045,20 @@ updateDrop ddm flags settings msg model = --- View2 -viewDrop2 : Texts -> DD.DragDropData -> Flags -> UiSettings -> Model -> Html Msg -viewDrop2 texts ddd flags settings model = +type alias ViewConfig = + { overrideTabLook : SearchTab -> Comp.Tabs.Look -> Comp.Tabs.Look + } + + +viewDrop2 : Texts -> DD.DragDropData -> Flags -> ViewConfig -> UiSettings -> Model -> Html Msg +viewDrop2 texts ddd flags cfg settings model = let akkordionStyle = Comp.Tabs.searchMenuStyle in Comp.Tabs.akkordion akkordionStyle - (searchTabState settings model) + (searchTabState settings cfg model) (searchTabs texts ddd flags settings model) @@ -1173,12 +1260,9 @@ tabLook settings model tab = Comp.Tabs.Normal -searchTabState : UiSettings -> Model -> Comp.Tabs.Tab Msg -> ( Comp.Tabs.State, Msg ) -searchTabState settings model tab = +searchTabState : UiSettings -> ViewConfig -> Model -> Comp.Tabs.Tab Msg -> ( Comp.Tabs.State, Msg ) +searchTabState settings cfg model tab = let - isHidden f = - Data.UiSettings.fieldHidden settings f - searchTab = findTab tab @@ -1192,7 +1276,7 @@ searchTabState settings model tab = state = { folded = folded , look = - Maybe.map (tabLook settings model) searchTab + Maybe.map (\t -> tabLook settings model t |> cfg.overrideTabLook t) searchTab |> Maybe.withDefault Comp.Tabs.Normal } in diff --git a/modules/webapp/src/main/elm/Comp/ShareForm.elm b/modules/webapp/src/main/elm/Comp/ShareForm.elm new file mode 100644 index 00000000..ee08a7d8 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareForm.elm @@ -0,0 +1,332 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareForm exposing (Model, Msg, getShare, init, initQuery, setShare, update, view) + +import Api.Model.ShareData exposing (ShareData) +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Basic as B +import Comp.DatePicker +import Comp.PasswordInput +import Comp.PowerSearchInput +import Data.Flags exposing (Flags) +import DatePicker exposing (DatePicker) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onCheck, onInput) +import Messages.Comp.ShareForm exposing (Texts) +import Styles as S +import Util.Maybe + + +type alias Model = + { share : ShareDetail + , name : Maybe String + , queryModel : Comp.PowerSearchInput.Model + , enabled : Bool + , passwordModel : Comp.PasswordInput.Model + , password : Maybe String + , passwordSet : Bool + , clearPassword : Bool + , untilModel : DatePicker + , untilDate : Maybe Int + } + + +initQuery : String -> ( Model, Cmd Msg ) +initQuery q = + let + ( dp, dpc ) = + Comp.DatePicker.init + + res = + Comp.PowerSearchInput.update + (Comp.PowerSearchInput.setSearchString q) + Comp.PowerSearchInput.init + in + ( { share = Api.Model.ShareDetail.empty + , name = Nothing + , queryModel = res.model + , enabled = True + , passwordModel = Comp.PasswordInput.init + , password = Nothing + , passwordSet = False + , clearPassword = False + , untilModel = dp + , untilDate = Nothing + } + , Cmd.batch + [ Cmd.map UntilDateMsg dpc + , Cmd.map QueryMsg res.cmd + ] + ) + + +init : ( Model, Cmd Msg ) +init = + initQuery "" + + +isValid : Model -> Bool +isValid model = + Comp.PowerSearchInput.isValid model.queryModel + && model.untilDate + /= Nothing + + +type Msg + = SetName String + | SetShare ShareDetail + | ToggleEnabled + | ToggleClearPassword + | PasswordMsg Comp.PasswordInput.Msg + | UntilDateMsg Comp.DatePicker.Msg + | QueryMsg Comp.PowerSearchInput.Msg + + +setShare : ShareDetail -> Msg +setShare share = + SetShare share + + +getShare : Model -> Maybe ( String, ShareData ) +getShare model = + if isValid model then + Just + ( model.share.id + , { name = model.name + , query = + model.queryModel.input + |> Maybe.withDefault "" + , enabled = model.enabled + , password = model.password + , removePassword = + if model.share.id == "" then + Nothing + + else + Just model.clearPassword + , publishUntil = Maybe.withDefault 0 model.untilDate + } + ) + + else + Nothing + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update _ msg model = + case msg of + SetShare s -> + let + res = + Comp.PowerSearchInput.update + (Comp.PowerSearchInput.setSearchString s.query) + model.queryModel + in + ( { model + | share = s + , name = s.name + , queryModel = res.model + , enabled = s.enabled + , password = Nothing + , passwordSet = s.password + , clearPassword = False + , untilDate = + if s.publishUntil > 0 then + Just s.publishUntil + + else + Nothing + } + , Cmd.map QueryMsg res.cmd + , Sub.map QueryMsg res.subs + ) + + SetName n -> + ( { model | name = Util.Maybe.fromString n }, Cmd.none, Sub.none ) + + ToggleEnabled -> + ( { model | enabled = not model.enabled }, Cmd.none, Sub.none ) + + ToggleClearPassword -> + ( { model | clearPassword = not model.clearPassword }, Cmd.none, Sub.none ) + + PasswordMsg lm -> + let + ( pm, pw ) = + Comp.PasswordInput.update lm model.passwordModel + in + ( { model + | passwordModel = pm + , password = pw + } + , Cmd.none + , Sub.none + ) + + UntilDateMsg lm -> + let + ( dp, event ) = + Comp.DatePicker.updateDefault lm model.untilModel + + nextDate = + case event of + DatePicker.Picked date -> + Just (Comp.DatePicker.endOfDay date) + + _ -> + Nothing + in + ( { model | untilModel = dp, untilDate = nextDate } + , Cmd.none + , Sub.none + ) + + QueryMsg lm -> + let + res = + Comp.PowerSearchInput.update lm model.queryModel + in + ( { model | queryModel = res.model } + , Cmd.map QueryMsg res.cmd + , Sub.map QueryMsg res.subs + ) + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + let + queryInput = + div + [ class "relative flex flex-grow flex-row" ] + [ Html.map QueryMsg + (Comp.PowerSearchInput.viewInput + { placeholder = texts.queryLabel + , extraAttrs = [] + } + model.queryModel + ) + , Html.map QueryMsg + (Comp.PowerSearchInput.viewResult [] model.queryModel) + ] + in + div + [ class "flex flex-col" ] + [ div [ class "mb-4" ] + [ label + [ for "sharename" + , class S.inputLabel + ] + [ text texts.basics.name + ] + , input + [ type_ "text" + , onInput SetName + , placeholder texts.basics.name + , value <| Maybe.withDefault "" model.name + , id "sharename" + , class S.textInput + ] + [] + ] + , div [ class "mb-4" ] + [ label + [ for "sharequery" + , class S.inputLabel + ] + [ text texts.queryLabel + , B.inputRequired + ] + , queryInput + ] + , div [ class "mb-4" ] + [ label + [ class "inline-flex items-center" + , for "source-enabled" + ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleEnabled) + , checked model.enabled + , class S.checkboxInput + , id "source-enabled" + ] + [] + , span [ class "ml-2" ] + [ text texts.enabled + ] + ] + ] + , div [ class "mb-4" ] + [ label + [ class S.inputLabel + ] + [ text texts.password + ] + , Html.map PasswordMsg + (Comp.PasswordInput.view2 + { placeholder = texts.password } + model.password + False + model.passwordModel + ) + , div + [ class "mb-2" + , classList [ ( "hidden", not model.passwordSet ) ] + ] + [ label + [ class "inline-flex items-center" + , for "clear-password" + ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleClearPassword) + , checked model.clearPassword + , class S.checkboxInput + , id "clear-password" + ] + [] + , span [ class "ml-2" ] + [ text texts.clearPassword + ] + ] + ] + ] + , div + [ class "mb-2 max-w-sm" + ] + [ label [ class S.inputLabel ] + [ text texts.publishUntil + , B.inputRequired + ] + , div + [ class "relative" + ] + [ Html.map UntilDateMsg + (Comp.DatePicker.viewTimeDefault + model.untilDate + model.untilModel + ) + , i [ class S.dateInputIcon, class "fa fa-calendar" ] [] + ] + , div + [ classList + [ ( "hidden" + , model.untilDate /= Nothing + ) + ] + , class "mt-1" + , class S.errorText + ] + [ text "This field is required." ] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ShareMail.elm b/modules/webapp/src/main/elm/Comp/ShareMail.elm new file mode 100644 index 00000000..79701eb3 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareMail.elm @@ -0,0 +1,191 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareMail exposing (Model, Msg, init, setMailInfo, update, view) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Basic as B +import Comp.ItemMail +import Data.Flags exposing (Flags) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Http +import Messages.Comp.ShareMail exposing (Texts) +import Page exposing (Page(..)) +import Styles as S + + +type FormState + = FormStateNone + | FormStateSubmit String + | FormStateHttp Http.Error + | FormStateSent + + +type alias Model = + { mailModel : Comp.ItemMail.Model + , share : ShareDetail + , sending : Bool + , formState : FormState + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + let + ( mm, mc ) = + Comp.ItemMail.init flags + in + ( { mailModel = mm + , share = Api.Model.ShareDetail.empty + , sending = False + , formState = FormStateNone + } + , Cmd.map MailMsg mc + ) + + +type Msg + = MailMsg Comp.ItemMail.Msg + | SetMailInfo ShareDetail + | SendMailResp (Result Http.Error BasicResult) + + + +--- Update + + +setMailInfo : ShareDetail -> Msg +setMailInfo share = + SetMailInfo share + + +update : Texts -> Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update texts flags msg model = + case msg of + MailMsg lm -> + let + ( mm, mc, fa ) = + Comp.ItemMail.update flags lm model.mailModel + + defaultResult = + ( { model | mailModel = mm }, Cmd.map MailMsg mc ) + in + case fa of + Comp.ItemMail.FormSend sm -> + let + mail = + { mail = + { shareId = model.share.id + , recipients = sm.mail.recipients + , cc = sm.mail.cc + , bcc = sm.mail.bcc + , subject = sm.mail.subject + , body = sm.mail.body + } + , conn = sm.conn + } + in + ( { model | sending = True, mailModel = mm } + , Cmd.batch + [ Cmd.map MailMsg mc + , Api.shareSendMail flags mail SendMailResp + ] + ) + + Comp.ItemMail.FormNone -> + defaultResult + + Comp.ItemMail.FormCancel -> + defaultResult + + SetMailInfo share -> + let + url = + flags.config.baseUrl ++ Page.pageToString (SharePage share.id) + + name = + share.name + + lm = + Comp.ItemMail.setMailInfo + (texts.subjectTemplate name) + (texts.bodyTemplate url) + + nm = + { model + | share = share + , mailModel = Comp.ItemMail.clearRecipients model.mailModel + , formState = FormStateNone + } + in + update texts flags (MailMsg lm) nm + + SendMailResp (Ok res) -> + if res.success then + ( { model + | formState = FormStateSent + , mailModel = Comp.ItemMail.clearRecipients model.mailModel + , sending = False + } + , Cmd.none + ) + + else + ( { model + | formState = FormStateSubmit res.message + , sending = False + } + , Cmd.none + ) + + SendMailResp (Err err) -> + ( { model | formState = FormStateHttp err }, Cmd.none ) + + + +--- View + + +view : Texts -> Flags -> UiSettings -> Model -> Html Msg +view texts flags settings model = + let + cfg = + { withAttachments = False + , textAreaClass = "h-52" + , showCancel = False + } + in + div [ class "relative" ] + [ case model.formState of + FormStateNone -> + span [ class "hidden" ] [] + + FormStateSubmit msg -> + div [ class S.errorMessage ] + [ text msg + ] + + FormStateHttp err -> + div [ class S.errorMessage ] + [ text (texts.httpError err) + ] + + FormStateSent -> + div [ class S.successMessage ] + [ text texts.mailSent + ] + , Html.map MailMsg + (Comp.ItemMail.view texts.itemMail settings cfg model.mailModel) + , B.loadingDimmer + { active = model.sending + , label = "" + } + ] diff --git a/modules/webapp/src/main/elm/Comp/ShareManage.elm b/modules/webapp/src/main/elm/Comp/ShareManage.elm new file mode 100644 index 00000000..19bdce86 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareManage.elm @@ -0,0 +1,519 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareManage exposing (Model, Msg, init, loadShares, update, view) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.IdResult exposing (IdResult) +import Api.Model.ShareDetail exposing (ShareDetail) +import Api.Model.ShareList exposing (ShareList) +import Comp.Basic as B +import Comp.ItemDetail.Model exposing (Msg(..)) +import Comp.MenuBar as MB +import Comp.ShareForm +import Comp.ShareMail +import Comp.ShareTable +import Comp.ShareView +import Data.Flags exposing (Flags) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.ShareManage exposing (Texts) +import Page exposing (Page(..)) +import Ports +import Styles as S + + +type FormError + = FormErrorNone + | FormErrorHttp Http.Error + | FormErrorInvalid + | FormErrorSubmit String + + +type ViewMode + = Table + | Form + + +type DeleteConfirm + = DeleteConfirmOff + | DeleteConfirmOn + + +type alias Model = + { viewMode : ViewMode + , shares : List ShareDetail + , formModel : Comp.ShareForm.Model + , mailModel : Comp.ShareMail.Model + , loading : Bool + , formError : FormError + , deleteConfirm : DeleteConfirm + , query : String + , owningOnly : Bool + , sendMailVisible : Bool + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + let + ( fm, fc ) = + Comp.ShareForm.init + + ( mm, mc ) = + Comp.ShareMail.init flags + in + ( { viewMode = Table + , shares = [] + , formModel = fm + , mailModel = mm + , loading = False + , formError = FormErrorNone + , deleteConfirm = DeleteConfirmOff + , query = "" + , owningOnly = True + , sendMailVisible = False + } + , Cmd.batch + [ Cmd.map FormMsg fc + , Cmd.map MailMsg mc + ] + ) + + +type Msg + = LoadShares + | TableMsg Comp.ShareTable.Msg + | FormMsg Comp.ShareForm.Msg + | MailMsg Comp.ShareMail.Msg + | InitNewShare + | SetViewMode ViewMode + | SetQuery String + | ToggleOwningOnly + | ToggleSendMailVisible + | Submit + | RequestDelete + | CancelDelete + | DeleteShareNow String + | LoadSharesResp (Result Http.Error ShareList) + | AddShareResp (Result Http.Error IdResult) + | UpdateShareResp (Result Http.Error BasicResult) + | GetShareResp (Result Http.Error ShareDetail) + | DeleteShareResp (Result Http.Error BasicResult) + + +loadShares : Msg +loadShares = + LoadShares + + + +--- update + + +update : Texts -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update texts flags msg model = + case msg of + InitNewShare -> + let + nm = + { model | viewMode = Form, formError = FormErrorNone } + + share = + Api.Model.ShareDetail.empty + in + update texts flags (FormMsg (Comp.ShareForm.setShare { share | enabled = True })) nm + + SetViewMode vm -> + ( { model | viewMode = vm, formError = FormErrorNone } + , if vm == Table then + Api.getShares flags model.query model.owningOnly LoadSharesResp + + else + Cmd.none + , Sub.none + ) + + FormMsg lm -> + let + ( fm, fc, fs ) = + Comp.ShareForm.update flags lm model.formModel + in + ( { model | formModel = fm, formError = FormErrorNone } + , Cmd.map FormMsg fc + , Sub.map FormMsg fs + ) + + TableMsg lm -> + let + action = + Comp.ShareTable.update lm + in + case action of + Comp.ShareTable.Edit share -> + setShare texts share flags model + + RequestDelete -> + ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none, Sub.none ) + + CancelDelete -> + ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none, Sub.none ) + + DeleteShareNow id -> + ( { model | deleteConfirm = DeleteConfirmOff, loading = True } + , Api.deleteShare flags id DeleteShareResp + , Sub.none + ) + + LoadShares -> + ( { model | loading = True } + , Api.getShares flags model.query model.owningOnly LoadSharesResp + , Sub.none + ) + + LoadSharesResp (Ok list) -> + ( { model | loading = False, shares = list.items, formError = FormErrorNone } + , Cmd.none + , Sub.none + ) + + LoadSharesResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + Submit -> + case Comp.ShareForm.getShare model.formModel of + Just ( id, data ) -> + if id == "" then + ( { model | loading = True }, Api.addShare flags data AddShareResp, Sub.none ) + + else + ( { model | loading = True }, Api.updateShare flags id data UpdateShareResp, Sub.none ) + + Nothing -> + ( { model | formError = FormErrorInvalid }, Cmd.none, Sub.none ) + + AddShareResp (Ok res) -> + if res.success then + ( model, Api.getShare flags res.id GetShareResp, Sub.none ) + + else + ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none ) + + AddShareResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + UpdateShareResp (Ok res) -> + if res.success then + ( model, Api.getShare flags model.formModel.share.id GetShareResp, Sub.none ) + + else + ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none ) + + UpdateShareResp (Err err) -> + ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + GetShareResp (Ok share) -> + setShare texts share flags model + + GetShareResp (Err err) -> + ( { model | formError = FormErrorHttp err }, Cmd.none, Sub.none ) + + DeleteShareResp (Ok res) -> + if res.success then + update texts flags (SetViewMode Table) { model | loading = False } + + else + ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none, Sub.none ) + + DeleteShareResp (Err err) -> + ( { model | formError = FormErrorHttp err, loading = False }, Cmd.none, Sub.none ) + + MailMsg lm -> + let + ( mm, mc ) = + Comp.ShareMail.update texts.shareMail flags lm model.mailModel + in + ( { model | mailModel = mm }, Cmd.map MailMsg mc, Sub.none ) + + SetQuery q -> + let + nm = + { model | query = q } + in + ( nm + , Api.getShares flags nm.query nm.owningOnly LoadSharesResp + , Sub.none + ) + + ToggleOwningOnly -> + let + nm = + { model | owningOnly = not model.owningOnly } + in + ( nm + , Api.getShares flags nm.query nm.owningOnly LoadSharesResp + , Sub.none + ) + + ToggleSendMailVisible -> + ( { model | sendMailVisible = not model.sendMailVisible }, Cmd.none, Sub.none ) + + +setShare : Texts -> ShareDetail -> Flags -> Model -> ( Model, Cmd Msg, Sub Msg ) +setShare texts share flags model = + let + shareUrl = + flags.config.baseUrl ++ Page.pageToString (SharePage share.id) + + nextModel = + { model | formError = FormErrorNone, viewMode = Form, loading = False, sendMailVisible = False } + + initClipboard = + Ports.initClipboard (Comp.ShareView.clipboardData share) + + ( nm, nc, ns ) = + update texts flags (FormMsg <| Comp.ShareForm.setShare share) nextModel + + ( nm2, nc2, ns2 ) = + update texts flags (MailMsg <| Comp.ShareMail.setMailInfo share) nm + in + ( nm2, Cmd.batch [ initClipboard, nc, nc2 ], Sub.batch [ ns, ns2 ] ) + + + +--- view + + +view : Texts -> UiSettings -> Flags -> Model -> Html Msg +view texts settings flags model = + if model.viewMode == Table then + viewTable texts model + + else + viewForm texts settings flags model + + +viewTable : Texts -> Model -> Html Msg +viewTable texts model = + div [ class "flex flex-col" ] + [ MB.view + { start = + [ MB.TextInput + { tagger = SetQuery + , value = model.query + , placeholder = texts.basics.searchPlaceholder + , icon = Just "fa fa-search" + } + , MB.Checkbox + { tagger = \_ -> ToggleOwningOnly + , label = texts.showOwningSharesOnly + , value = model.owningOnly + , id = "share-toggle-owner" + } + ] + , end = + [ MB.PrimaryButton + { tagger = InitNewShare + , title = texts.createNewShare + , icon = Just "fa fa-plus" + , label = texts.newShare + } + ] + , rootClasses = "mb-4" + } + , Html.map TableMsg (Comp.ShareTable.view texts.shareTable model.shares) + , B.loadingDimmer + { label = "" + , active = model.loading + } + ] + + +viewForm : Texts -> UiSettings -> Flags -> Model -> Html Msg +viewForm texts settings flags model = + let + newShare = + model.formModel.share.id == "" + + isOwner = + Maybe.map .user flags.account + |> Maybe.map ((==) model.formModel.share.owner.name) + |> Maybe.withDefault False + in + div [] + [ Html.form [] + [ if newShare then + h1 [ class S.header2 ] + [ text texts.createNewShare + ] + + else + h1 [ class S.header2 ] + [ div [ class "flex flex-row items-center" ] + [ div + [ class "flex text-sm opacity-75 label mr-3" + , classList [ ( "hidden", isOwner ) ] + ] + [ i [ class "fa fa-user mr-2" ] [] + , text model.formModel.share.owner.name + ] + , text <| Maybe.withDefault texts.noName model.formModel.share.name + ] + , div [ class "flex flex-row items-center" ] + [ div [ class "opacity-50 text-sm flex-grow" ] + [ text "Id: " + , text model.formModel.share.id + ] + ] + ] + , MB.view + { start = + [ MB.CustomElement <| + B.primaryButton + { handler = onClick Submit + , title = "Submit this form" + , icon = "fa fa-save" + , label = texts.basics.submit + , disabled = not isOwner + , attrs = [ href "#" ] + } + , MB.SecondaryButton + { tagger = SetViewMode Table + , title = texts.basics.backToList + , icon = Just "fa fa-arrow-left" + , label = texts.basics.cancel + } + ] + , end = + if not newShare then + [ MB.DeleteButton + { tagger = RequestDelete + , title = texts.deleteThisShare + , icon = Just "fa fa-trash" + , label = texts.basics.delete + } + ] + + else + [] + , rootClasses = "mb-4" + } + , div + [ classList + [ ( "hidden", model.formError == FormErrorNone ) + ] + , class "my-2" + , class S.errorMessage + ] + [ case model.formError of + FormErrorNone -> + text "" + + FormErrorHttp err -> + text (texts.httpError err) + + FormErrorInvalid -> + text texts.correctFormErrors + + FormErrorSubmit m -> + text m + ] + , div + [ classList [ ( "hidden", isOwner ) ] + , class S.infoMessage + ] + [ text texts.notOwnerInfo + ] + , div [ classList [ ( "hidden", not isOwner ) ] ] + [ Html.map FormMsg (Comp.ShareForm.view texts.shareForm model.formModel) + ] + , B.loadingDimmer + { active = model.loading + , label = texts.basics.loading + } + , B.contentDimmer + (model.deleteConfirm == DeleteConfirmOn) + (div [ class "flex flex-col" ] + [ div [ class "text-lg" ] + [ i [ class "fa fa-info-circle mr-2" ] [] + , text texts.reallyDeleteShare + ] + , div [ class "mt-4 flex flex-row items-center" ] + [ B.deleteButton + { label = texts.basics.yes + , icon = "fa fa-check" + , disabled = False + , handler = onClick (DeleteShareNow model.formModel.share.id) + , attrs = [ href "#" ] + } + , B.secondaryButton + { label = texts.basics.no + , icon = "fa fa-times" + , disabled = False + , handler = onClick CancelDelete + , attrs = [ href "#", class "ml-2" ] + } + ] + ] + ) + ] + , shareInfo texts flags model.formModel.share + , shareSendMail texts flags settings model + ] + + +shareInfo : Texts -> Flags -> ShareDetail -> Html Msg +shareInfo texts flags share = + div + [ class "mt-6" + , classList [ ( "hidden", share.id == "" ) ] + ] + [ h2 + [ class S.header2 + , class "border-b-2 dark:border-bluegray-600" + ] + [ text texts.shareInformation + ] + , Comp.ShareView.viewDefault texts.shareView flags share + ] + + +shareSendMail : Texts -> Flags -> UiSettings -> Model -> Html Msg +shareSendMail texts flags settings model = + let + share = + model.formModel.share + in + div + [ class "mt-8 mb-2" + , classList [ ( "hidden", share.id == "" || not share.enabled || share.expired ) ] + ] + [ a + [ class S.header2 + , class "border-b-2 dark:border-bluegray-600 w-full inline-block" + , href "#" + , onClick ToggleSendMailVisible + ] + [ if model.sendMailVisible then + i [ class "fa fa-caret-down mr-2" ] [] + + else + i [ class "fa fa-caret-right mr-2" ] [] + , text texts.sendViaMail + ] + , div + [ class "px-2 py-2 dark:border-bluegray-600" + , classList [ ( "hidden", not model.sendMailVisible ) ] + ] + [ Html.map MailMsg + (Comp.ShareMail.view texts.shareMail flags settings model.mailModel) + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/SharePasswordForm.elm b/modules/webapp/src/main/elm/Comp/SharePasswordForm.elm new file mode 100644 index 00000000..d280c732 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/SharePasswordForm.elm @@ -0,0 +1,163 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.SharePasswordForm exposing (Model, Msg, init, update, view) + +import Api +import Api.Model.ShareVerifyResult exposing (ShareVerifyResult) +import Api.Model.VersionInfo exposing (VersionInfo) +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput, onSubmit) +import Http +import Messages.Comp.SharePasswordForm exposing (Texts) +import Styles as S + + +type CompError + = CompErrorNone + | CompErrorPasswordFailed + | CompErrorHttp Http.Error + + +type alias Model = + { password : String + , compError : CompError + } + + +init : Model +init = + { password = "" + , compError = CompErrorNone + } + + +type Msg + = SetPassword String + | SubmitPassword + | VerifyResp (Result Http.Error ShareVerifyResult) + + + +--- update + + +update : String -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe ShareVerifyResult ) +update shareId flags msg model = + case msg of + SetPassword pw -> + ( { model | password = pw }, Cmd.none, Nothing ) + + SubmitPassword -> + let + secret = + { shareId = shareId + , password = Just model.password + } + in + ( model, Api.verifyShare flags secret VerifyResp, Nothing ) + + VerifyResp (Ok res) -> + if res.success then + ( { model | password = "", compError = CompErrorNone }, Cmd.none, Just res ) + + else + ( { model | password = "", compError = CompErrorPasswordFailed }, Cmd.none, Nothing ) + + VerifyResp (Err err) -> + ( { model | password = "", compError = CompErrorHttp err }, Cmd.none, Nothing ) + + + +--- view + + +view : Texts -> Flags -> VersionInfo -> Model -> Html Msg +view texts flags versionInfo model = + div [ class "flex flex-col items-center" ] + [ div [ class ("flex flex-col px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md " ++ S.box) ] + [ div [ class "self-center" ] + [ img + [ class "w-16 py-2" + , src (flags.config.docspellAssetPath ++ "/img/logo-96.png") + ] + [] + ] + , div [ class "font-medium self-center text-xl sm:text-2xl" ] + [ text texts.passwordRequired + ] + , Html.form + [ action "#" + , onSubmit SubmitPassword + , autocomplete False + ] + [ div [ class "flex flex-col my-3" ] + [ label + [ for "password" + , class S.inputLabel + ] + [ text texts.password + ] + , div [ class "relative" ] + [ div [ class S.inputIcon ] + [ i [ class "fa fa-lock" ] [] + ] + , input + [ type_ "password" + , name "password" + , autocomplete False + , autofocus True + , tabindex 1 + , onInput SetPassword + , value model.password + , class ("pl-10 pr-4 py-2 rounded-lg" ++ S.textInput) + , placeholder texts.password + ] + [] + ] + ] + , div [ class "flex flex-col my-3" ] + [ button + [ type_ "submit" + , class S.primaryButton + ] + [ text texts.passwordSubmitButton + ] + ] + , case model.compError of + CompErrorNone -> + span [ class "hidden" ] [] + + CompErrorHttp err -> + div [ class S.errorMessage ] + [ text (texts.httpError err) + ] + + CompErrorPasswordFailed -> + div [ class S.errorMessage ] + [ text texts.passwordFailed + ] + ] + ] + , a + [ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90" + , href "https://docspell.org" + , target "_new" + ] + [ img + [ src (flags.config.docspellAssetPath ++ "/img/logo-mc-96.png") + , class "w-3 h-3 mr-1" + ] + [] + , span [] + [ text "Docspell " + , text versionInfo.version + ] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ShareTable.elm b/modules/webapp/src/main/elm/Comp/ShareTable.elm new file mode 100644 index 00000000..1082567d --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareTable.elm @@ -0,0 +1,100 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareTable exposing + ( Msg(..) + , SelectAction(..) + , update + , view + ) + +import Api.Model.ShareDetail exposing (ShareDetail) +import Comp.Basic as B +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Comp.ShareTable exposing (Texts) +import Styles as S +import Util.Html +import Util.String + + +type Msg + = Select ShareDetail + + +type SelectAction + = Edit ShareDetail + + +update : Msg -> SelectAction +update msg = + case msg of + Select share -> + Edit share + + + +--- View + + +view : Texts -> List ShareDetail -> Html Msg +view texts shares = + table [ class S.tableMain ] + [ thead [] + [ tr [] + [ th [ class "" ] [] + , th [ class "text-left" ] + [ text texts.basics.id + ] + , th [ class "text-left" ] + [ text texts.basics.name + ] + , th [ class "text-center" ] + [ text texts.active + ] + , th [ class "hidden sm:table-cell text-center" ] + [ text texts.user + ] + , th [ class "hidden sm:table-cell text-center" ] + [ text texts.publishUntil + ] + ] + ] + , tbody [] + (List.map (renderShareLine texts) shares) + ] + + +renderShareLine : Texts -> ShareDetail -> Html Msg +renderShareLine texts share = + tr + [ class S.tableRow + ] + [ B.editLinkTableCell texts.basics.edit (Select share) + , td [ class "text-left py-4 md:py-2" ] + [ text (Util.String.ellipsis 8 share.id) + ] + , td [ class "text-left py-4 md:py-2" ] + [ text (Maybe.withDefault "-" share.name) + ] + , td [ class "w-px px-2 text-center" ] + [ if not share.enabled then + i [ class "fa fa-ban" ] [] + + else if share.expired then + i [ class "fa fa-bolt text-red-600 dark:text-orange-800" ] [] + + else + i [ class "fa fa-check" ] [] + ] + , td [ class "hidden sm:table-cell text-center" ] + [ text share.owner.name + ] + , td [ class "hidden sm:table-cell text-center" ] + [ texts.formatDateTime share.publishUntil |> text + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ShareView.elm b/modules/webapp/src/main/elm/Comp/ShareView.elm new file mode 100644 index 00000000..dbb33752 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ShareView.elm @@ -0,0 +1,185 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ShareView exposing (ViewSettings, clipboardData, view, viewDefault) + +import Api.Model.ShareDetail exposing (ShareDetail) +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Comp.ShareView exposing (Texts) +import QRCode +import Styles as S +import Svg.Attributes as SvgA + + +type alias ViewSettings = + { mainClasses : String + , showAccessData : Bool + } + + +view : ViewSettings -> Texts -> Flags -> ShareDetail -> Html msg +view cfg texts flags share = + if not share.enabled then + viewDisabled cfg texts share + + else if share.expired then + viewExpired cfg texts share + + else + viewActive cfg texts flags share + + +viewDefault : Texts -> Flags -> ShareDetail -> Html msg +viewDefault = + view + { mainClasses = "" + , showAccessData = True + } + + +clipboardData : ShareDetail -> ( String, String ) +clipboardData share = + ( "app-share-" ++ share.id, "#app-share-url-copy-to-clipboard-btn-" ++ share.id ) + + + +--- Helper + + +viewActive : ViewSettings -> Texts -> Flags -> ShareDetail -> Html msg +viewActive cfg texts flags share = + let + clipboard = + clipboardData share + + appUrl = + flags.config.baseUrl ++ "/app/share/" ++ share.id + + styleUrl = + "truncate px-2 py-2 border-0 border-t border-b border-r font-mono text-sm my-auto rounded-r border-gray-400 dark:border-bluegray-500" + + infoLine hidden icon label value = + div + [ class "flex flex-row items-center" + , classList [ ( "hidden", hidden ) ] + ] + [ div [ class "flex mr-3" ] + [ i [ class icon ] [] + ] + , div [ class "flex flex-col" ] + [ div [ class "-mb-1" ] + [ text value + ] + , div [ class "opacity-50 text-sm" ] + [ text label + ] + ] + ] + in + div + [ class cfg.mainClasses + , class "flex flex-col sm:flex-row " + ] + [ div [ class "flex" ] + [ div + [ class S.border + , class S.qrCode + ] + [ qrCodeView texts appUrl + ] + ] + , div + [ class "flex flex-col ml-3 pr-2" + + -- hack for the qr code that is 265px + , style "max-width" "calc(100% - 265px)" + ] + [ div [ class "font-medium text-2xl" ] + [ text <| Maybe.withDefault texts.noName share.name + ] + , div [ class "my-2" ] + [ div [ class "flex flex-row" ] + [ a + [ class S.secondaryBasicButtonPlain + , class "rounded-l border text-sm px-4 py-2" + , title texts.copyToClipboard + , href "#" + , Tuple.second clipboard + |> String.dropLeft 1 + |> id + , attribute "data-clipboard-target" ("#" ++ Tuple.first clipboard) + ] + [ i [ class "fa fa-copy" ] [] + ] + , a + [ class S.secondaryBasicButtonPlain + , class "px-4 py-2 border-0 border-t border-b border-r text-sm" + , href appUrl + , target "_blank" + , title texts.openInNewTab + ] + [ i [ class "fa fa-external-link-alt" ] [] + ] + , div + [ id (Tuple.first clipboard) + , class styleUrl + ] + [ text appUrl + ] + ] + ] + , div [ class "text-lg flex flex-col" ] + [ infoLine False "fa fa-calendar" texts.publishUntil (texts.date share.publishUntil) + , infoLine False + (if share.password then + "fa fa-lock" + + else + "fa fa-lock-open" + ) + texts.passwordProtected + (if share.password then + texts.basics.yes + + else + texts.basics.no + ) + , infoLine + (not cfg.showAccessData) + "fa fa-eye" + texts.views + (String.fromInt share.views) + , infoLine + (not cfg.showAccessData) + "fa fa-calendar-check font-thin" + texts.lastAccess + (Maybe.map texts.date share.lastAccess |> Maybe.withDefault "-") + ] + ] + ] + + +viewExpired : ViewSettings -> Texts -> ShareDetail -> Html msg +viewExpired cfg texts share = + div [ class S.warnMessage ] + [ text texts.expiredInfo ] + + +viewDisabled : ViewSettings -> Texts -> ShareDetail -> Html msg +viewDisabled cfg texts share = + div [ class S.warnMessage ] + [ text texts.disabledInfo ] + + +qrCodeView : Texts -> String -> Html msg +qrCodeView texts message = + QRCode.fromString message + |> Result.map (QRCode.toSvg [ SvgA.class "w-64 h-64" ]) + |> Result.withDefault + (Html.text texts.qrCodeError) diff --git a/modules/webapp/src/main/elm/Comp/SourceManage.elm b/modules/webapp/src/main/elm/Comp/SourceManage.elm index 5ce42136..629ac18e 100644 --- a/modules/webapp/src/main/elm/Comp/SourceManage.elm +++ b/modules/webapp/src/main/elm/Comp/SourceManage.elm @@ -32,6 +32,7 @@ import Messages.Comp.SourceManage exposing (Texts) import Ports import QRCode import Styles as S +import Svg.Attributes as SvgA type alias Model = @@ -226,8 +227,8 @@ update flags msg model = qrCodeView : Texts -> String -> Html msg qrCodeView texts message = - QRCode.encode message - |> Result.map QRCode.toSvg + QRCode.fromString message + |> Result.map (QRCode.toSvg [ SvgA.class "w-64 h-64" ]) |> Result.withDefault (Html.text texts.errorGeneratingQR) diff --git a/modules/webapp/src/main/elm/Comp/TagSelect.elm b/modules/webapp/src/main/elm/Comp/TagSelect.elm index 62f2a65d..07d76380 100644 --- a/modules/webapp/src/main/elm/Comp/TagSelect.elm +++ b/modules/webapp/src/main/elm/Comp/TagSelect.elm @@ -16,6 +16,7 @@ module Comp.TagSelect exposing , makeWorkModel , modifyAll , modifyCount + , modifyCountKeepExisting , reset , toggleTag , update @@ -99,6 +100,40 @@ modifyCount model tags cats = } +modifyCountKeepExisting : Model -> List TagCount -> List NameCount -> Model +modifyCountKeepExisting model tags cats = + let + tagZeros : Dict String TagCount + tagZeros = + Dict.map (\_ -> \tc -> TagCount tc.tag 0) model.availableTags + + tagAvail = + List.foldl (\tc -> \dict -> Dict.insert tc.tag.id tc dict) tagZeros tags + + tcs = + Dict.values tagAvail + + catcs = + List.filterMap (\e -> Maybe.map (\k -> CategoryCount k e.count) e.name) cats + + catZeros : Dict String CategoryCount + catZeros = + Dict.map (\_ -> \cc -> CategoryCount cc.name 0) model.availableCats + + catAvail = + List.foldl (\cc -> \dict -> Dict.insert cc.name cc dict) catZeros catcs + + ccs = + Dict.values catAvail + in + { model + | tagCounts = tcs + , availableTags = tagAvail + , categoryCounts = ccs + , availableCats = catAvail + } + + reset : Model -> Model reset model = { model @@ -245,6 +280,12 @@ makeWorkModel sel model = } +noEmptyTags : Model -> Bool +noEmptyTags model = + Dict.filter (\k -> \v -> v.count == 0) model.availableTags + |> Dict.isEmpty + + type Msg = ToggleTag String | ToggleCat String @@ -422,6 +463,7 @@ viewTagsDrop2 texts ddm wm settings model = [ a [ class S.secondaryBasicButtonPlain , class "border rounded flex-none px-1 py-1" + , classList [ ( "hidden", noEmptyTags model ) ] , href "#" , onClick ToggleShowEmpty ] diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm index 4e4b1632..ecfb6781 100644 --- a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm @@ -28,6 +28,7 @@ import Data.DropdownStyle as DS import Data.Fields exposing (Field) import Data.Flags exposing (Flags) import Data.ItemTemplate as IT exposing (ItemTemplate) +import Data.Pdf exposing (PdfMode) import Data.TagOrder import Data.UiSettings exposing (ItemPattern, Pos(..), UiSettings) import Dict exposing (Dict) @@ -50,7 +51,8 @@ type alias Model = , searchPageSizeModel : Comp.IntField.Model , tagColors : Dict String Color , tagColorModel : Comp.ColorTagger.Model - , nativePdfPreview : Bool + , pdfMode : PdfMode + , pdfModeModel : Comp.FixedDropdown.Model PdfMode , itemSearchNoteLength : Maybe Int , searchNoteLengthModel : Comp.IntField.Model , searchMenuFolderCount : Maybe Int @@ -122,7 +124,8 @@ init flags settings = Comp.ColorTagger.init [] Data.Color.all - , nativePdfPreview = settings.nativePdfPreview + , pdfMode = settings.pdfMode + , pdfModeModel = Comp.FixedDropdown.init Data.Pdf.allModes , itemSearchNoteLength = Just settings.itemSearchNoteLength , searchNoteLengthModel = Comp.IntField.init @@ -169,7 +172,6 @@ type Msg = SearchPageSizeMsg Comp.IntField.Msg | TagColorMsg Comp.ColorTagger.Msg | GetTagsResp (Result Http.Error TagList) - | TogglePdfPreview | NoteLengthMsg Comp.IntField.Msg | SearchMenuFolderMsg Comp.IntField.Msg | SearchMenuTagMsg Comp.IntField.Msg @@ -185,6 +187,7 @@ type Msg | ToggleSideMenuVisible | TogglePowerSearch | UiLangMsg (Comp.FixedDropdown.Msg UiLanguage) + | PdfModeMsg (Comp.FixedDropdown.Msg PdfMode) @@ -290,15 +293,6 @@ update sett msg model = in ( model_, nextSettings ) - TogglePdfPreview -> - let - flag = - not model.nativePdfPreview - in - ( { model | nativePdfPreview = flag } - , Just { sett | nativePdfPreview = flag } - ) - GetTagsResp (Ok tl) -> let categories = @@ -463,6 +457,22 @@ update sett msg model = Just { sett | uiLang = newLang } ) + PdfModeMsg lm -> + let + ( m, sel ) = + Comp.FixedDropdown.update lm model.pdfModeModel + + newMode = + Maybe.withDefault model.pdfMode sel + in + ( { model | pdfModeModel = m, pdfMode = newMode } + , if newMode == model.pdfMode then + Nothing + + else + Just { sett | pdfMode = newMode } + ) + --- View2 @@ -516,6 +526,13 @@ settingFormTabs texts flags _ model = , style = DS.mainStyle , selectPlaceholder = texts.basics.selectPlaceholder } + + pdfModeCfg = + { display = texts.pdfMode + , icon = \_ -> Nothing + , style = DS.mainStyle + , selectPlaceholder = texts.basics.selectPlaceholder + } in [ { name = "general" , title = texts.general @@ -689,13 +706,14 @@ settingFormTabs texts flags _ model = , info = Nothing , body = [ div [ class "mb-4" ] - [ MB.viewItem <| - MB.Checkbox - { tagger = \_ -> TogglePdfPreview - , label = texts.browserNativePdfView - , value = model.nativePdfPreview - , id = "uisetting-pdfpreview-toggle" - } + [ label [ class S.inputLabel ] [ text texts.browserNativePdfView ] + , Html.map PdfModeMsg + (Comp.FixedDropdown.viewStyled2 + pdfModeCfg + False + (Just model.pdfMode) + model.pdfModeModel + ) ] , div [ class "mb-4" ] [ MB.viewItem <| diff --git a/modules/webapp/src/main/elm/Comp/UrlCopy.elm b/modules/webapp/src/main/elm/Comp/UrlCopy.elm new file mode 100644 index 00000000..ac42d239 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/UrlCopy.elm @@ -0,0 +1,102 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.UrlCopy exposing (..) + +import Comp.Basic as B +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Ports +import QRCode +import Styles as S +import Svg.Attributes as SvgA + + +type Msg + = Print String + + +update : Msg -> Cmd msg +update msg = + case msg of + Print id -> + Ports.printElement id + + +initCopy : String -> Cmd msg +initCopy data = + Ports.initClipboard <| clipboardData data + + +clipboardData : String -> ( String, String ) +clipboardData data = + ( "share-url", "#button-share-url" ) + + +view : String -> Html Msg +view data = + let + ( elementId, buttonId ) = + clipboardData data + + btnId = + String.dropLeft 1 buttonId + + printId = + "print-qr-code" + in + div [ class "flex flex-col items-center" ] + [ div + [ class S.border + , class S.qrCode + , id printId + ] + [ qrCodeView data + ] + , div + [ class "flex w-64" + ] + [ p + [ id elementId + , class "font-mono text-xs py-2 mx-auto break-all" + ] + [ text data + ] + ] + , div [ class "flex flex-row mt-1 space-x-2 items-center w-full" ] + [ B.primaryButton + { label = "Copy" + , icon = "fa fa-copy" + , handler = href "#" + , disabled = False + , attrs = + [ id btnId + , class "flex flex-grow items-center justify-center" + , attribute "data-clipboard-target" ("#" ++ elementId) + ] + } + , B.primaryButton + { label = "Print" + , icon = "fa fa-print" + , handler = onClick (Print printId) + , disabled = False + , attrs = + [ href "#" + , class "flex flex-grow items-center justify-center" + ] + } + ] + ] + + +qrCodeView : String -> Html msg +qrCodeView message = + QRCode.fromString message + |> Result.map (QRCode.toSvg [ SvgA.class "w-64 h-64" ]) + |> Result.withDefault + (text "Error generating QR code") diff --git a/modules/webapp/src/main/elm/Comp/UserManage.elm b/modules/webapp/src/main/elm/Comp/UserManage.elm index 15efbaf8..47a6d0a6 100644 --- a/modules/webapp/src/main/elm/Comp/UserManage.elm +++ b/modules/webapp/src/main/elm/Comp/UserManage.elm @@ -295,7 +295,7 @@ renderDeleteConfirm texts settings model = DimmerUserData data -> let empty = - List.isEmpty data.folders && data.sentMails == 0 + List.isEmpty data.folders && data.sentMails == 0 && data.shares == 0 folderNames = String.join ", " data.folders @@ -312,16 +312,20 @@ renderDeleteConfirm texts settings model = [ div [] [ text texts.reallyDeleteUser , text " " - , text "The following data will be deleted:" + , text (texts.deleteFollowingData ++ ":") ] , ul [ class "list-inside list-disc" ] [ li [ classList [ ( "hidden", List.isEmpty data.folders ) ] ] - [ text "Folders: " + [ text (texts.folders ++ ": ") , text folderNames ] , li [ classList [ ( "hidden", data.sentMails == 0 ) ] ] [ text (String.fromInt data.sentMails) - , text " sent mails" + , text (" " ++ texts.sentMails) + ] + , li [ classList [ ( "hidden", data.shares == 0 ) ] ] + [ text (String.fromInt data.shares) + , text (" " ++ texts.shares) ] ] ] diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm index ea23b52f..5ebfa9c7 100644 --- a/modules/webapp/src/main/elm/Data/Flags.elm +++ b/modules/webapp/src/main/elm/Data/Flags.elm @@ -9,7 +9,9 @@ module Data.Flags exposing ( Config , Flags , accountString + , getAccount , getToken + , isAuthenticated , withAccount , withoutAccount ) @@ -39,10 +41,29 @@ type alias Config = type alias Flags = { account : Maybe AuthResult + , pdfSupported : Bool , config : Config } +isAuthenticated : Flags -> Bool +isAuthenticated flags = + getAccount flags /= Nothing + + +getAccount : Flags -> Maybe AuthResult +getAccount flags = + Maybe.andThen + (\ar -> + if ar.success then + Just ar + + else + Nothing + ) + flags.account + + getToken : Flags -> Maybe String getToken flags = flags.account diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index f2455c8d..dbe79fc0 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -58,19 +58,17 @@ module Data.Icons exposing , personIcon2 , search , searchIcon + , share + , shareIcon , showQr , showQrIcon - , source , source2 - , sourceIcon , sourceIcon2 , tag , tag2 , tagIcon , tagIcon2 - , tags , tags2 - , tagsIcon , tagsIcon2 ) @@ -79,9 +77,14 @@ import Html exposing (Html, i) import Html.Attributes exposing (class) -source : String -source = - "upload icon" +share : String +share = + "fa fa-share-alt" + + +shareIcon : String -> Html msg +shareIcon classes = + i [ class (classes ++ " " ++ share) ] [] source2 : String @@ -89,11 +92,6 @@ source2 = "fa fa-upload" -sourceIcon : String -> Html msg -sourceIcon classes = - i [ class (source ++ " " ++ classes) ] [] - - sourceIcon2 : String -> Html msg sourceIcon2 classes = i [ class (source2 ++ " " ++ classes) ] [] @@ -361,16 +359,6 @@ tagIcon2 classes = i [ class (tag2 ++ " " ++ classes) ] [] -tags : String -tags = - "tags icon" - - -tagsIcon : String -> Html msg -tagsIcon classes = - i [ class (tags ++ " " ++ classes) ] [] - - tags2 : String tags2 = "fa fa-tags" diff --git a/modules/webapp/src/main/elm/Data/Items.elm b/modules/webapp/src/main/elm/Data/Items.elm index a2459b7b..0940781c 100644 --- a/modules/webapp/src/main/elm/Data/Items.elm +++ b/modules/webapp/src/main/elm/Data/Items.elm @@ -8,6 +8,7 @@ module Data.Items exposing ( concat , first + , flatten , idSet , length , replaceIn @@ -21,6 +22,11 @@ import Set exposing (Set) import Util.List +flatten : ItemLightList -> List ItemLight +flatten list = + List.concatMap .items list.groups + + concat : ItemLightList -> ItemLightList -> ItemLightList concat l0 l1 = let diff --git a/modules/webapp/src/main/elm/Data/Pdf.elm b/modules/webapp/src/main/elm/Data/Pdf.elm new file mode 100644 index 00000000..9943dbe1 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/Pdf.elm @@ -0,0 +1,74 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.Pdf exposing (PdfMode(..), allModes, asString, detectUrl, fromString, serverUrl) + +{-| Makes use of the fact, that docspell uses a `/view` suffix on the +path to provide a browser independent PDF view. +-} + +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) + + +type PdfMode + = Detect + | Native + | Server + + +allModes : List PdfMode +allModes = + [ Detect, Native, Server ] + + +asString : PdfMode -> String +asString mode = + case mode of + Detect -> + "detect" + + Native -> + "native" + + Server -> + "server" + + +fromString : String -> Maybe PdfMode +fromString str = + case String.toLower str of + "detect" -> + Just Detect + + "native" -> + Just Native + + "server" -> + Just Server + + _ -> + Nothing + + +serverUrl : String -> String +serverUrl url = + if String.endsWith "/" url then + url ++ "view" + + else + url ++ "/view" + + +detectUrl : Flags -> String -> String +detectUrl flags url = + if flags.pdfSupported then + url + + else + serverUrl url diff --git a/modules/webapp/src/main/elm/Data/UiSettings.elm b/modules/webapp/src/main/elm/Data/UiSettings.elm index fa7180df..3b5d4d80 100644 --- a/modules/webapp/src/main/elm/Data/UiSettings.elm +++ b/modules/webapp/src/main/elm/Data/UiSettings.elm @@ -20,6 +20,7 @@ module Data.UiSettings exposing , fieldVisible , merge , mergeDefaults + , pdfUrl , posFromString , posToString , storedUiSettingsDecoder @@ -34,7 +35,9 @@ import Api.Model.Tag exposing (Tag) import Data.BasicSize exposing (BasicSize) import Data.Color exposing (Color) import Data.Fields exposing (Field) +import Data.Flags exposing (Flags) import Data.ItemTemplate exposing (ItemTemplate) +import Data.Pdf exposing (PdfMode) import Data.UiTheme exposing (UiTheme) import Dict exposing (Dict) import Html exposing (Attribute) @@ -57,7 +60,7 @@ force default settings. type alias StoredUiSettings = { itemSearchPageSize : Maybe Int , tagCategoryColors : List ( String, String ) - , nativePdfPreview : Bool + , pdfMode : Maybe String , itemSearchNoteLength : Maybe Int , itemDetailNotesPosition : Maybe String , searchMenuFolderCount : Maybe Int @@ -91,7 +94,7 @@ storedUiSettingsDecoder = Decode.succeed StoredUiSettings |> P.optional "itemSearchPageSize" maybeInt Nothing |> P.optional "tagCategoryColors" (Decode.keyValuePairs Decode.string) [] - |> P.optional "nativePdfPreview" Decode.bool False + |> P.optional "pdfMode" maybeString Nothing |> P.optional "itemSearchNoteLength" maybeInt Nothing |> P.optional "itemDetailNotesPosition" maybeString Nothing |> P.optional "searchMenuFolderCount" maybeInt Nothing @@ -121,7 +124,7 @@ storedUiSettingsEncode value = Encode.object [ ( "itemSearchPageSize", maybeEnc Encode.int value.itemSearchPageSize ) , ( "tagCategoryColors", Encode.dict identity Encode.string (Dict.fromList value.tagCategoryColors) ) - , ( "nativePdfPreview", Encode.bool value.nativePdfPreview ) + , ( "pdfMode", maybeEnc Encode.string value.pdfMode ) , ( "itemSearchNoteLength", maybeEnc Encode.int value.itemSearchNoteLength ) , ( "itemDetailNotesPosition", maybeEnc Encode.string value.itemDetailNotesPosition ) , ( "searchMenuFolderCount", maybeEnc Encode.int value.searchMenuFolderCount ) @@ -146,14 +149,15 @@ storedUiSettingsEncode value = {-| Settings for the web ui. These fields are all mandatory, since there is always a default value. -When loaded from local storage, all optional fields can fallback to a -default value, converting the StoredUiSettings into a UiSettings. +When loaded from local storage or the server, all optional fields can +fallback to a default value, converting the StoredUiSettings into a +UiSettings. -} type alias UiSettings = { itemSearchPageSize : Int , tagCategoryColors : Dict String Color - , nativePdfPreview : Bool + , pdfMode : PdfMode , itemSearchNoteLength : Int , itemDetailNotesPosition : Pos , searchMenuFolderCount : Int @@ -219,7 +223,7 @@ defaults : UiSettings defaults = { itemSearchPageSize = 60 , tagCategoryColors = Dict.empty - , nativePdfPreview = False + , pdfMode = Data.Pdf.Detect , itemSearchNoteLength = 0 , itemDetailNotesPosition = Bottom , searchMenuFolderCount = 3 @@ -259,7 +263,10 @@ merge given fallback = |> Dict.map (\_ -> Maybe.withDefault Data.Color.Grey) ) fallback.tagCategoryColors - , nativePdfPreview = given.nativePdfPreview + , pdfMode = + given.pdfMode + |> Maybe.andThen Data.Pdf.fromString + |> Maybe.withDefault fallback.pdfMode , itemSearchNoteLength = choose given.itemSearchNoteLength fallback.itemSearchNoteLength , itemDetailNotesPosition = @@ -313,7 +320,7 @@ toStoredUiSettings settings = , tagCategoryColors = Dict.map (\_ -> Data.Color.toString) settings.tagCategoryColors |> Dict.toList - , nativePdfPreview = settings.nativePdfPreview + , pdfMode = Just (Data.Pdf.asString settings.pdfMode) , itemSearchNoteLength = Just settings.itemSearchNoteLength , itemDetailNotesPosition = Just (posToString settings.itemDetailNotesPosition) , searchMenuFolderCount = Just settings.searchMenuFolderCount @@ -407,6 +414,19 @@ cardPreviewSize2 settings = "max-h-80" +pdfUrl : UiSettings -> Flags -> String -> String +pdfUrl settings flags originalUrl = + case settings.pdfMode of + Data.Pdf.Detect -> + Data.Pdf.detectUrl flags originalUrl + + Data.Pdf.Native -> + originalUrl + + Data.Pdf.Server -> + Data.Pdf.serverUrl originalUrl + + --- Helpers diff --git a/modules/webapp/src/main/elm/Messages.elm b/modules/webapp/src/main/elm/Messages.elm index a0671289..24399b15 100644 --- a/modules/webapp/src/main/elm/Messages.elm +++ b/modules/webapp/src/main/elm/Messages.elm @@ -21,6 +21,8 @@ import Messages.Page.ManageData import Messages.Page.NewInvite import Messages.Page.Queue import Messages.Page.Register +import Messages.Page.Share +import Messages.Page.ShareDetail import Messages.Page.Upload import Messages.Page.UserSettings import Messages.UiLanguage exposing (UiLanguage(..)) @@ -44,6 +46,8 @@ type alias Messages = , userSettings : Messages.Page.UserSettings.Texts , manageData : Messages.Page.ManageData.Texts , home : Messages.Page.Home.Texts + , share : Messages.Page.Share.Texts + , shareDetail : Messages.Page.ShareDetail.Texts } @@ -109,6 +113,8 @@ gb = , userSettings = Messages.Page.UserSettings.gb , manageData = Messages.Page.ManageData.gb , home = Messages.Page.Home.gb + , share = Messages.Page.Share.gb + , shareDetail = Messages.Page.ShareDetail.gb } @@ -129,4 +135,6 @@ de = , userSettings = Messages.Page.UserSettings.de , manageData = Messages.Page.ManageData.de , home = Messages.Page.Home.de + , share = Messages.Page.Share.de + , shareDetail = Messages.Page.ShareDetail.de } diff --git a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm index 79de30cc..ea6248f1 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm @@ -44,6 +44,8 @@ type alias Texts = , fulltextReindexSubmitted : String , fulltextReindexOkMissing : String , emptyTrash : String + , passwords : String + , passwordsInfo : String } @@ -77,6 +79,8 @@ gb = , fulltextReindexOkMissing = "Please type OK in the field if you really want to start re-indexing your data." , emptyTrash = "Empty Trash" + , passwords = "Passwords" + , passwordsInfo = "These passwords are used when encrypted PDFs are being processed. Please note, that they are stored in the database as **plain text**!" } @@ -110,4 +114,6 @@ de = , fulltextReindexOkMissing = "Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest." , emptyTrash = "Papierkorb löschen" + , passwords = "Passwörter" + , passwordsInfo = "Diese Passwörter werden zum Lesen von verschlüsselten PDFs verwendet. Diese Passwörter werden in der Datanbank **in Klartext** gespeichert!" } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemMail.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemMail.elm index 78a538d2..f50885bd 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemMail.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemMail.elm @@ -29,6 +29,8 @@ type alias Texts = , includeAllAttachments : String , connectionMissing : String , sendLabel : String + , moreRecipients : String + , lessRecipients : String } @@ -39,13 +41,15 @@ gb = , selectConnection = "Select connection..." , sendVia = "Send via" , recipients = "Recipient(s)" - , ccRecipients = "CC recipient(s)" - , bccRecipients = "BCC recipient(s)..." + , ccRecipients = "CC" + , bccRecipients = "BCC" , subject = "Subject" , body = "Body" , includeAllAttachments = "Include all item attachments" , connectionMissing = "No E-Mail connections configured. Goto user settings to add one." , sendLabel = "Send" + , moreRecipients = "More…" + , lessRecipients = "Less…" } @@ -63,4 +67,6 @@ de = , includeAllAttachments = "Alle Anhänge mit einfügen" , connectionMissing = "Keine E-Mail-Verbindung definiert. Gehe zu den Benutzereinstellungen und füge eine hinzu." , sendLabel = "Senden" + , moreRecipients = "Weitere…" + , lessRecipients = "Weniger…" } diff --git a/modules/webapp/src/main/elm/Messages/Comp/PublishItems.elm b/modules/webapp/src/main/elm/Messages/Comp/PublishItems.elm new file mode 100644 index 00000000..b23703a8 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/PublishItems.elm @@ -0,0 +1,89 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.PublishItems exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.ShareForm +import Messages.Comp.ShareMail +import Messages.Comp.ShareView +import Messages.DateFormat +import Messages.UiLanguage + + +type alias Texts = + { basics : Messages.Basics.Texts + , httpError : Http.Error -> String + , shareForm : Messages.Comp.ShareForm.Texts + , shareView : Messages.Comp.ShareView.Texts + , shareMail : Messages.Comp.ShareMail.Texts + , title : String + , infoText : String + , formatDateLong : Int -> String + , formatDateShort : Int -> String + , submitPublish : String + , cancelPublish : String + , submitPublishTitle : String + , cancelPublishTitle : String + , publishSuccessful : String + , publishInProcess : String + , correctFormErrors : String + , doneLabel : String + , sendViaMail : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , httpError = Messages.Comp.HttpError.gb + , shareForm = Messages.Comp.ShareForm.gb + , shareView = Messages.Comp.ShareView.gb + , shareMail = Messages.Comp.ShareMail.gb + , title = "Publish Items" + , infoText = "Publishing items creates a cryptic link, which can be used by everyone to see the selected documents. This link cannot be guessed, but is public! It exists for a certain amount of time and can be further protected using a password." + , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.English + , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.English + , submitPublish = "Publish" + , submitPublishTitle = "Publish the documents now" + , cancelPublish = "Cancel" + , cancelPublishTitle = "Back to select view" + , publishSuccessful = "Items published successfully" + , publishInProcess = "Items are published …" + , correctFormErrors = "Please correct the errors in the form." + , doneLabel = "Done" + , sendViaMail = "Send via E-Mail" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , httpError = Messages.Comp.HttpError.de + , shareForm = Messages.Comp.ShareForm.de + , shareView = Messages.Comp.ShareView.de + , shareMail = Messages.Comp.ShareMail.de + , title = "Dokumente publizieren" + , infoText = "Beim Publizieren der Dokumente wird ein kryptischer Link erzeugt, mit welchem jeder die dahinter publizierten Dokumente einsehen kann. Dieser Link kann nicht erraten werden, ist aber öffentlich. Er ist zeitlich begrenzt und kann zusätzlich mit einem Passwort geschützt werden." + , formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.German + , formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.German + , submitPublish = "Publizieren" + , submitPublishTitle = "Dokumente jetzt publizieren" + , cancelPublish = "Abbrechen" + , cancelPublishTitle = "Zurück zur Auswahl" + , publishSuccessful = "Die Dokumente wurden erfolgreich publiziert." + , publishInProcess = "Dokumente werden publiziert…" + , correctFormErrors = "Bitte korrigiere die Fehler im Formular." + , doneLabel = "Fertig" + , sendViaMail = "Per E-Mail versenden" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm new file mode 100644 index 00000000..44a9bcb5 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm @@ -0,0 +1,46 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , queryLabel : String + , enabled : String + , password : String + , publishUntil : String + , clearPassword : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , queryLabel = "Query" + , enabled = "Enabled" + , password = "Password" + , publishUntil = "Publish Until" + , clearPassword = "Remove password" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , queryLabel = "Abfrage" + , enabled = "Aktiv" + , password = "Passwort" + , publishUntil = "Publiziert bis" + , clearPassword = "Passwort entfernen" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareMail.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareMail.elm new file mode 100644 index 00000000..04d49ba1 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareMail.elm @@ -0,0 +1,63 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareMail exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.ItemMail + + +type alias Texts = + { basics : Messages.Basics.Texts + , itemMail : Messages.Comp.ItemMail.Texts + , httpError : Http.Error -> String + , subjectTemplate : Maybe String -> String + , bodyTemplate : String -> String + , mailSent : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , httpError = Messages.Comp.HttpError.gb + , itemMail = Messages.Comp.ItemMail.gb + , subjectTemplate = \mt -> "Shared Documents" ++ (Maybe.map (\n -> ": " ++ n) mt |> Maybe.withDefault "") + , bodyTemplate = \url -> """Hi, + +you can find the documents here: + + """ ++ url ++ """ + +Kind regards +""" + , mailSent = "Mail sent." + } + + +de : Texts +de = + { basics = Messages.Basics.de + , httpError = Messages.Comp.HttpError.de + , itemMail = Messages.Comp.ItemMail.de + , subjectTemplate = \mt -> "Freigegebene Dokumente" ++ (Maybe.map (\n -> ": " ++ n) mt |> Maybe.withDefault "") + , bodyTemplate = \url -> """Hallo, + +die freigegebenen Dokumente befinden sich hier: + + """ ++ url ++ """ + +Freundliche Grüße +""" + , mailSent = "E-Mail gesendet." + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm new file mode 100644 index 00000000..9de0104e --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm @@ -0,0 +1,94 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareManage exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.ShareForm +import Messages.Comp.ShareMail +import Messages.Comp.ShareTable +import Messages.Comp.ShareView + + +type alias Texts = + { basics : Messages.Basics.Texts + , shareTable : Messages.Comp.ShareTable.Texts + , shareForm : Messages.Comp.ShareForm.Texts + , shareView : Messages.Comp.ShareView.Texts + , shareMail : Messages.Comp.ShareMail.Texts + , httpError : Http.Error -> String + , newShare : String + , copyToClipboard : String + , openInNewTab : String + , publicUrl : String + , reallyDeleteShare : String + , createNewShare : String + , deleteThisShare : String + , errorGeneratingQR : String + , correctFormErrors : String + , noName : String + , shareInformation : String + , sendViaMail : String + , notOwnerInfo : String + , showOwningSharesOnly : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , httpError = Messages.Comp.HttpError.gb + , shareTable = Messages.Comp.ShareTable.gb + , shareForm = Messages.Comp.ShareForm.gb + , shareView = Messages.Comp.ShareView.gb + , shareMail = Messages.Comp.ShareMail.gb + , newShare = "New share" + , copyToClipboard = "Copy to clipboard" + , openInNewTab = "Open in new tab/window" + , publicUrl = "Public URL" + , reallyDeleteShare = "Really delete this share?" + , createNewShare = "Create new share" + , deleteThisShare = "Delete this share" + , errorGeneratingQR = "Error generating QR Code" + , correctFormErrors = "Please correct the errors in the form." + , noName = "No Name" + , shareInformation = "Share Information" + , sendViaMail = "Send via E-Mail" + , notOwnerInfo = "Only the user who created this share can edit its properties." + , showOwningSharesOnly = "Show my shares only" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , shareTable = Messages.Comp.ShareTable.de + , shareForm = Messages.Comp.ShareForm.de + , shareView = Messages.Comp.ShareView.de + , httpError = Messages.Comp.HttpError.de + , shareMail = Messages.Comp.ShareMail.de + , newShare = "Neue Freigabe" + , copyToClipboard = "In die Zwischenablage kopieren" + , openInNewTab = "Im neuen Tab/Fenster öffnen" + , publicUrl = "Öffentliche URL" + , reallyDeleteShare = "Diese Freigabe wirklich entfernen?" + , createNewShare = "Neue Freigabe erstellen" + , deleteThisShare = "Freigabe löschen" + , errorGeneratingQR = "Fehler beim Generieren des QR-Code" + , correctFormErrors = "Bitte korrigiere die Fehler im Formular." + , noName = "Ohne Name" + , shareInformation = "Informationen zur Freigabe" + , sendViaMail = "Per E-Mail versenden" + , notOwnerInfo = "Nur der Benutzer, der diese Freigabe erstellt hat, kann diese auch ändern." + , showOwningSharesOnly = "Nur meine Freigaben anzeigen" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/SharePasswordForm.elm b/modules/webapp/src/main/elm/Messages/Comp/SharePasswordForm.elm new file mode 100644 index 00000000..889495c8 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/SharePasswordForm.elm @@ -0,0 +1,40 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.SharePasswordForm exposing (Texts, de, gb) + +import Http +import Messages.Comp.HttpError + + +type alias Texts = + { httpError : Http.Error -> String + , passwordRequired : String + , password : String + , passwordSubmitButton : String + , passwordFailed : String + } + + +gb : Texts +gb = + { httpError = Messages.Comp.HttpError.gb + , passwordRequired = "Password required" + , password = "Password" + , passwordSubmitButton = "Submit" + , passwordFailed = "Password is wrong" + } + + +de : Texts +de = + { httpError = Messages.Comp.HttpError.de + , passwordRequired = "Passwort benötigt" + , password = "Passwort" + , passwordSubmitButton = "Submit" + , passwordFailed = "Das Passwort ist falsch" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm new file mode 100644 index 00000000..170876ff --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm @@ -0,0 +1,45 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareTable exposing + ( Texts + , de + , gb + ) + +import Messages.Basics +import Messages.DateFormat as DF +import Messages.UiLanguage + + +type alias Texts = + { basics : Messages.Basics.Texts + , formatDateTime : Int -> String + , active : String + , publishUntil : String + , user : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , formatDateTime = DF.formatDateTimeLong Messages.UiLanguage.English + , active = "Active" + , publishUntil = "Publish Until" + , user = "User" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , formatDateTime = DF.formatDateTimeLong Messages.UiLanguage.German + , active = "Aktiv" + , publishUntil = "Publiziert bis" + , user = "Benutzer" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ShareView.elm b/modules/webapp/src/main/elm/Messages/Comp/ShareView.elm new file mode 100644 index 00000000..86f15c07 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ShareView.elm @@ -0,0 +1,66 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ShareView exposing + ( Texts + , de + , gb + ) + +import Messages.Basics +import Messages.DateFormat as DF +import Messages.UiLanguage + + +type alias Texts = + { basics : Messages.Basics.Texts + , date : Int -> String + , qrCodeError : String + , expiredInfo : String + , disabledInfo : String + , noName : String + , copyToClipboard : String + , openInNewTab : String + , publishUntil : String + , passwordProtected : String + , views : String + , lastAccess : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , date = DF.formatDateLong Messages.UiLanguage.English + , qrCodeError = "Error generating QR Code." + , expiredInfo = "This share has expired." + , disabledInfo = "This share is disabled." + , noName = "No Name" + , copyToClipboard = "Copy to clipboard" + , openInNewTab = "Open in new tab/window" + , publishUntil = "Published Until" + , passwordProtected = "Password protected" + , views = "Views" + , lastAccess = "Last Access" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , date = DF.formatDateLong Messages.UiLanguage.German + , qrCodeError = "Fehler beim Erzeugen des QR-Codes." + , expiredInfo = "Diese Freigabe ist abgelaufen." + , disabledInfo = "Diese Freigae ist nicht aktiv." + , noName = "Ohne Name" + , copyToClipboard = "In die Zwischenablage kopieren" + , openInNewTab = "Im neuen Tab/Fenster öffnen" + , publishUntil = "Publiziert bis" + , passwordProtected = "Passwordgeschützt" + , views = "Aufrufe" + , lastAccess = "Letzter Zugriff" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Messages/Comp/UiSettingsForm.elm index 929128bd..a09151a3 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/UiSettingsForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/UiSettingsForm.elm @@ -13,9 +13,11 @@ module Messages.Comp.UiSettingsForm exposing import Data.Color exposing (Color) import Data.Fields exposing (Field) +import Data.Pdf exposing (PdfMode) import Messages.Basics import Messages.Data.Color import Messages.Data.Fields +import Messages.Data.PdfMode type alias Texts = @@ -53,6 +55,7 @@ type alias Texts = , fieldsInfo : String , fieldLabel : Field -> String , templateHelpMessage : String + , pdfMode : PdfMode -> String } @@ -127,6 +130,7 @@ for example `{{corrOrg|corrPerson|-}}` would render the organization and if that is not present the person. If both are absent a dash `-` is rendered. """ + , pdfMode = Messages.Data.PdfMode.gb } @@ -203,4 +207,5 @@ verknüpft werden, bis zur ersten die einen Wert enthält. Zum Beispiel: oder, wenn diese leer ist, die Person. Sind beide leer wird ein `-` dargestellt. """ + , pdfMode = Messages.Data.PdfMode.de } diff --git a/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm b/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm index 2c59fd72..5f1fd5f2 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/UserManage.elm @@ -31,6 +31,10 @@ type alias Texts = , deleteThisUser : String , pleaseCorrectErrors : String , notDeleteCurrentUser : String + , folders : String + , sentMails : String + , shares : String + , deleteFollowingData : String } @@ -48,6 +52,10 @@ gb = , deleteThisUser = "Delete this user" , pleaseCorrectErrors = "Please correct the errors in the form." , notDeleteCurrentUser = "You can't delete the user you are currently logged in with." + , folders = "Folders" + , sentMails = "sent mails" + , shares = "shares" + , deleteFollowingData = "The following data will be deleted" } @@ -65,4 +73,8 @@ de = , deleteThisUser = "Benutzer löschen" , pleaseCorrectErrors = "Bitte korrigiere die Fehler im Formular." , notDeleteCurrentUser = "Der aktuelle Benutzer kann nicht gelöscht werden." + , folders = "Ordner" + , sentMails = "gesendete E-Mails" + , shares = "Freigaben" + , deleteFollowingData = "Die folgenden Daten werden auch gelöscht" } diff --git a/modules/webapp/src/main/elm/Messages/Data/PdfMode.elm b/modules/webapp/src/main/elm/Messages/Data/PdfMode.elm new file mode 100644 index 00000000..73748076 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Data/PdfMode.elm @@ -0,0 +1,39 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Data.PdfMode exposing + ( de + , gb + ) + +import Data.Pdf exposing (PdfMode(..)) + + +gb : PdfMode -> String +gb st = + case st of + Detect -> + "Detect automatically" + + Native -> + "Use the browser's native PDF view" + + Server -> + "Use cross-browser fallback" + + +de : PdfMode -> String +de st = + case st of + Detect -> + "Automatisch ermitteln" + + Native -> + "Browsernative Darstellung" + + Server -> + "Browserübergreifende Ersatzdarstellung" diff --git a/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm b/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm index dc36ab98..4ca75f93 100644 --- a/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm +++ b/modules/webapp/src/main/elm/Messages/Page/CollectiveSettings.elm @@ -15,6 +15,7 @@ import Http import Messages.Basics import Messages.Comp.CollectiveSettingsForm import Messages.Comp.HttpError +import Messages.Comp.ShareManage import Messages.Comp.SourceManage import Messages.Comp.UserManage @@ -24,12 +25,14 @@ type alias Texts = , userManage : Messages.Comp.UserManage.Texts , collectiveSettingsForm : Messages.Comp.CollectiveSettingsForm.Texts , sourceManage : Messages.Comp.SourceManage.Texts + , shareManage : Messages.Comp.ShareManage.Texts , httpError : Http.Error -> String , collectiveSettings : String , insights : String , sources : String , settings : String , users : String + , shares : String , user : String , collective : String , size : String @@ -44,12 +47,14 @@ gb = , userManage = Messages.Comp.UserManage.gb , collectiveSettingsForm = Messages.Comp.CollectiveSettingsForm.gb , sourceManage = Messages.Comp.SourceManage.gb + , shareManage = Messages.Comp.ShareManage.gb , httpError = Messages.Comp.HttpError.gb , collectiveSettings = "Collective Settings" , insights = "Insights" , sources = "Sources" , settings = "Settings" , users = "Users" + , shares = "Shares" , user = "User" , collective = "Collective" , size = "Size" @@ -64,12 +69,14 @@ de = , userManage = Messages.Comp.UserManage.de , collectiveSettingsForm = Messages.Comp.CollectiveSettingsForm.de , sourceManage = Messages.Comp.SourceManage.de + , shareManage = Messages.Comp.ShareManage.de , httpError = Messages.Comp.HttpError.de , collectiveSettings = "Kollektiveinstellungen" , insights = "Statistiken" , sources = "Quellen" , settings = "Einstellungen" , users = "Benutzer" + , shares = "Freigaben" , user = "Benutzer" , collective = "Kollektiv" , size = "Größe" diff --git a/modules/webapp/src/main/elm/Messages/Page/Home.elm b/modules/webapp/src/main/elm/Messages/Page/Home.elm index f51c5202..dada7a27 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Home.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Home.elm @@ -14,6 +14,7 @@ module Messages.Page.Home exposing import Messages.Basics import Messages.Comp.ItemCardList import Messages.Comp.ItemMerge +import Messages.Comp.PublishItems import Messages.Comp.SearchStatsView import Messages.Page.HomeSideMenu @@ -24,6 +25,7 @@ type alias Texts = , searchStatsView : Messages.Comp.SearchStatsView.Texts , sideMenu : Messages.Page.HomeSideMenu.Texts , itemMerge : Messages.Comp.ItemMerge.Texts + , publishItems : Messages.Comp.PublishItems.Texts , contentSearch : String , searchInNames : String , selectModeTitle : String @@ -42,6 +44,11 @@ type alias Texts = , resetSearchForm : String , exitSelectMode : String , mergeItemsTitle : Int -> String + , publishItemsTitle : Int -> String + , publishCurrentQueryTitle : String + , nothingSelectedToShare : String + , loadMore : String + , thatsAll : String } @@ -52,6 +59,7 @@ gb = , searchStatsView = Messages.Comp.SearchStatsView.gb , sideMenu = Messages.Page.HomeSideMenu.gb , itemMerge = Messages.Comp.ItemMerge.gb + , publishItems = Messages.Comp.PublishItems.gb , contentSearch = "Content search…" , searchInNames = "Search in names…" , selectModeTitle = "Select Mode" @@ -70,6 +78,11 @@ gb = , resetSearchForm = "Reset search form" , exitSelectMode = "Exit Select Mode" , mergeItemsTitle = \n -> "Merge " ++ String.fromInt n ++ " selected items" + , publishItemsTitle = \n -> "Publish " ++ String.fromInt n ++ " selected items" + , publishCurrentQueryTitle = "Publish current results" + , nothingSelectedToShare = "Sharing everything doesn't work. You need to apply some criteria." + , loadMore = "Load more…" + , thatsAll = "That's all" } @@ -80,6 +93,7 @@ de = , searchStatsView = Messages.Comp.SearchStatsView.de , sideMenu = Messages.Page.HomeSideMenu.de , itemMerge = Messages.Comp.ItemMerge.de + , publishItems = Messages.Comp.PublishItems.de , contentSearch = "Volltextsuche…" , searchInNames = "Suche in Namen…" , selectModeTitle = "Auswahlmodus" @@ -98,4 +112,9 @@ de = , resetSearchForm = "Suchformular zurücksetzen" , exitSelectMode = "Auswahlmodus verlassen" , mergeItemsTitle = \n -> String.fromInt n ++ " gewählte Dokumente zusammenführen" + , publishItemsTitle = \n -> String.fromInt n ++ " gewählte Dokumente publizieren" + , publishCurrentQueryTitle = "Aktuelle Ansicht publizieren" + , nothingSelectedToShare = "Alles kann nicht geteilt werden; es muss etwas gesucht werden." + , loadMore = "Mehr laden…" + , thatsAll = "Mehr gibt es nicht" } diff --git a/modules/webapp/src/main/elm/Messages/Page/Share.elm b/modules/webapp/src/main/elm/Messages/Page/Share.elm new file mode 100644 index 00000000..53061b7d --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Page/Share.elm @@ -0,0 +1,56 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Page.Share exposing (..) + +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.ItemCardList +import Messages.Comp.SearchMenu +import Messages.Comp.SharePasswordForm + + +type alias Texts = + { searchMenu : Messages.Comp.SearchMenu.Texts + , basics : Messages.Basics.Texts + , itemCardList : Messages.Comp.ItemCardList.Texts + , passwordForm : Messages.Comp.SharePasswordForm.Texts + , httpError : Http.Error -> String + , authFailed : String + , fulltextPlaceholder : String + , powerSearchPlaceholder : String + , extendedSearch : String + } + + +gb : Texts +gb = + { searchMenu = Messages.Comp.SearchMenu.gb + , basics = Messages.Basics.gb + , itemCardList = Messages.Comp.ItemCardList.gb + , passwordForm = Messages.Comp.SharePasswordForm.gb + , authFailed = "This share does not exist." + , httpError = Messages.Comp.HttpError.gb + , fulltextPlaceholder = "Fulltext search…" + , powerSearchPlaceholder = "Extended search…" + , extendedSearch = "Extended search query" + } + + +de : Texts +de = + { searchMenu = Messages.Comp.SearchMenu.de + , basics = Messages.Basics.de + , itemCardList = Messages.Comp.ItemCardList.de + , passwordForm = Messages.Comp.SharePasswordForm.de + , authFailed = "Diese Freigabe existiert nicht." + , httpError = Messages.Comp.HttpError.de + , fulltextPlaceholder = "Volltextsuche…" + , powerSearchPlaceholder = "Erweiterte Suche…" + , extendedSearch = "Erweiterte Suchanfrage" + } diff --git a/modules/webapp/src/main/elm/Messages/Page/ShareDetail.elm b/modules/webapp/src/main/elm/Messages/Page/ShareDetail.elm new file mode 100644 index 00000000..33408d3f --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Page/ShareDetail.elm @@ -0,0 +1,61 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Page.ShareDetail exposing (..) + +import Data.Fields exposing (Field) +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.SharePasswordForm +import Messages.Data.Fields +import Messages.DateFormat +import Messages.UiLanguage exposing (UiLanguage(..)) + + +type alias Texts = + { passwordForm : Messages.Comp.SharePasswordForm.Texts + , basics : Messages.Basics.Texts + , field : Field -> String + , formatDateLong : Int -> String + , formatDateShort : Int -> String + , httpError : Http.Error -> String + , authFailed : String + , tagsAndFields : String + , noName : String + , unconfirmed : String + } + + +gb : Texts +gb = + { passwordForm = Messages.Comp.SharePasswordForm.gb + , basics = Messages.Basics.gb + , field = Messages.Data.Fields.gb + , formatDateLong = Messages.DateFormat.formatDateLong English + , formatDateShort = Messages.DateFormat.formatDateShort English + , authFailed = "This share does not exist." + , httpError = Messages.Comp.HttpError.gb + , tagsAndFields = "Tags & Fields" + , noName = "No name" + , unconfirmed = "Unconfirmed" + } + + +de : Texts +de = + { passwordForm = Messages.Comp.SharePasswordForm.de + , basics = Messages.Basics.de + , field = Messages.Data.Fields.de + , formatDateLong = Messages.DateFormat.formatDateLong German + , formatDateShort = Messages.DateFormat.formatDateShort German + , authFailed = "Diese Freigabe existiert nicht." + , httpError = Messages.Comp.HttpError.de + , tagsAndFields = "Tags & Felder" + , noName = "Kein Name" + , unconfirmed = "Nicht bestätigt" + } diff --git a/modules/webapp/src/main/elm/Page.elm b/modules/webapp/src/main/elm/Page.elm index 92ff29d9..a14e5295 100644 --- a/modules/webapp/src/main/elm/Page.elm +++ b/modules/webapp/src/main/elm/Page.elm @@ -19,6 +19,8 @@ module Page exposing , loginPageReferrer , pageFromString , pageName + , pageShareDetail + , pageShareId , pageToString , set , uploadId @@ -59,6 +61,8 @@ type Page | UploadPage (Maybe String) | NewInvitePage | ItemDetailPage String + | SharePage String + | ShareDetailPage String String isSecured : Page -> Bool @@ -94,6 +98,12 @@ isSecured page = ItemDetailPage _ -> True + SharePage _ -> + False + + ShareDetailPage _ _ -> + False + {-| Currently, all secured pages have a sidebar, except UploadPage. -} @@ -103,6 +113,12 @@ hasSidebar page = UploadPage _ -> False + SharePage _ -> + True + + ShareDetailPage _ _ -> + True + _ -> isSecured page @@ -160,6 +176,12 @@ pageName page = ItemDetailPage _ -> "Item" + SharePage _ -> + "Share" + + ShareDetailPage _ _ -> + "Share Detail" + loginPageReferrer : Page -> LoginData loginPageReferrer page = @@ -171,6 +193,26 @@ loginPageReferrer page = emptyLoginData +pageShareId : Page -> Maybe String +pageShareId page = + case page of + SharePage id -> + Just id + + _ -> + Nothing + + +pageShareDetail : Page -> Maybe ( String, String ) +pageShareDetail page = + case page of + ShareDetailPage shareId itemId -> + Just ( shareId, itemId ) + + _ -> + Nothing + + uploadId : Page -> Maybe String uploadId page = case page of @@ -224,6 +266,12 @@ pageToString page = ItemDetailPage id -> "/app/item/" ++ id + SharePage id -> + "/app/share/" ++ id + + ShareDetailPage shareId itemId -> + "/app/share/" ++ shareId ++ "/" ++ itemId + pageFromString : String -> Maybe Page pageFromString str = @@ -280,6 +328,8 @@ parser = , Parser.map (UploadPage Nothing) (s pathPrefix s "upload") , Parser.map NewInvitePage (s pathPrefix s "newinvite") , Parser.map ItemDetailPage (s pathPrefix s "item" string) + , Parser.map ShareDetailPage (s pathPrefix s "share" string string) + , Parser.map SharePage (s pathPrefix s "share" string) ] diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm index d24b9494..286913b1 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm @@ -17,6 +17,7 @@ import Api.Model.BasicResult exposing (BasicResult) import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Api.Model.ItemInsights exposing (ItemInsights) import Comp.CollectiveSettingsForm +import Comp.ShareManage import Comp.SourceManage import Comp.UserManage import Data.Flags exposing (Flags) @@ -28,6 +29,7 @@ type alias Model = , sourceModel : Comp.SourceManage.Model , userModel : Comp.UserManage.Model , settingsModel : Comp.CollectiveSettingsForm.Model + , shareModel : Comp.ShareManage.Model , insights : ItemInsights , formState : FormState } @@ -48,10 +50,14 @@ init flags = ( cm, cc ) = Comp.CollectiveSettingsForm.init flags Api.Model.CollectiveSettings.empty + + ( shm, shc ) = + Comp.ShareManage.init flags in ( { currentTab = Just InsightsTab , sourceModel = sm , userModel = Comp.UserManage.emptyModel + , shareModel = shm , settingsModel = cm , insights = Api.Model.ItemInsights.empty , formState = InitialState @@ -59,6 +65,7 @@ init flags = , Cmd.batch [ Cmd.map SourceMsg sc , Cmd.map SettingsFormMsg cc + , Cmd.map ShareMsg shc ] ) @@ -68,6 +75,7 @@ type Tab | UserTab | InsightsTab | SettingsTab + | ShareTab type Msg @@ -79,3 +87,4 @@ type Msg | GetInsightsResp (Result Http.Error ItemInsights) | CollectiveSettingsResp (Result Http.Error CollectiveSettings) | SubmitResp (Result Http.Error BasicResult) + | ShareMsg Comp.ShareManage.Msg diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm index b3b55b88..519971c0 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm @@ -9,14 +9,16 @@ module Page.CollectiveSettings.Update exposing (update) import Api import Comp.CollectiveSettingsForm +import Comp.ShareManage import Comp.SourceManage import Comp.UserManage import Data.Flags exposing (Flags) +import Messages.Page.CollectiveSettings exposing (Texts) import Page.CollectiveSettings.Data exposing (..) -update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) -update flags msg model = +update : Texts -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update texts flags msg model = case msg of SetTab t -> let @@ -25,30 +27,40 @@ update flags msg model = in case t of SourceTab -> - update flags (SourceMsg Comp.SourceManage.LoadSources) m + update texts flags (SourceMsg Comp.SourceManage.LoadSources) m UserTab -> - update flags (UserMsg Comp.UserManage.LoadUsers) m + update texts flags (UserMsg Comp.UserManage.LoadUsers) m InsightsTab -> - update flags Init m + update texts flags Init m SettingsTab -> - update flags Init m + update texts flags Init m + + ShareTab -> + update texts flags (ShareMsg Comp.ShareManage.loadShares) m SourceMsg m -> let ( m2, c2 ) = Comp.SourceManage.update flags m model.sourceModel in - ( { model | sourceModel = m2 }, Cmd.map SourceMsg c2 ) + ( { model | sourceModel = m2 }, Cmd.map SourceMsg c2, Sub.none ) + + ShareMsg lm -> + let + ( sm, sc, ss ) = + Comp.ShareManage.update texts.shareManage flags lm model.shareModel + in + ( { model | shareModel = sm }, Cmd.map ShareMsg sc, Sub.map ShareMsg ss ) UserMsg m -> let ( m2, c2 ) = Comp.UserManage.update flags m model.userModel in - ( { model | userModel = m2 }, Cmd.map UserMsg c2 ) + ( { model | userModel = m2 }, Cmd.map UserMsg c2, Sub.none ) SettingsFormMsg m -> let @@ -65,6 +77,7 @@ update flags msg model = in ( { model | settingsModel = m2, formState = InitialState } , Cmd.batch [ cmd, Cmd.map SettingsFormMsg c2 ] + , Sub.none ) Init -> @@ -73,13 +86,14 @@ update flags msg model = [ Api.getInsights flags GetInsightsResp , Api.getCollectiveSettings flags CollectiveSettingsResp ] + , Sub.none ) GetInsightsResp (Ok data) -> - ( { model | insights = data }, Cmd.none ) + ( { model | insights = data }, Cmd.none, Sub.none ) GetInsightsResp (Err _) -> - ( model, Cmd.none ) + ( model, Cmd.none, Sub.none ) CollectiveSettingsResp (Ok data) -> let @@ -88,10 +102,11 @@ update flags msg model = in ( { model | settingsModel = cm } , Cmd.map SettingsFormMsg cc + , Sub.none ) CollectiveSettingsResp (Err _) -> - ( model, Cmd.none ) + ( model, Cmd.none, Sub.none ) SubmitResp (Ok res) -> ( { model @@ -103,7 +118,8 @@ update flags msg model = SubmitFailed res.message } , Cmd.none + , Sub.none ) SubmitResp (Err err) -> - ( { model | formState = SubmitError err }, Cmd.none ) + ( { model | formState = SubmitError err }, Cmd.none, Sub.none ) diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm index 4454e30f..7a09d909 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm @@ -10,6 +10,7 @@ module Page.CollectiveSettings.View2 exposing (viewContent, viewSidebar) import Api.Model.TagCount exposing (TagCount) import Comp.Basic as B import Comp.CollectiveSettingsForm +import Comp.ShareManage import Comp.SourceManage import Comp.UserManage import Data.Flags exposing (Flags) @@ -60,6 +61,17 @@ viewSidebar texts visible _ _ model = [ class "ml-3" ] [ text texts.sources ] ] + , a + [ href "#" + , onClick (SetTab ShareTab) + , class S.sidebarLink + , menuEntryActive model ShareTab + ] + [ Icons.shareIcon "" + , span + [ class "ml-3" ] + [ text texts.shares ] + ] , a [ href "#" , onClick (SetTab SettingsTab) @@ -105,6 +117,9 @@ viewContent texts flags settings model = Just SourceTab -> viewSources texts flags settings model + Just ShareTab -> + viewShares texts settings flags model + Nothing -> [] ) @@ -230,6 +245,21 @@ viewSources texts flags settings model = ] +viewShares : Texts -> UiSettings -> Flags -> Model -> List (Html Msg) +viewShares texts settings flags model = + [ h1 + [ class S.header1 + , class "inline-flex items-center" + ] + [ Icons.shareIcon "" + , div [ class "ml-3" ] + [ text texts.shares + ] + ] + , Html.map ShareMsg (Comp.ShareManage.view texts.shareManage settings flags model.shareModel) + ] + + viewUsers : Texts -> UiSettings -> Model -> List (Html Msg) viewUsers texts settings model = [ h1 diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index cb60492d..0a2deed7 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -14,6 +14,7 @@ module Page.Home.Data exposing , SelectActionMode(..) , SelectViewModel , ViewMode(..) + , createQuery , doSearchCmd , editActive , init @@ -36,6 +37,7 @@ import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..)) import Comp.ItemMerge import Comp.LinkTarget exposing (LinkTarget) import Comp.PowerSearchInput +import Comp.PublishItems import Comp.SearchMenu import Data.Flags exposing (Flags) import Data.ItemNav exposing (ItemNav) @@ -79,18 +81,20 @@ type alias SelectViewModel = , confirmModal : Maybe ConfirmModalValue , editModel : Comp.ItemDetail.MultiEditMenu.Model , mergeModel : Comp.ItemMerge.Model + , publishModel : Comp.PublishItems.Model , saveNameState : SaveNameState , saveCustomFieldState : Set String } -initSelectViewModel : SelectViewModel -initSelectViewModel = +initSelectViewModel : Flags -> SelectViewModel +initSelectViewModel flags = { ids = Set.empty , action = NoneAction , confirmModal = Nothing , editModel = Comp.ItemDetail.MultiEditMenu.init , mergeModel = Comp.ItemMerge.init [] + , publishModel = Tuple.first (Comp.PublishItems.init flags) , saveNameState = SaveSuccess , saveCustomFieldState = Set.empty } @@ -100,6 +104,7 @@ type ViewMode = SimpleView | SearchView | SelectView SelectViewModel + | PublishView Comp.PublishItems.Model init : Flags -> ViewMode -> Model @@ -143,6 +148,9 @@ menuCollapsed model = SelectView _ -> False + PublishView _ -> + False + selectActive : Model -> Bool selectActive model = @@ -153,6 +161,9 @@ selectActive model = SearchView -> False + PublishView _ -> + False + SelectView _ -> True @@ -166,6 +177,9 @@ editActive model = SearchView -> False + PublishView _ -> + False + SelectView svm -> svm.action == EditSelected @@ -211,6 +225,10 @@ type Msg | RemoveItem String | MergeSelectedItems | MergeItemsMsg Comp.ItemMerge.Msg + | PublishSelectedItems + | PublishItemsMsg Comp.PublishItems.Msg + | TogglePublishCurrentQueryView + | PublishViewMsg Comp.PublishItems.Msg type SearchType @@ -225,6 +243,7 @@ type SelectActionMode | ReprocessSelected | RestoreSelected | MergeSelected + | PublishSelected type alias SearchParam = @@ -251,10 +270,7 @@ doSearchDefaultCmd param model = let smask = Q.request model.searchMenuModel.searchMode <| - Q.and - [ Comp.SearchMenu.getItemQuery model.searchMenuModel - , Maybe.map Q.Fragment model.powerSearchInput.input - ] + createQuery model mask = { smask @@ -272,6 +288,14 @@ doSearchDefaultCmd param model = Api.itemSearch param.flags mask ItemSearchAddResp +createQuery : Model -> Maybe Q.ItemQuery +createQuery model = + Q.and + [ Comp.SearchMenu.getItemQuery model.searchMenuModel + , Maybe.map Q.Fragment model.powerSearchInput.input + ] + + resultsBelowLimit : UiSettings -> Model -> Bool resultsBelowLimit settings model = let diff --git a/modules/webapp/src/main/elm/Page/Home/SideMenu.elm b/modules/webapp/src/main/elm/Page/Home/SideMenu.elm index f5690952..5ad19c47 100644 --- a/modules/webapp/src/main/elm/Page/Home/SideMenu.elm +++ b/modules/webapp/src/main/elm/Page/Home/SideMenu.elm @@ -82,10 +82,16 @@ viewSearch texts flags settings model = , end = [] , rootClasses = "my-1 text-xs hidden sm:flex" } - , Html.map SearchMenuMsg + , let + searchMenuCfg = + { overrideTabLook = \_ -> identity + } + in + Html.map SearchMenuMsg (Comp.SearchMenu.viewDrop2 texts.searchMenu model.dragDropData flags + searchMenuCfg settings model.searchMenuModel ) diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 2ba19438..27ddf0fd 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -19,6 +19,7 @@ import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..)) import Comp.ItemMerge import Comp.LinkTarget exposing (LinkTarget) import Comp.PowerSearchInput +import Comp.PublishItems import Comp.SearchMenu import Data.Flags exposing (Flags) import Data.ItemQuery as Q @@ -26,6 +27,7 @@ import Data.ItemSelection import Data.Items import Data.SearchMode exposing (SearchMode) import Data.UiSettings exposing (UiSettings) +import Messages.Page.Home exposing (Texts) import Page exposing (Page(..)) import Page.Home.Data exposing (..) import Process @@ -47,8 +49,8 @@ type alias UpdateResult = } -update : Maybe String -> Nav.Key -> Flags -> UiSettings -> Msg -> Model -> UpdateResult -update mId key flags settings msg model = +update : Maybe String -> Nav.Key -> Flags -> Texts -> UiSettings -> Msg -> Model -> UpdateResult +update mId key flags texts settings msg model = case msg of Init -> let @@ -62,7 +64,7 @@ update mId key flags settings msg model = in makeResult <| Util.Update.andThen3 - [ update mId key flags settings (SearchMenuMsg Comp.SearchMenu.Init) + [ update mId key flags texts settings (SearchMenuMsg Comp.SearchMenu.Init) , doSearch searchParam ] model @@ -72,7 +74,7 @@ update mId key flags settings msg model = nm = { model | searchOffset = 0, powerSearchInput = Comp.PowerSearchInput.init } in - update mId key flags settings (SearchMenuMsg Comp.SearchMenu.ResetForm) nm + update mId key flags texts settings (SearchMenuMsg Comp.SearchMenu.ResetForm) nm SearchMenuMsg m -> let @@ -118,7 +120,7 @@ update mId key flags settings msg model = SetLinkTarget lt -> case linkTargetMsg lt of Just m -> - update mId key flags settings m model + update mId key flags texts settings m model Nothing -> makeResult ( model, Cmd.none, Sub.none ) @@ -166,7 +168,7 @@ update mId key flags settings msg model = in makeResult <| Util.Update.andThen3 - [ update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.SetResults list)) + [ update mId key flags texts settings (ItemCardListMsg (Comp.ItemCardList.SetResults list)) , if scroll then scrollToCard mId @@ -188,7 +190,7 @@ update mId key flags settings msg model = , moreAvailable = list.groups /= [] } in - update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.AddResults list)) m + update mId key flags texts settings (ItemCardListMsg (Comp.ItemCardList.AddResults list)) m ItemSearchAddResp (Err _) -> withSub @@ -237,6 +239,9 @@ update mId key flags settings msg model = SelectView _ -> SimpleView + + PublishView q -> + PublishView q in withSub ( { model | viewMode = nextView } @@ -248,13 +253,16 @@ update mId key flags settings msg model = ( nextView, cmd ) = case model.viewMode of SimpleView -> - ( SelectView initSelectViewModel, loadEditModel flags ) + ( SelectView <| initSelectViewModel flags, loadEditModel flags ) SearchView -> - ( SelectView initSelectViewModel, loadEditModel flags ) + ( SelectView <| initSelectViewModel flags, loadEditModel flags ) SelectView _ -> ( SearchView, Cmd.none ) + + PublishView q -> + ( PublishView q, Cmd.none ) in withSub ( { model @@ -282,18 +290,18 @@ update mId key flags settings msg model = smMsg = SearchMenuMsg (Comp.SearchMenu.SetTextSearch str) in - update mId key flags settings smMsg model + update mId key flags texts settings smMsg model ToggleSearchType -> case model.searchTypeDropdownValue of BasicSearch -> - update mId key flags settings (SearchMenuMsg Comp.SearchMenu.SetFulltextSearch) model + update mId key flags texts settings (SearchMenuMsg Comp.SearchMenu.SetFulltextSearch) model ContentOnlySearch -> - update mId key flags settings (SearchMenuMsg Comp.SearchMenu.SetNamesSearch) model + update mId key flags texts settings (SearchMenuMsg Comp.SearchMenu.SetNamesSearch) model KeyUpSearchbarMsg (Just Enter) -> - update mId key flags settings (DoSearch model.searchTypeDropdownValue) model + update mId key flags texts settings (DoSearch model.searchTypeDropdownValue) model KeyUpSearchbarMsg _ -> withSub ( model, Cmd.none ) @@ -607,6 +615,7 @@ update mId key flags settings msg model = update mId key flags + texts settings (DoSearch model.searchTypeDropdownValue) model_ @@ -620,6 +629,86 @@ update mId key flags settings msg model = _ -> noSub ( model, Cmd.none ) + PublishSelectedItems -> + case model.viewMode of + SelectView svm -> + if svm.action == PublishSelected then + let + ( mm, mc ) = + Comp.PublishItems.init flags + in + noSub + ( { model + | viewMode = + SelectView + { svm + | action = NoneAction + , publishModel = mm + } + } + , Cmd.map PublishItemsMsg mc + ) + + else if svm.ids == Set.empty then + noSub ( model, Cmd.none ) + + else + let + ( mm, mc ) = + Comp.PublishItems.initQuery flags + (Q.ItemIdIn (Set.toList svm.ids)) + in + noSub + ( { model + | viewMode = + SelectView + { svm + | action = PublishSelected + , publishModel = mm + } + } + , Cmd.map PublishItemsMsg mc + ) + + _ -> + noSub ( model, Cmd.none ) + + PublishItemsMsg lmsg -> + case model.viewMode of + SelectView svm -> + let + result = + Comp.PublishItems.update texts.publishItems flags lmsg svm.publishModel + + nextView = + case result.outcome of + Comp.PublishItems.OutcomeDone -> + SelectView { svm | action = NoneAction } + + Comp.PublishItems.OutcomeInProgress -> + SelectView { svm | publishModel = result.model } + + model_ = + { model | viewMode = nextView } + in + if result.outcome == Comp.PublishItems.OutcomeDone then + update mId + key + flags + texts + settings + (DoSearch model.searchTypeDropdownValue) + model_ + + else + noSub + ( model_ + , Cmd.map PublishItemsMsg result.cmd + ) + + _ -> + noSub ( model, Cmd.none ) + EditMenuMsg lmsg -> case model.viewMode of SelectView svm -> @@ -723,7 +812,7 @@ update mId key flags settings msg model = model_ = { model | viewMode = viewMode } in - update mId key flags settings (DoSearch model.lastSearchType) model_ + update mId key flags texts settings (DoSearch model.lastSearchType) model_ SearchStatsResp result -> let @@ -733,7 +822,7 @@ update mId key flags settings msg model = stats = Result.withDefault model.searchStats result in - update mId key flags settings lm { model | searchStats = stats } + update mId key flags texts settings lm { model | searchStats = stats } TogglePreviewFullWidth -> let @@ -775,16 +864,48 @@ update mId key flags settings msg model = makeResult ( model_, cmd_, Sub.map PowerSearchMsg result.subs ) Comp.PowerSearchInput.SubmitSearch -> - update mId key flags settings (DoSearch model_.searchTypeDropdownValue) model_ + update mId key flags texts settings (DoSearch model_.searchTypeDropdownValue) model_ KeyUpPowerSearchbarMsg (Just Enter) -> - update mId key flags settings (DoSearch model.searchTypeDropdownValue) model + update mId key flags texts settings (DoSearch model.searchTypeDropdownValue) model KeyUpPowerSearchbarMsg _ -> withSub ( model, Cmd.none ) RemoveItem id -> - update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.RemoveItem id)) model + update mId key flags texts settings (ItemCardListMsg (Comp.ItemCardList.RemoveItem id)) model + + TogglePublishCurrentQueryView -> + case createQuery model of + Just q -> + let + ( pm, pc ) = + Comp.PublishItems.initQuery flags q + in + noSub ( { model | viewMode = PublishView pm }, Cmd.map PublishViewMsg pc ) + + Nothing -> + noSub ( model, Cmd.none ) + + PublishViewMsg lmsg -> + case model.viewMode of + PublishView inPM -> + let + result = + Comp.PublishItems.update texts.publishItems flags lmsg inPM + in + case result.outcome of + Comp.PublishItems.OutcomeInProgress -> + noSub + ( { model | viewMode = PublishView result.model } + , Cmd.map PublishViewMsg result.cmd + ) + + Comp.PublishItems.OutcomeDone -> + noSub ( { model | viewMode = SearchView }, Cmd.none ) + + _ -> + noSub ( model, Cmd.none ) @@ -917,33 +1038,7 @@ doSearch param model = linkTargetMsg : LinkTarget -> Maybe Msg linkTargetMsg linkTarget = - case linkTarget of - Comp.LinkTarget.LinkNone -> - Nothing - - Comp.LinkTarget.LinkCorrOrg id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetCorrOrg id) - - Comp.LinkTarget.LinkCorrPerson id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetCorrPerson id) - - Comp.LinkTarget.LinkConcPerson id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetConcPerson id) - - Comp.LinkTarget.LinkConcEquip id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetConcEquip id) - - Comp.LinkTarget.LinkFolder id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetFolder id) - - Comp.LinkTarget.LinkTag id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetTag id.id) - - Comp.LinkTarget.LinkCustomField id -> - Just <| SearchMenuMsg (Comp.SearchMenu.SetCustomField id) - - Comp.LinkTarget.LinkSource str -> - Just <| SearchMenuMsg (Comp.SearchMenu.ResetToSource str) + Maybe.map SearchMenuMsg (Comp.SearchMenu.linkTargetMsg linkTarget) doSearchMore : Flags -> UiSettings -> Model -> ( Model, Cmd Msg ) diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm index 40dd3b3c..e58f4132 100644 --- a/modules/webapp/src/main/elm/Page/Home/View2.elm +++ b/modules/webapp/src/main/elm/Page/Home/View2.elm @@ -7,15 +7,19 @@ module Page.Home.View2 exposing (viewContent, viewSidebar) +import Api import Comp.Basic as B import Comp.ConfirmModal import Comp.ItemCardList import Comp.ItemMerge import Comp.MenuBar as MB import Comp.PowerSearchInput +import Comp.PublishItems import Comp.SearchMenu import Comp.SearchStatsView import Data.Flags exposing (Flags) +import Data.Icons as Icons +import Data.ItemQuery as Q import Data.ItemSelection import Data.SearchMode import Data.UiSettings exposing (UiSettings) @@ -63,29 +67,52 @@ viewContent texts flags settings model = mainView : Texts -> Flags -> UiSettings -> Model -> List (Html Msg) mainView texts flags settings model = let - mergeView = + otherView = case model.viewMode of SelectView svm -> case svm.action of MergeSelected -> - Just svm + Just + [ div [ class "sm:relative mb-2" ] + (itemMergeView texts settings svm) + ] + + PublishSelected -> + Just + [ div [ class "sm:relative mb-2" ] + (itemPublishView texts settings flags svm) + ] _ -> Nothing - _ -> + PublishView pm -> + Just + [ div [ class "sm:relative mb-2" ] + (publishResults texts settings flags model pm) + ] + + SimpleView -> + Nothing + + SearchView -> Nothing in - case mergeView of - Just svm -> - [ div [ class "sm:relative mb-2" ] - (itemMergeView texts settings svm) - ] + case otherView of + Just body -> + body Nothing -> itemCardList texts flags settings model +itemPublishView : Texts -> UiSettings -> Flags -> SelectViewModel -> List (Html Msg) +itemPublishView texts settings flags svm = + [ Html.map PublishItemsMsg + (Comp.PublishItems.view texts.publishItems settings flags svm.publishModel) + ] + + itemMergeView : Texts -> UiSettings -> SelectViewModel -> List (Html Msg) itemMergeView texts settings svm = [ Html.map MergeItemsMsg @@ -93,6 +120,13 @@ itemMergeView texts settings svm = ] +publishResults : Texts -> UiSettings -> Flags -> Model -> Comp.PublishItems.Model -> List (Html Msg) +publishResults texts settings flags model pm = + [ Html.map PublishViewMsg + (Comp.PublishItems.view texts.publishItems settings flags pm) + ] + + confirmModal : Texts -> Model -> List (Html Msg) confirmModal texts model = let @@ -148,6 +182,9 @@ itemsBar texts flags settings model = SelectView svm -> [ editMenuBar texts model svm ] + PublishView query -> + [ defaultMenuBar texts flags settings model ] + defaultMenuBar : Texts -> Flags -> UiSettings -> Model -> Html Msg defaultMenuBar texts flags settings model = @@ -215,6 +252,25 @@ defaultMenuBar texts flags settings model = MB.view { end = [ MB.CustomElement <| + B.secondaryBasicButton + { label = "" + , icon = Icons.share + , disabled = createQuery model == Nothing + , handler = onClick TogglePublishCurrentQueryView + , attrs = + [ title <| + if createQuery model == Nothing then + texts.nothingSelectedToShare + + else + texts.publishCurrentQueryTitle + , classList + [ ( btnStyle, True ) + ] + , href "#" + ] + } + , MB.CustomElement <| B.secondaryBasicButton { label = "" , icon = @@ -332,6 +388,17 @@ editMenuBar texts model svm = , ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed ) ] } + , MB.CustomButton + { tagger = PublishSelectedItems + , label = "" + , icon = Just Icons.share + , title = texts.publishItemsTitle selectCount + , inputClass = + [ ( btnStyle, True ) + , ( "bg-gray-200 dark:bg-bluegray-600", svm.action == PublishSelected ) + , ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed ) + ] + } ] , end = [ MB.CustomButton @@ -393,32 +460,44 @@ searchStats texts _ settings model = itemCardList : Texts -> Flags -> UiSettings -> Model -> List (Html Msg) -itemCardList texts _ settings model = +itemCardList texts flags settings model = let + previewUrl attach = + Api.attachmentPreviewURL attach.id + + previewUrlFallback item = + Api.itemBasePreviewURL item.id + + viewCfg sel = + Comp.ItemCardList.ViewConfig + model.scrollToCard + sel + previewUrl + previewUrlFallback + (.id >> Api.fileURL) + (.id >> ItemDetailPage) + itemViewCfg = case model.viewMode of SelectView svm -> - Comp.ItemCardList.ViewConfig - model.scrollToCard - (Data.ItemSelection.Active svm.ids) + viewCfg (Data.ItemSelection.Active svm.ids) _ -> - Comp.ItemCardList.ViewConfig - model.scrollToCard - Data.ItemSelection.Inactive + viewCfg Data.ItemSelection.Inactive in [ Html.map ItemCardListMsg (Comp.ItemCardList.view2 texts.itemCardList itemViewCfg settings + flags model.itemListModel ) - , loadMore settings model + , loadMore texts settings model ] -loadMore : UiSettings -> Model -> Html Msg -loadMore settings model = +loadMore : Texts -> UiSettings -> Model -> Html Msg +loadMore texts settings model = let inactive = not model.moreAvailable || model.moreInProgress || model.searchInProgress @@ -430,10 +509,10 @@ loadMore settings model = [ B.secondaryBasicButton { label = if model.moreAvailable then - "Load more…" + texts.loadMore else - "That's all" + texts.thatsAll , icon = if model.moreInProgress then "fa fa-circle-notch animate-spin" diff --git a/modules/webapp/src/main/elm/Page/Share/Data.elm b/modules/webapp/src/main/elm/Page/Share/Data.elm new file mode 100644 index 00000000..bea33497 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Share/Data.elm @@ -0,0 +1,102 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.Share.Data exposing (Mode(..), Model, Msg(..), PageError(..), SearchBarMode(..), init, initCmd) + +import Api +import Api.Model.ItemLightList exposing (ItemLightList) +import Api.Model.SearchStats exposing (SearchStats) +import Api.Model.ShareSecret exposing (ShareSecret) +import Api.Model.ShareVerifyResult exposing (ShareVerifyResult) +import Comp.ItemCardList +import Comp.PowerSearchInput +import Comp.SearchMenu +import Comp.SharePasswordForm +import Data.Flags exposing (Flags) +import Http +import Util.Html exposing (KeyCode) + + +type Mode + = ModeInitial + | ModePassword + | ModeShare + + +type PageError + = PageErrorNone + | PageErrorHttp Http.Error + | PageErrorAuthFail + + +type SearchBarMode + = SearchBarNormal + | SearchBarContent + + +type alias Model = + { mode : Mode + , verifyResult : ShareVerifyResult + , passwordModel : Comp.SharePasswordForm.Model + , pageError : PageError + , searchMenuModel : Comp.SearchMenu.Model + , powerSearchInput : Comp.PowerSearchInput.Model + , searchInProgress : Bool + , itemListModel : Comp.ItemCardList.Model + , initialized : Bool + , contentSearch : Maybe String + , searchMode : SearchBarMode + } + + +emptyModel : Flags -> Model +emptyModel flags = + { mode = ModeInitial + , verifyResult = Api.Model.ShareVerifyResult.empty + , passwordModel = Comp.SharePasswordForm.init + , pageError = PageErrorNone + , searchMenuModel = Comp.SearchMenu.init flags + , powerSearchInput = Comp.PowerSearchInput.init + , searchInProgress = False + , itemListModel = Comp.ItemCardList.init + , initialized = False + , contentSearch = Nothing + , searchMode = SearchBarContent + } + + +init : Maybe String -> Flags -> ( Model, Cmd Msg ) +init shareId flags = + let + em = + emptyModel flags + in + case shareId of + Just id -> + ( { em | initialized = True }, Api.verifyShare flags (ShareSecret id Nothing) VerifyResp ) + + Nothing -> + ( em, Cmd.none ) + + +initCmd : String -> Flags -> Cmd Msg +initCmd shareId flags = + Api.verifyShare flags (ShareSecret shareId Nothing) VerifyResp + + +type Msg + = VerifyResp (Result Http.Error ShareVerifyResult) + | SearchResp (Result Http.Error ItemLightList) + | StatsResp (Result Http.Error SearchStats) + | PasswordMsg Comp.SharePasswordForm.Msg + | SearchMenuMsg Comp.SearchMenu.Msg + | PowerSearchMsg Comp.PowerSearchInput.Msg + | ResetSearch + | ItemListMsg Comp.ItemCardList.Msg + | ToggleSearchBar + | SetContentSearch String + | ContentSearchKey (Maybe KeyCode) diff --git a/modules/webapp/src/main/elm/Page/Share/Menubar.elm b/modules/webapp/src/main/elm/Page/Share/Menubar.elm new file mode 100644 index 00000000..1bfcc08c --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Share/Menubar.elm @@ -0,0 +1,116 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.Share.Menubar exposing (view) + +import Comp.Basic as B +import Comp.MenuBar as MB +import Comp.PowerSearchInput +import Comp.SearchMenu +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Messages.Page.Share exposing (Texts) +import Page.Share.Data exposing (Model, Msg(..), SearchBarMode(..)) +import Styles as S +import Util.Html + + +view : Texts -> Model -> Html Msg +view texts model = + let + btnStyle = + S.secondaryBasicButton ++ " text-sm" + + searchInput = + Comp.SearchMenu.textSearchString + model.searchMenuModel.textSearchModel + + powerSearchBar = + div [ class "flex-grow flex flex-col relative" ] + [ div + [ class "relative flex flex-grow flex-row" ] + [ Html.map PowerSearchMsg + (Comp.PowerSearchInput.viewInput + { placeholder = texts.powerSearchPlaceholder + , extraAttrs = [] + } + model.powerSearchInput + ) + , Html.map PowerSearchMsg + (Comp.PowerSearchInput.viewResult [] model.powerSearchInput) + ] + , div + [ class "opacity-60 text-xs -mt-1.5 absolute -bottom-4" + ] + [ text "Use an " + , a + [ href "https://docspell.org/docs/query/#structure" + , target "_new" + , class S.link + , class "mx-1" + ] + [ i [ class "fa fa-external-link-alt mr-1" ] [] + , text "extended search" + ] + , text " syntax." + ] + ] + + contentSearchBar = + div [ class "flex flex-grow" ] + [ input + [ type_ "text" + , class S.textInput + , class "text-sm" + , placeholder texts.fulltextPlaceholder + , onInput SetContentSearch + , value (Maybe.withDefault "" model.contentSearch) + , Util.Html.onKeyUpCode ContentSearchKey + ] + [] + ] + in + MB.view + { start = + [ MB.CustomElement <| + case model.searchMode of + SearchBarContent -> + contentSearchBar + + SearchBarNormal -> + powerSearchBar + , MB.CustomElement <| + B.secondaryBasicButton + { label = "" + , icon = "fa fa-search-plus" + , disabled = False + , handler = onClick ToggleSearchBar + , attrs = + [ href "#" + , title texts.extendedSearch + , classList [ ( "bg-gray-200 dark:bg-bluegray-600", model.searchMode == SearchBarNormal ) ] + ] + } + ] + , end = + [ MB.CustomElement <| + B.secondaryBasicButton + { label = "" + , icon = + if model.searchInProgress then + "fa fa-sync animate-spin" + + else + "fa fa-sync" + , disabled = model.searchInProgress + , handler = onClick ResetSearch + , attrs = [ href "#" ] + } + ] + , rootClasses = "mb-2 pt-1 dark:bg-bluegray-700 items-center text-sm" + } diff --git a/modules/webapp/src/main/elm/Page/Share/Results.elm b/modules/webapp/src/main/elm/Page/Share/Results.elm new file mode 100644 index 00000000..6785663c --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Share/Results.elm @@ -0,0 +1,37 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.Share.Results exposing (view) + +import Api +import Comp.ItemCardList +import Data.Flags exposing (Flags) +import Data.ItemSelection +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Page.Share exposing (Texts) +import Page exposing (Page(..)) +import Page.Share.Data exposing (Model, Msg(..)) + + +view : Texts -> UiSettings -> Flags -> String -> Model -> Html Msg +view texts settings flags shareId model = + let + viewCfg = + { current = Nothing + , selection = Data.ItemSelection.Inactive + , previewUrl = \attach -> Api.shareAttachmentPreviewURL attach.id + , previewUrlFallback = \item -> Api.shareItemBasePreviewURL item.id + , attachUrl = .id >> Api.shareFileURL + , detailPage = \item -> ShareDetailPage shareId item.id + } + in + div [] + [ Html.map ItemListMsg + (Comp.ItemCardList.view2 texts.itemCardList viewCfg settings flags model.itemListModel) + ] diff --git a/modules/webapp/src/main/elm/Page/Share/Sidebar.elm b/modules/webapp/src/main/elm/Page/Share/Sidebar.elm new file mode 100644 index 00000000..11c31457 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Share/Sidebar.elm @@ -0,0 +1,54 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.Share.Sidebar exposing (..) + +import Comp.SearchMenu +import Comp.Tabs +import Data.Flags exposing (Flags) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Page.Share exposing (Texts) +import Page.Share.Data exposing (Model, Msg(..)) +import Util.ItemDragDrop + + +view : Texts -> Flags -> UiSettings -> Model -> Html Msg +view texts flags settings model = + let + hideTrashTab tab default = + case tab of + Comp.SearchMenu.TabTrashed -> + Comp.Tabs.Hidden + + _ -> + default + + searchMenuCfg = + { overrideTabLook = hideTrashTab + } + in + div + [ class "flex flex-col" + ] + [ Html.map SearchMenuMsg + (Comp.SearchMenu.viewDrop2 texts.searchMenu + ddDummy + flags + searchMenuCfg + settings + model.searchMenuModel + ) + ] + + +ddDummy : Util.ItemDragDrop.DragDropData +ddDummy = + { model = Util.ItemDragDrop.init + , dropped = Nothing + } diff --git a/modules/webapp/src/main/elm/Page/Share/Update.elm b/modules/webapp/src/main/elm/Page/Share/Update.elm new file mode 100644 index 00000000..e7f6c852 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Share/Update.elm @@ -0,0 +1,239 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.Share.Update exposing (UpdateResult, update) + +import Api +import Api.Model.ItemQuery +import Comp.ItemCardList +import Comp.LinkTarget exposing (LinkTarget) +import Comp.PowerSearchInput +import Comp.SearchMenu +import Comp.SharePasswordForm +import Data.Flags exposing (Flags) +import Data.ItemQuery as Q +import Data.SearchMode +import Data.UiSettings exposing (UiSettings) +import Page.Share.Data exposing (..) +import Util.Html +import Util.Maybe +import Util.Update + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + } + + +update : Flags -> UiSettings -> String -> Msg -> Model -> UpdateResult +update flags settings shareId msg model = + case msg of + VerifyResp (Ok res) -> + if res.success then + let + eq = + Api.Model.ItemQuery.empty + + iq = + { eq | withDetails = Just True } + in + noSub + ( { model + | pageError = PageErrorNone + , mode = ModeShare + , verifyResult = res + , searchInProgress = True + } + , makeSearchCmd flags model + ) + + else if res.passwordRequired then + noSub + ( { model + | pageError = PageErrorNone + , mode = ModePassword + } + , Cmd.none + ) + + else + noSub + ( { model | pageError = PageErrorAuthFail } + , Cmd.none + ) + + VerifyResp (Err err) -> + noSub ( { model | pageError = PageErrorHttp err }, Cmd.none ) + + SearchResp (Ok list) -> + update flags + settings + shareId + (ItemListMsg (Comp.ItemCardList.SetResults list)) + { model | searchInProgress = False, pageError = PageErrorNone } + + SearchResp (Err err) -> + noSub ( { model | pageError = PageErrorHttp err, searchInProgress = False }, Cmd.none ) + + StatsResp (Ok stats) -> + update flags + settings + shareId + (SearchMenuMsg (Comp.SearchMenu.setFromStats stats)) + model + + StatsResp (Err err) -> + noSub ( { model | pageError = PageErrorHttp err }, Cmd.none ) + + PasswordMsg lmsg -> + let + ( m, c, res ) = + Comp.SharePasswordForm.update shareId flags lmsg model.passwordModel + in + case res of + Just verifyResult -> + update flags + settings + shareId + (VerifyResp (Ok verifyResult)) + model + + Nothing -> + noSub ( { model | passwordModel = m }, Cmd.map PasswordMsg c ) + + SearchMenuMsg lm -> + let + res = + Comp.SearchMenu.update flags settings lm model.searchMenuModel + + nextModel = + { model | searchMenuModel = res.model } + + ( initSearch, searchCmd ) = + if res.stateChange && not model.searchInProgress then + ( True, makeSearchCmd flags nextModel ) + + else + ( False, Cmd.none ) + in + noSub + ( { nextModel | searchInProgress = initSearch } + , Cmd.batch [ Cmd.map SearchMenuMsg res.cmd, searchCmd ] + ) + + PowerSearchMsg lm -> + let + res = + Comp.PowerSearchInput.update lm model.powerSearchInput + + nextModel = + { model | powerSearchInput = res.model } + + ( initSearch, searchCmd ) = + case res.action of + Comp.PowerSearchInput.NoAction -> + ( False, Cmd.none ) + + Comp.PowerSearchInput.SubmitSearch -> + ( True, makeSearchCmd flags nextModel ) + in + { model = { nextModel | searchInProgress = initSearch } + , cmd = Cmd.batch [ Cmd.map PowerSearchMsg res.cmd, searchCmd ] + , sub = Sub.map PowerSearchMsg res.subs + } + + ResetSearch -> + let + nm = + { model + | powerSearchInput = Comp.PowerSearchInput.init + , contentSearch = Nothing + , pageError = PageErrorNone + } + in + update flags settings shareId (SearchMenuMsg Comp.SearchMenu.ResetForm) nm + + ItemListMsg lm -> + let + ( im, ic, linkTarget ) = + Comp.ItemCardList.update flags lm model.itemListModel + + searchMsg = + Maybe.map Util.Update.cmdUnit (linkTargetMsg linkTarget) + |> Maybe.withDefault Cmd.none + in + noSub + ( { model | itemListModel = im } + , Cmd.batch [ Cmd.map ItemListMsg ic, searchMsg ] + ) + + ToggleSearchBar -> + noSub + ( { model + | searchMode = + case model.searchMode of + SearchBarContent -> + SearchBarNormal + + SearchBarNormal -> + SearchBarContent + } + , Cmd.none + ) + + SetContentSearch q -> + noSub ( { model | contentSearch = Util.Maybe.fromString q }, Cmd.none ) + + ContentSearchKey (Just Util.Html.Enter) -> + noSub ( model, makeSearchCmd flags model ) + + ContentSearchKey _ -> + noSub ( model, Cmd.none ) + + +noSub : ( Model, Cmd Msg ) -> UpdateResult +noSub ( m, c ) = + UpdateResult m c Sub.none + + +makeSearchCmd : Flags -> Model -> Cmd Msg +makeSearchCmd flags model = + let + xq = + Q.and + [ Comp.SearchMenu.getItemQuery model.searchMenuModel + , Maybe.map Q.Fragment <| + case model.searchMode of + SearchBarNormal -> + model.powerSearchInput.input + + SearchBarContent -> + Maybe.map (Q.Contents >> Q.render) model.contentSearch + ] + + request mq = + { offset = Nothing + , limit = Nothing + , withDetails = Just True + , query = Q.renderMaybe mq + , searchMode = Just (Data.SearchMode.asString Data.SearchMode.Normal) + } + + searchCmd = + Api.searchShare flags model.verifyResult.token (request xq) SearchResp + + statsCmd = + Api.searchShareStats flags model.verifyResult.token (request xq) StatsResp + in + Cmd.batch [ searchCmd, statsCmd ] + + +linkTargetMsg : LinkTarget -> Maybe Msg +linkTargetMsg linkTarget = + Maybe.map SearchMenuMsg (Comp.SearchMenu.linkTargetMsg linkTarget) diff --git a/modules/webapp/src/main/elm/Page/Share/View.elm b/modules/webapp/src/main/elm/Page/Share/View.elm new file mode 100644 index 00000000..924b4631 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Share/View.elm @@ -0,0 +1,115 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.Share.View exposing (viewContent, viewSidebar) + +import Api.Model.VersionInfo exposing (VersionInfo) +import Comp.Basic as B +import Comp.SharePasswordForm +import Data.Flags exposing (Flags) +import Data.Items +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput, onSubmit) +import Messages.Page.Share exposing (Texts) +import Page.Share.Data exposing (..) +import Page.Share.Menubar as Menubar +import Page.Share.Results as Results +import Page.Share.Sidebar as Sidebar +import Styles as S + + +viewSidebar : Texts -> Bool -> Flags -> UiSettings -> Model -> Html Msg +viewSidebar texts visible flags settings model = + div + [ id "sidebar" + , class S.sidebar + , class S.sidebarBg + , classList [ ( "hidden", not visible || model.mode /= ModeShare ) ] + ] + [ Sidebar.view texts flags settings model + ] + + +viewContent : Texts -> Flags -> VersionInfo -> UiSettings -> String -> Model -> Html Msg +viewContent texts flags versionInfo uiSettings shareId model = + case model.mode of + ModeInitial -> + div + [ id "content" + , class "h-full w-full flex flex-col" + , class S.content + ] + [ div [ class " text-5xl" ] + [ B.loadingDimmer + { active = model.pageError == PageErrorNone + , label = "" + } + ] + , div [ class "my-4 text-lg" ] + [ errorMessage texts model + ] + ] + + ModePassword -> + passwordContent texts flags versionInfo model + + ModeShare -> + mainContent texts flags uiSettings shareId model + + + +--- Helpers + + +mainContent : Texts -> Flags -> UiSettings -> String -> Model -> Html Msg +mainContent texts flags settings shareId model = + div + [ id "content" + , class "h-full flex flex-col" + , class S.content + ] + [ h1 + [ class S.header1 + , classList [ ( "hidden", model.verifyResult.name == Nothing ) ] + ] + [ text <| Maybe.withDefault "" model.verifyResult.name + ] + , Menubar.view texts model + , errorMessage texts model + , Results.view texts settings flags shareId model + ] + + +errorMessage : Texts -> Model -> Html Msg +errorMessage texts model = + case model.pageError of + PageErrorNone -> + span [ class "hidden" ] [] + + PageErrorAuthFail -> + div [ class S.errorMessage ] + [ text texts.authFailed + ] + + PageErrorHttp err -> + div [ class S.errorMessage ] + [ text (texts.httpError err) + ] + + +passwordContent : Texts -> Flags -> VersionInfo -> Model -> Html Msg +passwordContent texts flags versionInfo model = + div + [ id "content" + , class "h-full flex flex-col items-center justify-center w-full" + , class S.content + ] + [ Html.map PasswordMsg + (Comp.SharePasswordForm.view texts.passwordForm flags versionInfo model.passwordModel) + ] diff --git a/modules/webapp/src/main/elm/Page/ShareDetail/Data.elm b/modules/webapp/src/main/elm/Page/ShareDetail/Data.elm new file mode 100644 index 00000000..2d6e1b3a --- /dev/null +++ b/modules/webapp/src/main/elm/Page/ShareDetail/Data.elm @@ -0,0 +1,73 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.ShareDetail.Data exposing (Model, Msg(..), PageError(..), ViewMode(..), init) + +import Api +import Api.Model.ItemDetail exposing (ItemDetail) +import Api.Model.ShareSecret exposing (ShareSecret) +import Api.Model.ShareVerifyResult exposing (ShareVerifyResult) +import Comp.SharePasswordForm +import Comp.UrlCopy +import Data.Flags exposing (Flags) +import Http + + +type ViewMode + = ViewNormal + | ViewPassword + | ViewLoading + + +type PageError + = PageErrorNone + | PageErrorHttp Http.Error + | PageErrorAuthFail + + +type alias Model = + { item : ItemDetail + , verifyResult : ShareVerifyResult + , passwordModel : Comp.SharePasswordForm.Model + , viewMode : ViewMode + , pageError : PageError + , attachMenuOpen : Bool + , visibleAttach : Int + } + + +type Msg + = VerifyResp (Result Http.Error ShareVerifyResult) + | GetItemResp (Result Http.Error ItemDetail) + | PasswordMsg Comp.SharePasswordForm.Msg + | SelectActiveAttachment Int + | ToggleSelectAttach + | UrlCopyMsg Comp.UrlCopy.Msg + + +emptyModel : ViewMode -> Model +emptyModel vm = + { item = Api.Model.ItemDetail.empty + , verifyResult = Api.Model.ShareVerifyResult.empty + , passwordModel = Comp.SharePasswordForm.init + , viewMode = vm + , pageError = PageErrorNone + , attachMenuOpen = False + , visibleAttach = 0 + } + + +init : Maybe ( String, String ) -> Flags -> ( Model, Cmd Msg ) +init mids flags = + case mids of + Just ( shareId, itemId ) -> + ( emptyModel ViewLoading + , Api.verifyShare flags (ShareSecret shareId Nothing) VerifyResp + ) + + Nothing -> + ( emptyModel ViewLoading, Cmd.none ) diff --git a/modules/webapp/src/main/elm/Page/ShareDetail/Update.elm b/modules/webapp/src/main/elm/Page/ShareDetail/Update.elm new file mode 100644 index 00000000..efa880d3 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/ShareDetail/Update.elm @@ -0,0 +1,95 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.ShareDetail.Update exposing (update) + +import Api +import Comp.SharePasswordForm +import Comp.UrlCopy +import Data.Flags exposing (Flags) +import Page exposing (Page(..)) +import Page.ShareDetail.Data exposing (..) + + +update : String -> String -> Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update shareId itemId flags msg model = + case msg of + VerifyResp (Ok res) -> + if res.success then + ( { model + | pageError = PageErrorNone + , viewMode = ViewLoading + , verifyResult = res + } + , Api.itemDetailShare flags res.token itemId GetItemResp + ) + + else if res.passwordRequired then + ( { model + | pageError = PageErrorNone + , viewMode = ViewPassword + } + , Cmd.none + ) + + else + ( { model | pageError = PageErrorAuthFail } + , Cmd.none + ) + + VerifyResp (Err err) -> + ( { model | pageError = PageErrorHttp err }, Cmd.none ) + + GetItemResp (Ok item) -> + let + url = + Page.pageToString (ShareDetailPage shareId itemId) + in + ( { model + | item = item + , viewMode = ViewNormal + , pageError = PageErrorNone + } + , Comp.UrlCopy.initCopy url + ) + + GetItemResp (Err err) -> + ( { model | viewMode = ViewNormal, pageError = PageErrorHttp err }, Cmd.none ) + + PasswordMsg lmsg -> + let + ( m, c, res ) = + Comp.SharePasswordForm.update shareId flags lmsg model.passwordModel + in + case res of + Just verifyResult -> + update shareId + itemId + flags + (VerifyResp (Ok verifyResult)) + model + + Nothing -> + ( { model | passwordModel = m }, Cmd.map PasswordMsg c ) + + SelectActiveAttachment pos -> + ( { model + | visibleAttach = pos + , attachMenuOpen = False + } + , Cmd.none + ) + + ToggleSelectAttach -> + ( { model | attachMenuOpen = not model.attachMenuOpen }, Cmd.none ) + + UrlCopyMsg lm -> + let + cmd = + Comp.UrlCopy.update lm + in + ( model, cmd ) diff --git a/modules/webapp/src/main/elm/Page/ShareDetail/View.elm b/modules/webapp/src/main/elm/Page/ShareDetail/View.elm new file mode 100644 index 00000000..3ef1f3ea --- /dev/null +++ b/modules/webapp/src/main/elm/Page/ShareDetail/View.elm @@ -0,0 +1,381 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Page.ShareDetail.View exposing (viewContent, viewSidebar) + +import Api +import Api.Model.Attachment exposing (Attachment) +import Api.Model.VersionInfo exposing (VersionInfo) +import Comp.Basic as B +import Comp.SharePasswordForm +import Comp.UrlCopy +import Data.Fields +import Data.Flags exposing (Flags) +import Data.Icons as Icons +import Data.ItemTemplate as IT exposing (ItemTemplate) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Messages.Page.ShareDetail exposing (Texts) +import Page exposing (Page(..)) +import Page.ShareDetail.Data exposing (..) +import Styles as S +import Util.CustomField +import Util.Item +import Util.List +import Util.Size +import Util.String + + +viewSidebar : Texts -> Bool -> Flags -> UiSettings -> String -> String -> Model -> Html Msg +viewSidebar texts visible flags settings shareId itemId model = + div + [ id "sidebar" + , classList [ ( "hidden", not visible ) ] + , class S.sidebar + ] + [ div [ class "pt-2" ] + [ itemData texts flags model shareId itemId + ] + ] + + +viewContent : Texts -> Flags -> UiSettings -> VersionInfo -> String -> String -> Model -> Html Msg +viewContent texts flags uiSettings versionInfo shareId itemId model = + case model.viewMode of + ViewLoading -> + div + [ id "content" + , class "h-full w-full flex flex-col text-5xl" + , class S.content + ] + [ div [ class "text-5xl" ] + [ B.loadingDimmer + { active = model.pageError == PageErrorNone + , label = "" + } + ] + , div [ class "my-4 text-lg" ] + [ errorMessage texts model + ] + ] + + ViewPassword -> + passwordContent texts flags versionInfo model + + ViewNormal -> + mainContent texts flags uiSettings shareId model + + + +--- Helper + + +mainContent : Texts -> Flags -> UiSettings -> String -> Model -> Html Msg +mainContent texts flags settings shareId model = + div + [ class "flex flex-col" + , class S.content + ] + [ itemHead texts shareId model + , errorMessage texts model + , div [ class "relative h-full" ] + [ itemPreview texts flags settings model + ] + ] + + +itemData : Texts -> Flags -> Model -> String -> String -> Html Msg +itemData texts flags model shareId itemId = + let + boxStyle = + "mb-4 sm:mb-6" + + headerStyle = + "py-2 bg-blue-50 hover:bg-blue-100 dark:bg-bluegray-700 dark:hover:bg-opacity-100 dark:hover:bg-bluegray-600 text-lg font-medium rounded-lg" + + showTag tag = + div + [ class "flex ml-2 mt-1 font-semibold hover:opacity-75" + , class S.basicLabel + ] + [ i [ class "fa fa-tag mr-2" ] [] + , text tag.name + ] + + showField = + Util.CustomField.renderValue2 + [ ( S.basicLabel, True ) + , ( "flex ml-2 mt-1 font-semibold hover:opacity-75", True ) + ] + Nothing + in + div [ class "flex flex-col" ] + [ div [ class boxStyle ] + [ div [ class headerStyle ] + [ Icons.dateIcon2 "mr-2 ml-2" + , text (texts.field Data.Fields.Date) + ] + , div [ class "text-lg ml-2" ] + [ Util.Item.toItemLight model.item + |> IT.render IT.dateLong (templateCtx texts) + |> text + ] + ] + , div + [ class boxStyle + , classList [ ( "hidden", model.item.dueDate == Nothing ) ] + ] + [ div [ class headerStyle ] + [ Icons.dueDateIcon2 "mr-2 ml-2" + , text (texts.field Data.Fields.DueDate) + ] + , div [ class "text-lg ml-2" ] + [ Util.Item.toItemLight model.item + |> IT.render IT.dueDateLong (templateCtx texts) + |> text + ] + ] + , div [ class boxStyle ] + [ div [ class headerStyle ] + [ Icons.tagsIcon2 "mr-2 ml-2" + , text texts.tagsAndFields + ] + , div [ class "flex flex-row items-center flex-wrap font-medium my-1" ] + (List.map showTag model.item.tags ++ List.map showField model.item.customfields) + ] + , div [ class boxStyle ] + [ div [ class headerStyle ] + [ Icons.correspondentIcon2 "mr-2 ml-2" + , text texts.basics.correspondent + ] + , div [ class "text-lg ml-2" ] + [ Util.Item.toItemLight model.item + |> IT.render IT.correspondent (templateCtx texts) + |> text + ] + ] + , div [ class boxStyle ] + [ div [ class headerStyle ] + [ Icons.concernedIcon2 "mr-2 ml-2" + , text texts.basics.concerning + ] + , div [ class "text-lg ml-2" ] + [ Util.Item.toItemLight model.item + |> IT.render IT.concerning (templateCtx texts) + |> text + ] + ] + , div [ class boxStyle ] + [ div [ class headerStyle ] + [ i [ class "fa fa-copy mr-2 ml-2" ] [] + , text "Copy URL" + ] + , div [ class "flex flex-col items-center py-2" ] + [ Html.map UrlCopyMsg + (Comp.UrlCopy.view + (flags.config.baseUrl + ++ Page.pageToString + (ShareDetailPage shareId itemId) + ) + ) + ] + ] + ] + + +itemPreview : Texts -> Flags -> UiSettings -> Model -> Html Msg +itemPreview texts flags settings model = + let + attach = + Util.List.get model.item.attachments model.visibleAttach + |> Maybe.withDefault Api.Model.Attachment.empty + + attachName = + Maybe.withDefault (texts.noName ++ ".pdf") attach.name + in + div + [ class "flex flex-grow flex-col h-full border-t dark:border-bluegray-600" + ] + [ div [ class "flex flex-col sm:flex-row items-center py-1 px-1 border-l border-r dark:border-bluegray-600" ] + [ div [ class "text-base font-bold flex-grow w-full text-center sm:text-left break-all" ] + [ text attachName + , text " (" + , text (Util.Size.bytesReadable Util.Size.B (toFloat attach.size)) + , text ")" + ] + , div [ class "flex flex-row space-x-2" ] + [ B.secondaryBasicButton + { label = "" + , icon = "fa fa-eye" + , disabled = False + , handler = href (Api.shareFileURL attach.id) + , attrs = + [ target "_new" + ] + } + , B.secondaryBasicButton + { label = "" + , icon = "fa fa-download" + , disabled = False + , handler = href (Api.shareFileURL attach.id) + , attrs = + [ download attachName + ] + } + , B.secondaryBasicButton + { label = "" + , icon = "fa fa-ellipsis-v" + , disabled = False + , handler = onClick ToggleSelectAttach + , attrs = + [ href "#" + , classList [ ( "hidden", List.length model.item.attachments <= 1 ) ] + ] + } + ] + ] + , attachmentSelect texts model + , div + [ class "flex w-full h-full mb-4 border-b border-l border-r dark:border-bluegray-600" + , style "min-height" "500px" + ] + [ embed + [ src (Data.UiSettings.pdfUrl settings flags (Api.shareFileURL attach.id)) + , class " h-full w-full mx-0 py-0" + ] + [] + ] + ] + + +itemHead : Texts -> String -> Model -> Html Msg +itemHead texts shareId model = + div [ class "flex flex-col sm:flex-row mt-1" ] + [ div [ class "flex flex-grow items-center" ] + [ h1 + [ class S.header1 + , class "items-center flex flex-row" + ] + [ text model.item.name + , span + [ classList [ ( "hidden", model.item.state /= "created" ) ] + , class S.blueBasicLabel + , class "inline ml-4 text-sm" + ] + [ text texts.unconfirmed + ] + ] + ] + , div [ class "flex flex-row items-center justify-end mb-2 sm:mb-0" ] + [ B.secondaryBasicButton + { label = texts.basics.back + , icon = "fa fa-times" + , disabled = False + , handler = Page.href (SharePage shareId) + , attrs = [] + } + ] + ] + + +passwordContent : Texts -> Flags -> VersionInfo -> Model -> Html Msg +passwordContent texts flags versionInfo model = + div + [ id "content" + , class "h-full flex flex-col items-center justify-center w-full" + , class S.content + ] + [ Html.map PasswordMsg + (Comp.SharePasswordForm.view texts.passwordForm flags versionInfo model.passwordModel) + ] + + +attachmentSelect : Texts -> Model -> Html Msg +attachmentSelect texts model = + div + [ class "flex flex-row border-l border-t border-r px-2 py-2 dark:border-bluegray-600 " + , class "overflow-x-auto overflow-y-none" + , classList + [ ( "hidden", not model.attachMenuOpen ) + ] + ] + (List.indexedMap (menuItem texts model) model.item.attachments) + + +menuItem : Texts -> Model -> Int -> Attachment -> Html Msg +menuItem texts model pos attach = + let + iconClass = + "fa fa-circle ml-1" + + visible = + model.visibleAttach == pos + in + a + [ classList <| + [ ( "border-blue-500 dark:border-lightblue-500", pos == 0 ) + , ( "dark:border-bluegray-600", pos /= 0 ) + ] + , class "flex flex-col relative border rounded px-1 py-1 mr-2" + , class " hover:shadow dark:hover:border-bluegray-500" + , href "#" + , onClick (SelectActiveAttachment pos) + ] + [ div + [ classList + [ ( "hidden", not visible ) + ] + , class "absolute right-1 top-1 text-blue-400 dark:text-lightblue-400 text-xl" + ] + [ i [ class iconClass ] [] + ] + , div [ class "flex-grow" ] + [ img + [ src (Api.shareAttachmentPreviewURL attach.id) + , class "block w-20 mx-auto" + ] + [] + ] + , div [ class "mt-1 text-sm break-all w-28 text-center" ] + [ Maybe.map (Util.String.ellipsis 36) attach.name + |> Maybe.withDefault texts.noName + |> text + ] + ] + + +errorMessage : Texts -> Model -> Html Msg +errorMessage texts model = + case model.pageError of + PageErrorNone -> + span [ class "hidden" ] [] + + PageErrorAuthFail -> + div + [ class S.errorMessage + , class "my-4" + ] + [ text texts.authFailed + ] + + PageErrorHttp err -> + div + [ class S.errorMessage + , class "my-4" + ] + [ text (texts.httpError err) + ] + + +templateCtx : Texts -> IT.TemplateContext +templateCtx texts = + { dateFormatLong = texts.formatDateLong + , dateFormatShort = texts.formatDateShort + , directionLabel = \_ -> "" + } diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index c7686ffa..645d2324 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -10,7 +10,7 @@ module Styles exposing (..) sidebar : String sidebar = - " flex flex-col flex-none md:w-80 w-full min-h-max px-2 dark:text-gray-200 shadow overflow-y-auto h-full transition-opacity transition-duration-200 scrollbar-thin scrollbar-light-sidebar dark:scrollbar-dark-sidebar" + " flex flex-col flex-none md:w-80 w-full min-h-max px-2 dark:text-gray-200 overflow-y-auto h-full transition-opacity transition-duration-200 scrollbar-thin scrollbar-light-sidebar dark:scrollbar-dark-sidebar" sidebarBg : String @@ -48,6 +48,11 @@ errorMessage = " border border-red-600 bg-red-50 text-red-600 dark:border-orange-800 dark:bg-orange-300 dark:text-orange-800 px-2 py-2 rounded " +errorText : String +errorText = + " text-red-600 dark:text-orange-800 " + + warnMessage : String warnMessage = warnMessageColors ++ " border dark:bg-opacity-25 px-2 py-2 rounded " @@ -95,6 +100,11 @@ basicLabel = " label border-gray-600 text-gray-600 dark:border-bluegray-300 dark:text-bluegray-300 " +blueBasicLabel : String +blueBasicLabel = + " label border-blue-500 text-blue-500 dark:border-lightblue-200 dark:text-lightblue-200 " + + --- Primary Button @@ -320,7 +330,7 @@ border2 = header1 : String header1 = - " text-3xl mt-3 mb-5 font-semibold tracking-wide break-all" + " text-3xl mt-3 mb-3 sm:mb-5 font-semibold tracking-wide break-all" header2 : String diff --git a/modules/webapp/src/main/elm/Util/CustomField.elm b/modules/webapp/src/main/elm/Util/CustomField.elm index fc121f62..cfe58d92 100644 --- a/modules/webapp/src/main/elm/Util/CustomField.elm +++ b/modules/webapp/src/main/elm/Util/CustomField.elm @@ -10,9 +10,12 @@ module Util.CustomField exposing , nameOrLabel , renderValue , renderValue2 + , statsToFields ) +import Api.Model.CustomField exposing (CustomField) import Api.Model.ItemFieldValue exposing (ItemFieldValue) +import Api.Model.SearchStats exposing (SearchStats) import Data.CustomFieldType import Data.Icons as Icons import Html exposing (..) @@ -20,6 +23,15 @@ import Html.Attributes exposing (..) import Html.Events exposing (onClick) +statsToFields : SearchStats -> List CustomField +statsToFields stats = + let + mkField fs = + CustomField fs.id fs.name fs.label fs.ftype fs.count 0 + in + List.map mkField stats.fieldStats + + {-| This is how the server wants the value to a bool custom field -} boolValue : Bool -> String diff --git a/modules/webapp/src/main/elm/Util/Http.elm b/modules/webapp/src/main/elm/Util/Http.elm index 550cbd7a..5088f892 100644 --- a/modules/webapp/src/main/elm/Util/Http.elm +++ b/modules/webapp/src/main/elm/Util/Http.elm @@ -14,6 +14,8 @@ module Util.Http exposing , authTask , executeIn , jsonResolver + , shareGet + , sharePost ) import Api.Model.AuthResult exposing (AuthResult) @@ -49,6 +51,28 @@ authReq req = } +shareReq : + { url : String + , token : String + , method : String + , headers : List Http.Header + , body : Http.Body + , expect : Http.Expect msg + , tracker : Maybe String + } + -> Cmd msg +shareReq req = + Http.request + { url = req.url + , method = req.method + , headers = Http.header "Docspell-Share-Auth" req.token :: req.headers + , expect = req.expect + , body = req.body + , timeout = Nothing + , tracker = req.tracker + } + + authPost : { url : String , account : AuthResult @@ -68,6 +92,25 @@ authPost req = } +sharePost : + { url : String + , token : String + , body : Http.Body + , expect : Http.Expect msg + } + -> Cmd msg +sharePost req = + shareReq + { url = req.url + , token = req.token + , body = req.body + , expect = req.expect + , method = "POST" + , headers = [] + , tracker = Nothing + } + + authPostTrack : { url : String , account : AuthResult @@ -125,6 +168,24 @@ authGet req = } +shareGet : + { url : String + , token : String + , expect : Http.Expect msg + } + -> Cmd msg +shareGet req = + shareReq + { url = req.url + , token = req.token + , body = Http.emptyBody + , expect = req.expect + , method = "GET" + , headers = [] + , tracker = Nothing + } + + authDelete : { url : String , account : AuthResult diff --git a/modules/webapp/src/main/elm/Util/Item.elm b/modules/webapp/src/main/elm/Util/Item.elm index c14a3b8d..3a8c8ea3 100644 --- a/modules/webapp/src/main/elm/Util/Item.elm +++ b/modules/webapp/src/main/elm/Util/Item.elm @@ -8,14 +8,49 @@ module Util.Item exposing ( concTemplate , corrTemplate + , toItemLight ) +import Api.Model.Attachment exposing (Attachment) +import Api.Model.AttachmentLight exposing (AttachmentLight) +import Api.Model.ItemDetail exposing (ItemDetail) import Api.Model.ItemLight exposing (ItemLight) import Data.Fields import Data.ItemTemplate as IT exposing (ItemTemplate) import Data.UiSettings exposing (UiSettings) +toItemLight : ItemDetail -> ItemLight +toItemLight detail = + { id = detail.id + , name = detail.name + , state = detail.state + , date = Maybe.withDefault detail.created detail.itemDate + , dueDate = detail.dueDate + , source = detail.source + , direction = Just detail.direction + , corrOrg = detail.corrOrg + , corrPerson = detail.corrPerson + , concPerson = detail.concPerson + , concEquipment = detail.concEquipment + , folder = detail.folder + , attachments = List.indexedMap toAttachmentLight detail.attachments + , tags = detail.tags + , customfields = detail.customfields + , notes = detail.notes + , highlighting = [] + } + + +toAttachmentLight : Int -> Attachment -> AttachmentLight +toAttachmentLight index attach = + { id = attach.id + , position = index + , name = attach.name + , pageCount = Nothing + } + + corrTemplate : UiSettings -> ItemTemplate corrTemplate settings = let diff --git a/modules/webapp/src/main/styles/index.css b/modules/webapp/src/main/styles/index.css index 274ad20a..aecfb8fb 100644 --- a/modules/webapp/src/main/styles/index.css +++ b/modules/webapp/src/main/styles/index.css @@ -18,7 +18,14 @@ } .elm-datepicker--input { - @apply pl-10 placeholder-gray-400 bg-blue-50 dark:text-bluegray-200 dark:bg-bluegray-700 dark:border-bluegray-500 border-gray-500 rounded w-full; + @apply pl-10 rounded w-full placeholder-gray-400 dark:text-bluegray-200 dark:border-bluegray-500; + } + #sidebar .elm-datepicker--input { + @apply dark:bg-bluegray-700 border-gray-500 bg-blue-50; + } + + #content .elm-datepicker--input { + @apply dark:bg-bluegray-800 border-gray-400; } .elm-datepicker--container { diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js index a5518163..ef4ef03c 100644 --- a/modules/webapp/src/main/webjar/docspell.js +++ b/modules/webapp/src/main/webjar/docspell.js @@ -127,8 +127,10 @@ elmApp.ports.printElement.subscribe(function(id) { w.document.write(''); } w.document.write(''); + w.document.write(''); + w.document.write(''); w.document.write(''); } } diff --git a/modules/webapp/tailwind.config.js b/modules/webapp/tailwind.config.js index 8dcb72c4..3578b787 100644 --- a/modules/webapp/tailwind.config.js +++ b/modules/webapp/tailwind.config.js @@ -19,7 +19,7 @@ module.exports = { orange: colors.orange, teal: colors.teal, lime: colors.lime, - lightblue: colors.lightBlue + lightblue: colors.sky } } }, diff --git a/nix/release.nix b/nix/release.nix index 3deef0dd..bdd8eb19 100644 --- a/nix/release.nix +++ b/nix/release.nix @@ -1,5 +1,16 @@ rec { cfg = { + v0_27_0 = rec { + version = "0.27.0"; + server = { + url = "https://github.com/eikek/docspell/releases/download/v${version}/docspell-restserver-${version}.zip"; + sha256 = "0rarzdmrrifncmrr7mdq62n2l6aa1zzwbh45xbbr64xnx86ld415"; + }; + joex = { + url = "https://github.com/eikek/docspell/releases/download/v${version}/docspell-joex-${version}.zip"; + sha256 = "0chzjaqb0mdpfr5qg9frm08fx8nkmsc0kjnjxyd00kzjq17i7r07"; + }; + }; v0_26_0 = rec { version = "0.26.0"; server = { @@ -118,7 +129,7 @@ rec { }; }; pkg = v: import ./pkg.nix v; - currentPkg = pkg cfg.v0_26_0; + currentPkg = pkg cfg.v0_27_0; module-joex = ./module-joex.nix; module-restserver = ./module-server.nix; modules = [ module-joex diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a9748e69..c53bcc6c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -15,15 +15,15 @@ object Dependencies { val DoobieVersion = "1.0.0-RC1" val EmilVersion = "0.10.0-M3" val FlexmarkVersion = "0.62.2" - val FlywayVersion = "7.15.0" - val Fs2Version = "3.1.2" + val FlywayVersion = "8.0.2" + val Fs2Version = "3.1.6" val Fs2CronVersion = "0.7.1" val H2Version = "1.4.200" - val Http4sVersion = "0.23.4" + val Http4sVersion = "0.23.6" val Icu4jVersion = "69.1" val javaOtpVersion = "0.3.0" - val JsoupVersion = "1.14.2" - val JwtScalaVersion = "9.0.1" + val JsoupVersion = "1.14.3" + val JwtScalaVersion = "9.0.2" val KindProjectorVersion = "0.10.3" val KittensVersion = "2.3.2" val LevigoJbig2Version = "2.0" @@ -31,20 +31,20 @@ object Dependencies { val LogbackVersion = "1.2.6" val MariaDbVersion = "2.7.4" val MUnitVersion = "0.7.29" - val MUnitCatsEffectVersion = "1.0.5" + val MUnitCatsEffectVersion = "1.0.6" val OrganizeImportsVersion = "0.5.0" val PdfboxVersion = "2.0.24" val PoiVersion = "4.1.2" - val PostgresVersion = "42.2.23" - val PureConfigVersion = "0.16.0" + val PostgresVersion = "42.3.0" + val PureConfigVersion = "0.17.0" val ScalaJavaTimeVersion = "2.3.0" - val ScodecBitsVersion = "1.1.28" + val ScodecBitsVersion = "1.1.29" val Slf4jVersion = "1.7.32" val StanfordNlpVersion = "4.2.2" val TikaVersion = "2.1.0" val YamuscaVersion = "0.8.1" - val SwaggerUIVersion = "3.52.1" - val TestContainerVersion = "0.39.8" + val SwaggerUIVersion = "3.52.5" + val TestContainerVersion = "0.39.9" val TwelveMonkeysVersion = "3.7.0" val JQueryVersion = "3.5.1" val ViewerJSVersion = "0.5.9" diff --git a/project/EnvConfig.scala b/project/EnvConfig.scala new file mode 100644 index 00000000..ea040adb --- /dev/null +++ b/project/EnvConfig.scala @@ -0,0 +1,75 @@ +import sbt._ +import com.typesafe.config._ + +import scala.annotation.tailrec +import scala.jdk.CollectionConverters._ +import java.util.{Map => JMap} + +object EnvConfig { + def serializeTo(cfg: Config, out: File): Unit = + IO.write(out, serialize(cfg)) + + def serialize(cfg: Config): String = { + val buffer = new StringBuilder + buffer.append("#### Server Configuration ####\n") + for ( + entry <- cfg.entrySet().asScala.toList.sortBy(_.getKey) + if isValidKey("docspell.server", entry) + ) append(buffer, entry.getKey, entry.getValue) + + buffer.append("\n#### JOEX Configuration ####\n") + for ( + entry <- cfg.entrySet().asScala.toList.sortBy(_.getKey) + if isValidKey("docspell.joex", entry) + ) append(buffer, entry.getKey, entry.getValue) + + buffer.toString().trim + } + + private def append(buffer: StringBuilder, key: String, value: ConfigValue): Unit = { + if (value.origin().comments().asScala.nonEmpty) { + buffer.append("\n") + } + value + .origin() + .comments() + .forEach(c => buffer.append("# ").append(c).append("\n")) + buffer.append(keyToEnv(key)).append("=").append(value.render()).append("\n") + } + + def isValidKey(prefix: String, entry: JMap.Entry[String, ConfigValue]): Boolean = + entry.getKey + .startsWith(prefix) && entry.getValue.valueType() != ConfigValueType.LIST + + def makeConfig(files: List[File]): Config = + files + .foldLeft(ConfigFactory.empty()) { (cfg, file) => + val cf = ConfigFactory.parseFile(file) + cfg.withFallback(cf) + } + .withFallback(ConfigFactory.defaultOverrides(getClass.getClassLoader)) + .resolve() + + def makeConfig(file: File, files: File*): Config = + makeConfig(file :: files.toList) + + def keyToEnv(k: String): String = { + val buffer = new StringBuilder() + val len = k.length + + @tailrec + def go(current: Int): String = + if (current >= len) buffer.toString() + else { + k.charAt(current) match { + case '.' => buffer.append("_") + case '-' => buffer.append("__") + case '_' => buffer.append("___") + case c => buffer.append(c.toUpper) + } + go(current + 1) + } + + go(0) + } +} diff --git a/project/StylesPlugin.scala b/project/StylesPlugin.scala index 5bb1b507..d83ff2e2 100644 --- a/project/StylesPlugin.scala +++ b/project/StylesPlugin.scala @@ -55,7 +55,7 @@ object StylesPlugin extends AutoPlugin { val files = postCss(npx, inDir, outDir, wd, mode, logger) ++ copyWebfonts(wd, outDir, logger) ++ copyFlags(wd, outDir, logger) - logger.info("Styles built") + logger.info(s"Styles built at $outDir") files }, stylesInstall := { @@ -63,7 +63,6 @@ object StylesPlugin extends AutoPlugin { val npm = stylesNpmCommand.value val wd = (LocalRootProject / baseDirectory).value npmInstall(npm, wd, logger) - } ) diff --git a/project/build.sbt b/project/build.sbt new file mode 100644 index 00000000..18e1242b --- /dev/null +++ b/project/build.sbt @@ -0,0 +1,2 @@ +libraryDependencies ++= + Seq("com.typesafe" % "config" % "1.4.1") diff --git a/project/plugins.sbt b/project/plugins.sbt index f673ecb9..905ec7db 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,12 +3,12 @@ addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.10.0") addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % "0.8.2") addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0") -addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.1") -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1") +addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.2") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.6") addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.0") -addSbtPlugin("io.kevinlee" % "sbt-github-pages" % "0.7.0") +addSbtPlugin("io.kevinlee" % "sbt-github-pages" % "0.8.0") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.1.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.7.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.7.1") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.10") diff --git a/tools/exim/exim.conf b/tools/exim/exim.conf index e2d7d6b4..25926737 100644 --- a/tools/exim/exim.conf +++ b/tools/exim/exim.conf @@ -28,7 +28,7 @@ require verify = recipient require message = Recipient unknown - condition = ${run{/usr/bin/curl --out /dev/null --silent --fail -H "Docspell-Integration: ${env{DS_HEADER}{$value} fail}" "${env{DS_URL}{$value} fail}/api/v1/open/integration/item/$local_part"}{yes}{no}} + condition = ${run{/usr/bin/curl --output /dev/null --silent --fail -H "Docspell-Integration: ${env{DS_HEADER}{$value} fail}" "${env{DS_URL}{$value} fail}/api/v1/open/integration/item/$local_part"}{yes}{no}} warn message = Reverse lookup failed !verify = reverse_host_lookup @@ -48,7 +48,7 @@ local_users: begin transports docspell: driver = pipe - command = /usr/bin/curl --out /dev/null --silent --fail -H "Docspell-Integration: ${env{DS_HEADER}{$value} fail}" -F "file=@-;filename=\"$h_subject:\"" "${env{DS_URL}{$value} fail}/api/v1/open/integration/item/$local_part" + command = /usr/bin/curl --output /dev/null --silent --fail -H "Docspell-Integration: ${env{DS_HEADER}{$value} fail}" -F "file=@-;filename=\"$h_subject:\"" "${env{DS_URL}{$value} fail}/api/v1/open/integration/item/$local_part" return_fail_output user = nobody delivery_date_add diff --git a/version.sbt b/version.sbt index d2b03354..387ed3e5 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -ThisBuild / version := "0.27.0" +ThisBuild / version := "0.28.0-SNAPSHOT" diff --git a/website/site/config.toml b/website/site/config.toml index c97d10cb..6efb0447 100644 --- a/website/site/config.toml +++ b/website/site/config.toml @@ -26,4 +26,4 @@ skip_anchor_prefixes = [ [extra] # Put all your custom variables here -version = "0.27.0" +version = "0.28.0-SNAPSHOT" diff --git a/website/site/content/docs/configure/_index.md b/website/site/content/docs/configure/_index.md index 7ebb0950..5eef1bb5 100644 --- a/website/site/content/docs/configure/_index.md +++ b/website/site/content/docs/configure/_index.md @@ -8,10 +8,11 @@ mktoc = true +++ Docspell's executables (restserver and joex) can take one argument – a -configuration file. If that is not given, the defaults are used. The -config file overrides default values, so only values that differ from -the defaults are necessary. The complete default options and their -documentation is at the end of this page. +configuration file. If that is not given, the defaults are used, +overriden by environment variables. A config file overrides default +values, so only values that differ from the defaults are necessary. +The complete default options and their documentation is at the end of +this page. Besides the config file, another way is to provide individual settings via key-value pairs to the executable by the `-D` option. For example @@ -22,6 +23,21 @@ the recommended way is to maintain a config file. If these options *and* a file is provded, then any setting given via the `-D…` option overrides the same setting from the config file. +At last, it is possible to configure docspell via environment +variables if there is no config file supplied (if a config file *is* +supplied, it is always preferred). Note that this approach is limited, +as arrays are not supported. A list of environment variables can be +found at the [end of this page](#environment-variables). The +environment variable name follows the corresponding config key - where +dots are replaced by underscores and dashes are replaced by two +underscores. For example, the config key `docspell.server.app-name` +can be defined as env variable `DOCSPELL_SERVER_APP__NAME`. + +It is also possible to specify environment variables inside a config +file (to get a mix of both) - please see the [documentation of the +config library](https://github.com/lightbend/config#standard-behavior) +for more on this. + # File Format The format of the configuration files can be @@ -31,9 +47,9 @@ library](https://github.com/lightbend/config) understands. The default values below are in HOCON format, which is recommended, since it allows comments and has some [advanced features](https://github.com/lightbend/config#features-of-hocon). -Please refer to their documentation for more on this. +Please also see their documentation for more details. -A short description (please see the links for better understanding): +A short description (please check the links for better understanding): The config consists of key-value pairs and can be written in a JSON-like format (called HOCON). Keys are organized in trees, and a key defines a full path into the tree. There are two ways: @@ -633,3 +649,11 @@ statements with level "DEBUG" will be printed, too. {{ incl_conf(path="templates/shortcodes/joex.conf") }} + +## Environment Variables + +Environment variables can be used when there is no config file +supplied. The listing below shows all possible variables and their +default values. + +{{ incl_conf(path="templates/shortcodes/config.env.txt") }} diff --git a/website/site/content/docs/features/_index.md b/website/site/content/docs/features/_index.md index b5855066..03da2d7d 100644 --- a/website/site/content/docs/features/_index.md +++ b/website/site/content/docs/features/_index.md @@ -56,6 +56,7 @@ description = "A list of features and limitations." - Everything stored in a SQL database: PostgreSQL, MariaDB or H2 - H2 is embedded, a "one-file-only" database, avoids installing db servers +- Support for encrypted PDFs - Files supported: - Documents: - PDF