Merge pull request from eikek/feature/446-share

Feature/446 share
This commit is contained in:
mergify[bot] 2021-10-24 12:56:49 +00:00 committed by GitHub
commit cd0f7ec66e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 8875 additions and 1293 deletions
build.sbt
modules
backend/src/main/scala/docspell/backend
common/src/main/scala/docspell/common
query/shared/src
restapi/src/main
resources
scala/docspell/restapi/codec
restserver/src/main
store/src/main
webapp

@ -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,

@ -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._

@ -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.")
}
}

@ -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)
)
}

@ -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",

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

@ -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

@ -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." ]
]
]

@ -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 = ""
}
]

@ -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)
]
]

@ -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
]
]
]

@ -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
]
]

@ -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 <|

@ -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

@ -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"
}

@ -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"
}

@ -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"
}

@ -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."
}

@ -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"
}

@ -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"
}

@ -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"
}

@ -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"
}

@ -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