Basic management of shares

This commit is contained in:
eikek 2021-10-02 15:16:02 +02:00
parent de1baf725f
commit c7d587bea4
27 changed files with 1551 additions and 20 deletions

View File

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

View File

@ -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,7 @@ object BackendApp {
customFieldsImpl <- OCustomFields(store)
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
clientSettingsImpl <- OClientSettings(store)
shareImpl <- Resource.pure(OShare(store))
} yield new BackendApp[F] {
val login = loginImpl
val signup = signupImpl
@ -107,6 +109,7 @@ object BackendApp {
val simpleSearch = simpleSearchImpl
val clientSettings = clientSettingsImpl
val totp = totpImpl
val share = shareImpl
}
def apply[F[_]: Async](

View File

@ -0,0 +1,116 @@
/*
* 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.common._
import docspell.query.ItemQuery
import docspell.store.Store
import docspell.store.records.RShare
trait OShare[F[_]] {
def findAll(collective: Ident): F[List[RShare]]
def delete(id: Ident, collective: Ident): F[Boolean]
def addNew(share: OShare.NewShare): F[OShare.ChangeResult]
def findOne(id: Ident, collective: Ident): OptionT[F, RShare]
def update(
id: Ident,
share: OShare.NewShare,
removePassword: Boolean
): F[OShare.ChangeResult]
}
object OShare {
final case class NewShare(
cid: Ident,
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
def success(id: Ident): ChangeResult = Success(id)
def publishUntilInPast: ChangeResult = PublishUntilInPast
}
def apply[F[_]: Async](store: Store[F]): OShare[F] =
new OShare[F] {
def findAll(collective: Ident): F[List[RShare]] =
store.transact(RShare.findAllByCollective(collective))
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]
pass = share.password.map(PasswordCrypt.crypt)
record = RShare(
id,
share.cid,
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]
record = RShare(
id,
share.cid,
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(_ => ChangeResult.success(id))
} yield res
def findOne(id: Ident, collective: Ident): OptionT[F, RShare] =
RShare.findOne(id, collective).mapK(store.transform)
}
}

View File

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

View File

@ -1711,6 +1711,96 @@ 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: []
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/{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 +4186,83 @@ paths:
components:
schemas:
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
- enabled
- publishAt
- publishUntil
- password
- views
properties:
id:
type: string
format: ident
query:
type: string
format: itemquery
name:
type: string
enabled:
type: boolean
publishAt:
type: integer
format: date-time
publishUntil:
type: integer
format: date-time
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
@ -6121,8 +6288,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
@ -6257,6 +6424,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

View File

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

View File

@ -94,6 +94,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(),

View File

@ -0,0 +1,105 @@
/*
* 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.common.Ident
import docspell.restapi.model._
import docspell.restserver.http4s.ResponseGenerator
import docspell.store.records.RShare
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 =>
for {
all <- backend.share.findAll(user.account.collective)
res <- Ok(ShareList(all.map(mkShareDetail)))
} 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)
resp <- OptionT.liftF(Ok(mkShareDetail(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
}
}
def mkNewShare(data: ShareData, user: AuthToken): OShare.NewShare =
OShare.NewShare(
user.account.collective,
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(""))
}
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")
}
def mkShareDetail(r: RShare): ShareDetail =
ShareDetail(
r.id,
r.query,
r.name,
r.enabled,
r.publishAt,
r.publishUntil,
r.password.isDefined,
r.views,
r.lastAccess
)
}

View File

@ -0,0 +1,13 @@
CREATE TABLE "item_share" (
"id" varchar(254) not null primary key,
"cid" 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 ("cid") references "collective"("cid") on delete cascade
)

View File

@ -0,0 +1,13 @@
CREATE TABLE `item_share` (
`id` varchar(254) not null primary key,
`cid` 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 (`cid`) references `collective`(`cid`) on delete cascade
)

View File

@ -0,0 +1,13 @@
CREATE TABLE "item_share" (
"id" varchar(254) not null primary key,
"cid" 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 ("cid") references "collective"("cid") on delete cascade
)

View File

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

View File

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

View File

@ -6,20 +6,25 @@
package docspell.store.records
import cats.data.NonEmptyList
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,
cid: Ident,
name: Option[String],
query: ItemQuery,
enabled: Boolean,
password: Option[Password],
publishedAt: Timestamp,
publishedUntil: Timestamp,
publishAt: Timestamp,
publishUntil: Timestamp,
views: Int,
lastAccess: Option[Timestamp]
) {}
@ -31,11 +36,12 @@ object RShare {
val id = Column[Ident]("id", this)
val cid = Column[Ident]("cid", this)
val name = Column[String]("name", this)
val query = Column[ItemQuery]("query", this)
val enabled = Column[Boolean]("enabled", this)
val password = Column[Password]("password", this)
val publishedAt = Column[Timestamp]("published_at", this)
val publishedUntil = Column[Timestamp]("published_until", 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)
@ -43,6 +49,7 @@ object RShare {
NonEmptyList.of(
id,
cid,
name,
query,
enabled,
password,
@ -56,4 +63,47 @@ object RShare {
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.cid},${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.cid === r.cid,
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] =
OptionT(
Select(select(T.all), from(T), T.id === id && T.cid === cid).build
.query[RShare]
.option
)
def findAllByCollective(cid: Ident): ConnectionIO[List[RShare]] =
Select(select(T.all), from(T), T.cid === cid).build.query[RShare].to[List]
def deleteByIdAndCid(id: Ident, cid: Ident): ConnectionIO[Int] =
DML.delete(T, T.id === id && T.cid === cid)
}

View File

@ -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
@ -147,6 +151,7 @@ module Api exposing
, unconfirmMultiple
, updateNotifyDueItems
, updateScanMailbox
, updateShare
, upload
, uploadAmend
, uploadSingle
@ -215,6 +220,9 @@ 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.SimpleMail exposing (SimpleMail)
import Api.Model.SourceAndTags exposing (SourceAndTags)
import Api.Model.SourceList exposing (SourceList)
@ -2206,6 +2214,57 @@ disableOtp flags otp receive =
--- Share
getShares : Flags -> (Result Http.Error ShareList -> msg) -> Cmd msg
getShares flags receive =
Http2.authGet
{ url = flags.config.baseUrl ++ "/api/v1/sec/share"
, 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
}
--- Helper

View File

@ -37,7 +37,7 @@ init =
emptyModel : DatePicker
emptyModel =
DatePicker.initFromDate (Date.fromCalendarDate 2019 Aug 21)
DatePicker.initFromDate (Date.fromCalendarDate 2021 Oct 31)
defaultSettings : Settings

View File

@ -0,0 +1,282 @@
{-
Copyright 2020 Eike K. & Contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-}
module Comp.ShareForm exposing (Model, Msg, getShare, init, 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 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
, query : String
, enabled : Bool
, passwordModel : Comp.PasswordInput.Model
, password : Maybe String
, passwordSet : Bool
, clearPassword : Bool
, untilModel : DatePicker
, untilDate : Maybe Int
}
init : ( Model, Cmd Msg )
init =
let
( dp, dpc ) =
Comp.DatePicker.init
in
( { share = Api.Model.ShareDetail.empty
, name = Nothing
, query = ""
, enabled = False
, passwordModel = Comp.PasswordInput.init
, password = Nothing
, passwordSet = False
, clearPassword = False
, untilModel = dp
, untilDate = Nothing
}
, Cmd.map UntilDateMsg dpc
)
isValid : Model -> Bool
isValid model =
model.query /= "" && model.untilDate /= Nothing
type Msg
= SetName String
| SetQuery String
| SetShare ShareDetail
| ToggleEnabled
| ToggleClearPassword
| PasswordMsg Comp.PasswordInput.Msg
| UntilDateMsg Comp.DatePicker.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.query
, 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 )
update _ msg model =
case msg of
SetShare s ->
( { model
| share = s
, name = s.name
, query = s.query
, enabled = s.enabled
, password = Nothing
, passwordSet = s.password
, clearPassword = False
, untilDate =
if s.publishUntil > 0 then
Just s.publishUntil
else
Nothing
}
, Cmd.none
)
SetName n ->
( { model | name = Util.Maybe.fromString n }, Cmd.none )
SetQuery n ->
( { model | query = n }, Cmd.none )
ToggleEnabled ->
( { model | enabled = not model.enabled }, Cmd.none )
ToggleClearPassword ->
( { model | clearPassword = not model.clearPassword }, Cmd.none )
PasswordMsg lm ->
let
( pm, pw ) =
Comp.PasswordInput.update lm model.passwordModel
in
( { model
| passwordModel = pm
, password = pw
}
, Cmd.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
)
--- View
view : Texts -> Model -> Html Msg
view texts model =
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
]
, input
[ type_ "text"
, onInput SetQuery
, placeholder texts.queryLabel
, value model.query
, id "sharequery"
, class S.textInput
, classList
[ ( S.inputErrorBorder
, not (isValid model)
)
]
]
[]
]
, 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" ] []
]
]
]

View File

@ -0,0 +1,349 @@
{-
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.ShareTable
import Data.Flags exposing (Flags)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Http
import Messages.Comp.ShareManage exposing (Texts)
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
, loading : Bool
, formError : FormError
, deleteConfirm : DeleteConfirm
}
init : ( Model, Cmd Msg )
init =
let
( fm, fc ) =
Comp.ShareForm.init
in
( { viewMode = Table
, shares = []
, formModel = fm
, loading = False
, formError = FormErrorNone
, deleteConfirm = DeleteConfirmOff
}
, Cmd.map FormMsg fc
)
type Msg
= LoadShares
| TableMsg Comp.ShareTable.Msg
| FormMsg Comp.ShareForm.Msg
| InitNewShare
| SetViewMode ViewMode
| 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 : Flags -> Msg -> Model -> ( Model, Cmd Msg )
update flags msg model =
case msg of
InitNewShare ->
let
nm =
{ model | viewMode = Form, formError = FormErrorNone }
share =
Api.Model.ShareDetail.empty
in
update flags (FormMsg (Comp.ShareForm.setShare share)) nm
SetViewMode vm ->
( { model | viewMode = vm, formError = FormErrorNone }
, if vm == Table then
Api.getShares flags LoadSharesResp
else
Cmd.none
)
FormMsg lm ->
let
( fm, fc ) =
Comp.ShareForm.update flags lm model.formModel
in
( { model | formModel = fm }, Cmd.map FormMsg fc )
TableMsg lm ->
let
action =
Comp.ShareTable.update lm
nextModel =
{ model | viewMode = Form, formError = FormErrorNone }
in
case action of
Comp.ShareTable.Edit share ->
update flags (FormMsg <| Comp.ShareForm.setShare share) nextModel
RequestDelete ->
( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none )
CancelDelete ->
( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none )
DeleteShareNow id ->
( { model | deleteConfirm = DeleteConfirmOff, loading = True }
, Api.deleteShare flags id DeleteShareResp
)
LoadShares ->
( { model | loading = True }, Api.getShares flags LoadSharesResp )
LoadSharesResp (Ok list) ->
( { model | loading = False, shares = list.items, formError = FormErrorNone }, Cmd.none )
LoadSharesResp (Err err) ->
( { model | loading = False, formError = FormErrorHttp err }, Cmd.none )
Submit ->
case Comp.ShareForm.getShare model.formModel of
Just ( id, data ) ->
if id == "" then
( { model | loading = True }, Api.addShare flags data AddShareResp )
else
( { model | loading = True }, Api.updateShare flags id data UpdateShareResp )
Nothing ->
( { model | formError = FormErrorInvalid }, Cmd.none )
AddShareResp (Ok res) ->
if res.success then
( model, Api.getShare flags res.id GetShareResp )
else
( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none )
AddShareResp (Err err) ->
( { model | loading = False, formError = FormErrorHttp err }, Cmd.none )
UpdateShareResp (Ok res) ->
if res.success then
( model, Api.getShare flags model.formModel.share.id GetShareResp )
else
( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none )
UpdateShareResp (Err err) ->
( { model | loading = False, formError = FormErrorHttp err }, Cmd.none )
GetShareResp (Ok share) ->
let
nextModel =
{ model | formError = FormErrorNone, loading = False }
in
update flags (FormMsg <| Comp.ShareForm.setShare share) nextModel
GetShareResp (Err err) ->
( { model | formError = FormErrorHttp err }, Cmd.none )
DeleteShareResp (Ok res) ->
if res.success then
update flags (SetViewMode Table) { model | loading = False }
else
( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none )
DeleteShareResp (Err err) ->
( { model | formError = FormErrorHttp err, loading = False }, Cmd.none )
--- view
view : Texts -> Flags -> Model -> Html Msg
view texts _ model =
if model.viewMode == Table then
viewTable texts model
else
viewForm texts model
viewTable : Texts -> Model -> Html Msg
viewTable texts model =
div [ class "flex flex-col" ]
[ MB.view
{ start =
[]
, 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 -> Model -> Html Msg
viewForm texts model =
let
newShare =
model.formModel.share.id == ""
in
Html.form [ class "relative" ]
[ if newShare then
h1 [ class S.header2 ]
[ text texts.createNewShare
]
else
h1 [ class S.header2 ]
[ text <| Maybe.withDefault texts.noName model.formModel.share.name
, div [ class "opacity-50 text-sm" ]
[ text "Id: "
, text model.formModel.share.id
]
]
, MB.view
{ start =
[ MB.PrimaryButton
{ tagger = Submit
, title = "Submit this form"
, icon = Just "fa fa-save"
, label = texts.basics.submit
}
, 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
]
, 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" ]
}
]
]
)
]

View File

@ -0,0 +1,87 @@
{-
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.enabled
]
, th [ class "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" ]
[ Util.Html.checkbox2 share.enabled
]
, td [ class "hidden sm:table-cell text-center" ]
[ texts.formatDateTime share.publishUntil |> text
]
]

View File

@ -58,11 +58,11 @@ module Data.Icons exposing
, personIcon2
, search
, searchIcon
, share
, shareIcon
, showQr
, showQrIcon
, source
, source2
, sourceIcon
, sourceIcon2
, tag
, tag2
@ -79,9 +79,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 +94,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) ] []

View File

@ -0,0 +1,46 @@
{-
Copyright 2020 Eike K. & Contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-}
module Messages.Comp.ShareForm exposing
( Texts
, de
, gb
)
import Messages.Basics
type alias Texts =
{ basics : Messages.Basics.Texts
, queryLabel : String
, enabled : String
, password : String
, publishUntil : String
, clearPassword : String
}
gb : Texts
gb =
{ basics = Messages.Basics.gb
, queryLabel = "Query"
, enabled = "Enabled"
, password = "Password"
, publishUntil = "Publish Until"
, clearPassword = "Remove password"
}
de : Texts
de =
{ basics = Messages.Basics.de
, queryLabel = "Abfrage"
, enabled = "Aktiv"
, password = "Passwort"
, publishUntil = "Publiziert bis"
, clearPassword = "Passwort entfernen"
}

View File

@ -0,0 +1,74 @@
{-
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.ShareTable
type alias Texts =
{ basics : Messages.Basics.Texts
, shareTable : Messages.Comp.ShareTable.Texts
, shareForm : Messages.Comp.ShareForm.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
}
gb : Texts
gb =
{ basics = Messages.Basics.gb
, httpError = Messages.Comp.HttpError.gb
, shareTable = Messages.Comp.ShareTable.gb
, shareForm = Messages.Comp.ShareForm.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"
}
de : Texts
de =
{ basics = Messages.Basics.de
, shareTable = Messages.Comp.ShareTable.de
, shareForm = Messages.Comp.ShareForm.de
, httpError = Messages.Comp.HttpError.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"
}

View File

@ -0,0 +1,42 @@
{-
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
, enabled : String
, publishUntil : String
}
gb : Texts
gb =
{ basics = Messages.Basics.gb
, formatDateTime = DF.formatDateTimeLong Messages.UiLanguage.English
, enabled = "Enabled"
, publishUntil = "Publish Until"
}
de : Texts
de =
{ basics = Messages.Basics.de
, formatDateTime = DF.formatDateTimeLong Messages.UiLanguage.German
, enabled = "Aktiv"
, publishUntil = "Publiziert bis"
}

View File

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

View File

@ -17,6 +17,7 @@ import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.CollectiveSettings exposing (CollectiveSettings)
import Api.Model.ItemInsights exposing (ItemInsights)
import Comp.CollectiveSettingsForm
import Comp.ShareManage
import Comp.SourceManage
import Comp.UserManage
import Data.Flags exposing (Flags)
@ -28,6 +29,7 @@ type alias Model =
, sourceModel : Comp.SourceManage.Model
, userModel : Comp.UserManage.Model
, settingsModel : Comp.CollectiveSettingsForm.Model
, shareModel : Comp.ShareManage.Model
, insights : ItemInsights
, formState : FormState
}
@ -48,10 +50,14 @@ init flags =
( cm, cc ) =
Comp.CollectiveSettingsForm.init flags Api.Model.CollectiveSettings.empty
( shm, shc ) =
Comp.ShareManage.init
in
( { currentTab = Just InsightsTab
, sourceModel = sm
, userModel = Comp.UserManage.emptyModel
, shareModel = shm
, settingsModel = cm
, insights = Api.Model.ItemInsights.empty
, formState = InitialState
@ -59,6 +65,7 @@ init flags =
, Cmd.batch
[ Cmd.map SourceMsg sc
, Cmd.map SettingsFormMsg cc
, Cmd.map ShareMsg shc
]
)
@ -68,6 +75,7 @@ type Tab
| UserTab
| InsightsTab
| SettingsTab
| ShareTab
type Msg
@ -79,3 +87,4 @@ type Msg
| GetInsightsResp (Result Http.Error ItemInsights)
| CollectiveSettingsResp (Result Http.Error CollectiveSettings)
| SubmitResp (Result Http.Error BasicResult)
| ShareMsg Comp.ShareManage.Msg

View File

@ -9,6 +9,7 @@ module Page.CollectiveSettings.Update exposing (update)
import Api
import Comp.CollectiveSettingsForm
import Comp.ShareManage
import Comp.SourceManage
import Comp.UserManage
import Data.Flags exposing (Flags)
@ -36,6 +37,9 @@ update flags msg model =
SettingsTab ->
update flags Init m
ShareTab ->
update flags (ShareMsg Comp.ShareManage.loadShares) m
SourceMsg m ->
let
( m2, c2 ) =
@ -43,6 +47,13 @@ update flags msg model =
in
( { model | sourceModel = m2 }, Cmd.map SourceMsg c2 )
ShareMsg lm ->
let
( sm, sc ) =
Comp.ShareManage.update flags lm model.shareModel
in
( { model | shareModel = sm }, Cmd.map ShareMsg sc )
UserMsg m ->
let
( m2, c2 ) =

View File

@ -10,6 +10,7 @@ module Page.CollectiveSettings.View2 exposing (viewContent, viewSidebar)
import Api.Model.TagCount exposing (TagCount)
import Comp.Basic as B
import Comp.CollectiveSettingsForm
import Comp.ShareManage
import Comp.SourceManage
import Comp.UserManage
import Data.Flags exposing (Flags)
@ -60,6 +61,17 @@ viewSidebar texts visible _ _ model =
[ class "ml-3" ]
[ text texts.sources ]
]
, a
[ href "#"
, onClick (SetTab ShareTab)
, class S.sidebarLink
, menuEntryActive model ShareTab
]
[ Icons.shareIcon ""
, span
[ class "ml-3" ]
[ text texts.shares ]
]
, a
[ href "#"
, onClick (SetTab SettingsTab)
@ -105,6 +117,9 @@ viewContent texts flags settings model =
Just SourceTab ->
viewSources texts flags settings model
Just ShareTab ->
viewShares texts flags model
Nothing ->
[]
)
@ -230,6 +245,21 @@ viewSources texts flags settings model =
]
viewShares : Texts -> Flags -> Model -> List (Html Msg)
viewShares texts flags model =
[ h1
[ class S.header1
, class "inline-flex items-center"
]
[ Icons.shareIcon ""
, div [ class "ml-3" ]
[ text texts.shares
]
]
, Html.map ShareMsg (Comp.ShareManage.view texts.shareManage flags model.shareModel)
]
viewUsers : Texts -> UiSettings -> Model -> List (Html Msg)
viewUsers texts settings model =
[ h1