mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-11 03:29:33 +00:00
Merge pull request #1133 from eikek/feature/446-share
Feature/446 share
This commit is contained in:
commit
cd0f7ec66e
build.sbt
modules
backend/src/main/scala/docspell/backend
common/src/main/scala/docspell/common
query/shared/src
main/scala/docspell/query
test/scala/docspell/query
restapi/src/main
restserver/src/main
store/src/main
resources/db/migration
scala/docspell/store
webapp
elm.jsonpackage-lock.jsonpackage.jsonpostcss.config.js
src/main/elm
Api.elm
App
Comp
CustomFieldMultiInput.elmDatePicker.elmItemCard.elmItemCardList.elm
ItemDetail
ItemMail.elmOtpSetup.elmPowerSearchInput.elmPublishItems.elmSearchMenu.elmShareForm.elmShareMail.elmShareManage.elmSharePasswordForm.elmShareTable.elmShareView.elmSourceManage.elmTagSelect.elmUiSettingsForm.elmUrlCopy.elmUserManage.elmData
Messages.elmMessages
15
build.sbt
15
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._"
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
@ -367,6 +379,7 @@ val store = project
|
||||
.settings(testSettingsMUnit)
|
||||
.settings(
|
||||
name := "docspell-store",
|
||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||
libraryDependencies ++=
|
||||
Dependencies.doobie ++
|
||||
Dependencies.binny ++
|
||||
@ -472,7 +485,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"))
|
||||
|
@ -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,6 +111,7 @@ object BackendApp {
|
||||
val simpleSearch = simpleSearchImpl
|
||||
val clientSettings = clientSettingsImpl
|
||||
val totp = totpImpl
|
||||
val share = shareImpl
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
|
@ -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)
|
||||
|
||||
}
|
@ -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 =
|
||||
|
@ -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,
|
||||
|
381
modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
Normal file
381
modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
Normal file
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -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) "<empty>" 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 {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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\""
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
@ -6121,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
|
||||
@ -6242,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
|
||||
@ -6257,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
|
||||
@ -6285,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
|
||||
|
@ -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
|
@ -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
|
||||
@ -44,9 +44,12 @@ object RestServer {
|
||||
"/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),
|
||||
@ -94,6 +97,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 +123,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 +136,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._
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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]] = {
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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))
|
||||
}
|
@ -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" =>
|
||||
|
@ -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]) {
|
||||
|
@ -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._
|
||||
|
||||
|
64
modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala
Normal file
64
modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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.")
|
||||
}
|
||||
}
|
105
modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala
Normal file
105
modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala
Normal file
@ -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}"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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}}}
|
||||
};
|
||||
</script>
|
||||
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
@ -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]
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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) {}
|
@ -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
|
||||
|
@ -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] =
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
167
modules/store/src/main/scala/docspell/store/records/RShare.scala
Normal file
167
modules/store/src/main/scala/docspell/store/records/RShare.scala
Normal file
@ -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)))
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
||||
|
@ -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",
|
||||
|
1856
modules/webapp/package-lock.json
generated
1856
modules/webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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": "^9.0.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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 )
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -37,7 +37,7 @@ init =
|
||||
|
||||
emptyModel : DatePicker
|
||||
emptyModel =
|
||||
DatePicker.initFromDate (Date.fromCalendarDate 2019 Aug 21)
|
||||
DatePicker.initFromDate (Date.fromCalendarDate 2021 Oct 31)
|
||||
|
||||
|
||||
defaultSettings : Settings
|
||||
|
@ -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" ] []
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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"
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
}
|
||||
]
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
373
modules/webapp/src/main/elm/Comp/PublishItems.elm
Normal file
373
modules/webapp/src/main/elm/Comp/PublishItems.elm
Normal file
@ -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
|
||||
]
|
||||
]
|
@ -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
|
||||
|
332
modules/webapp/src/main/elm/Comp/ShareForm.elm
Normal file
332
modules/webapp/src/main/elm/Comp/ShareForm.elm
Normal file
@ -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." ]
|
||||
]
|
||||
]
|
191
modules/webapp/src/main/elm/Comp/ShareMail.elm
Normal file
191
modules/webapp/src/main/elm/Comp/ShareMail.elm
Normal file
@ -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 = ""
|
||||
}
|
||||
]
|
519
modules/webapp/src/main/elm/Comp/ShareManage.elm
Normal file
519
modules/webapp/src/main/elm/Comp/ShareManage.elm
Normal file
@ -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)
|
||||
]
|
||||
]
|
163
modules/webapp/src/main/elm/Comp/SharePasswordForm.elm
Normal file
163
modules/webapp/src/main/elm/Comp/SharePasswordForm.elm
Normal file
@ -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
|
||||
]
|
||||
]
|
||||
]
|
100
modules/webapp/src/main/elm/Comp/ShareTable.elm
Normal file
100
modules/webapp/src/main/elm/Comp/ShareTable.elm
Normal file
@ -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
|
||||
]
|
||||
]
|
185
modules/webapp/src/main/elm/Comp/ShareView.elm
Normal file
185
modules/webapp/src/main/elm/Comp/ShareView.elm
Normal file
@ -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)
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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 <|
|
||||
|
102
modules/webapp/src/main/elm/Comp/UrlCopy.elm
Normal file
102
modules/webapp/src/main/elm/Comp/UrlCopy.elm
Normal file
@ -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")
|
@ -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)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
74
modules/webapp/src/main/elm/Data/Pdf.elm
Normal file
74
modules/webapp/src/main/elm/Data/Pdf.elm
Normal file
@ -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
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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…"
|
||||
}
|
||||
|
89
modules/webapp/src/main/elm/Messages/Comp/PublishItems.elm
Normal file
89
modules/webapp/src/main/elm/Messages/Comp/PublishItems.elm
Normal file
@ -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"
|
||||
}
|
46
modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm
Normal file
46
modules/webapp/src/main/elm/Messages/Comp/ShareForm.elm
Normal file
@ -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"
|
||||
}
|
63
modules/webapp/src/main/elm/Messages/Comp/ShareMail.elm
Normal file
63
modules/webapp/src/main/elm/Messages/Comp/ShareMail.elm
Normal file
@ -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."
|
||||
}
|
94
modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm
Normal file
94
modules/webapp/src/main/elm/Messages/Comp/ShareManage.elm
Normal file
@ -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"
|
||||
}
|
@ -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"
|
||||
}
|
45
modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm
Normal file
45
modules/webapp/src/main/elm/Messages/Comp/ShareTable.elm
Normal file
@ -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"
|
||||
}
|
66
modules/webapp/src/main/elm/Messages/Comp/ShareView.elm
Normal file
66
modules/webapp/src/main/elm/Messages/Comp/ShareView.elm
Normal file
@ -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"
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
39
modules/webapp/src/main/elm/Messages/Data/PdfMode.elm
Normal file
39
modules/webapp/src/main/elm/Messages/Data/PdfMode.elm
Normal file
@ -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"
|
@ -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"
|
||||
|
@ -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"
|
||||
}
|
||||
|
56
modules/webapp/src/main/elm/Messages/Page/Share.elm
Normal file
56
modules/webapp/src/main/elm/Messages/Page/Share.elm
Normal file
@ -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"
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user