Merge pull request #180 from eikek/spaces

Add folders
This commit is contained in:
mergify[bot] 2020-07-14 21:38:15 +00:00 committed by GitHub
commit f33aa969d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 3166 additions and 232 deletions

View File

@ -598,3 +598,4 @@ addCommandAlias("make-zip", ";restserver/universal:packageBin ;joex/universal:pa
addCommandAlias("make-deb", ";restserver/debian:packageBin ;joex/debian:packageBin")
addCommandAlias("make-tools", ";root/toolsPackage")
addCommandAlias("make-pkg", ";clean ;make ;make-zip ;make-deb ;make-tools")
addCommandAlias("reformatAll", ";project root ;scalafix ;scalafmtAll")

View File

@ -35,6 +35,7 @@ trait BackendApp[F[_]] {
def mail: OMail[F]
def joex: OJoex[F]
def userTask: OUserTask[F]
def folder: OFolder[F]
}
object BackendApp {
@ -67,6 +68,7 @@ object BackendApp {
JavaMailEmil(blocker, Settings.defaultSettings.copy(debug = cfg.mailDebug))
mailImpl <- OMail(store, javaEmil)
userTaskImpl <- OUserTask(utStore, queue, joexImpl)
folderImpl <- OFolder(store)
} yield new BackendApp[F] {
val login: Login[F] = loginImpl
val signup: OSignup[F] = signupImpl
@ -84,6 +86,7 @@ object BackendApp {
val mail = mailImpl
val joex = joexImpl
val userTask = userTaskImpl
val folder = folderImpl
}
def apply[F[_]: ConcurrentEffect: ContextShift](

View File

@ -0,0 +1,110 @@
package docspell.backend.ops
import cats.effect._
import docspell.common._
import docspell.store.queries.QFolder
import docspell.store.records.{RFolder, RUser}
import docspell.store.{AddResult, Store}
trait OFolder[F[_]] {
def findAll(
account: AccountId,
ownerLogin: Option[Ident],
nameQuery: Option[String]
): F[Vector[OFolder.FolderItem]]
def findById(id: Ident, account: AccountId): F[Option[OFolder.FolderDetail]]
/** Adds a new folder. If `login` is non-empty, the `folder.user`
* property is ignored and the user-id is determined by the given
* login name.
*/
def add(folder: RFolder, login: Option[Ident]): F[AddResult]
def changeName(
folder: Ident,
account: AccountId,
name: String
): F[OFolder.FolderChangeResult]
def addMember(
folder: Ident,
account: AccountId,
member: Ident
): F[OFolder.FolderChangeResult]
def removeMember(
folder: Ident,
account: AccountId,
member: Ident
): F[OFolder.FolderChangeResult]
def delete(id: Ident, account: AccountId): F[OFolder.FolderChangeResult]
}
object OFolder {
type FolderChangeResult = QFolder.FolderChangeResult
val FolderChangeResult = QFolder.FolderChangeResult
type FolderItem = QFolder.FolderItem
val FolderItem = QFolder.FolderItem
type FolderDetail = QFolder.FolderDetail
val FolderDetail = QFolder.FolderDetail
def apply[F[_]: Effect](store: Store[F]): Resource[F, OFolder[F]] =
Resource.pure[F, OFolder[F]](new OFolder[F] {
def findAll(
account: AccountId,
ownerLogin: Option[Ident],
nameQuery: Option[String]
): F[Vector[FolderItem]] =
store.transact(QFolder.findAll(account, None, ownerLogin, nameQuery))
def findById(id: Ident, account: AccountId): F[Option[FolderDetail]] =
store.transact(QFolder.findById(id, account))
def add(folder: RFolder, login: Option[Ident]): F[AddResult] = {
val insert = login match {
case Some(n) =>
for {
user <- RUser.findByAccount(AccountId(folder.collectiveId, n))
s = user.map(u => folder.copy(owner = u.uid)).getOrElse(folder)
n <- RFolder.insert(s)
} yield n
case None =>
RFolder.insert(folder)
}
val exists = RFolder.existsByName(folder.collectiveId, folder.name)
store.add(insert, exists)
}
def changeName(
folder: Ident,
account: AccountId,
name: String
): F[FolderChangeResult] =
store.transact(QFolder.changeName(folder, account, name))
def addMember(
folder: Ident,
account: AccountId,
member: Ident
): F[FolderChangeResult] =
store.transact(QFolder.addMember(folder, account, member))
def removeMember(
folder: Ident,
account: AccountId,
member: Ident
): F[FolderChangeResult] =
store.transact(QFolder.removeMember(folder, account, member))
def delete(id: Ident, account: AccountId): F[FolderChangeResult] =
store.transact(QFolder.delete(id, account))
})
}

View File

@ -9,7 +9,7 @@ import docspell.backend.ops.OItemSearch._
import docspell.common._
import docspell.ftsclient._
import docspell.store.Store
import docspell.store.queries.QItem
import docspell.store.queries.{QFolder, QItem}
import docspell.store.queue.JobQueue
import docspell.store.records.RJob
@ -30,7 +30,7 @@ trait OFulltext[F[_]] {
def findIndexOnly(
fts: OFulltext.FtsInput,
collective: Ident,
account: AccountId,
batch: Batch
): F[Vector[OFulltext.FtsItemWithTags]]
@ -94,27 +94,29 @@ object OFulltext {
def findIndexOnly(
ftsQ: OFulltext.FtsInput,
collective: Ident,
account: AccountId,
batch: Batch
): F[Vector[OFulltext.FtsItemWithTags]] = {
val fq = FtsQuery(
ftsQ.query,
collective,
account.collective,
Set.empty,
Set.empty,
batch.limit,
batch.offset,
FtsQuery.HighlightSetting(ftsQ.highlightPre, ftsQ.highlightPost)
)
for {
ftsR <- fts.search(fq)
folders <- store.transact(QFolder.getMemberFolders(account))
ftsR <- fts.search(fq.withFolders(folders))
ftsItems = ftsR.results.groupBy(_.itemId)
select = ftsR.results.map(r => QItem.SelectedItem(r.itemId, r.score)).toSet
itemsWithTags <-
store
.transact(
QItem.findItemsWithTags(
collective,
QItem.findSelectedItems(QItem.Query.empty(collective), select)
account.collective,
QItem.findSelectedItems(QItem.Query.empty(account), select)
)
)
.take(batch.limit.toLong)
@ -182,7 +184,8 @@ object OFulltext {
val sqlResult = search(q, batch)
val fq = FtsQuery(
ftsQ.query,
q.collective,
q.account.collective,
Set.empty,
Set.empty,
0,
0,

View File

@ -24,6 +24,8 @@ trait OItem[F[_]] {
def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult]
def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[AddResult]
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult]
def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult]
@ -131,6 +133,19 @@ object OItem {
.attempt
.map(AddResult.fromUpdate)
def setFolder(
item: Ident,
folder: Option[Ident],
collective: Ident
): F[AddResult] =
store
.transact(RItem.updateFolder(item, collective, folder))
.attempt
.map(AddResult.fromUpdate)
.flatTap(
onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder))
)
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] =
store
.transact(RItem.updateCorrOrg(item, collective, org))

View File

@ -107,7 +107,7 @@ object OItemSearch {
val search = QItem.findItems(q, batch)
store
.transact(
QItem.findItemsWithTags(q.collective, search).take(batch.limit.toLong)
QItem.findItemsWithTags(q.account.collective, search).take(batch.limit.toLong)
)
.compile
.toVector

View File

@ -58,6 +58,7 @@ object OUpload {
case class UploadMeta(
direction: Option[Direction],
sourceAbbrev: String,
folderId: Option[Ident],
validFileTypes: Seq[MimeType]
)
@ -123,6 +124,7 @@ object OUpload {
lang.getOrElse(Language.German),
data.meta.direction,
data.meta.sourceAbbrev,
data.meta.folderId,
data.meta.validFileTypes
)
args =
@ -147,7 +149,10 @@ object OUpload {
(for {
src <- OptionT(store.transact(RSource.find(sourceId)))
updata = data.copy(
meta = data.meta.copy(sourceAbbrev = src.abbrev),
meta = data.meta.copy(
sourceAbbrev = src.abbrev,
folderId = data.meta.folderId.orElse(src.folderId)
),
priority = src.priority
)
accId = AccountId(src.cid, src.sid)

View File

@ -36,6 +36,7 @@ object ProcessItemArgs {
language: Language,
direction: Option[Direction],
sourceAbbrev: String,
folderId: Option[Ident],
validFileTypes: Seq[MimeType]
)

View File

@ -27,7 +27,9 @@ case class ScanMailboxArgs(
// delete the after submitting (only if targetFolder is None)
deleteMail: Boolean,
// set the direction when submitting
direction: Option[Direction]
direction: Option[Direction],
// set a folder for items
itemFolder: Option[Ident]
)
object ScanMailboxArgs {

View File

@ -17,11 +17,12 @@ import org.log4s.getLogger
*/
trait FtsClient[F[_]] {
/** Initialization tasks. This is called exactly once and then never
/** Initialization tasks. This is called exactly once at the very
* beginning when initializing the full-text index and then never
* again (except when re-indexing everything). It may be used to
* setup the database.
*/
def initialize: F[Unit]
def initialize: List[FtsMigration[F]]
/** Run a full-text search. */
def search(q: FtsQuery): F[FtsResult]
@ -57,7 +58,7 @@ trait FtsClient[F[_]] {
collective: Ident,
name: String
): F[Unit] =
updateIndex(logger, TextData.item(itemId, collective, Some(name), None))
updateIndex(logger, TextData.item(itemId, collective, None, Some(name), None))
def updateItemNotes(
logger: Logger[F],
@ -67,7 +68,7 @@ trait FtsClient[F[_]] {
): F[Unit] =
updateIndex(
logger,
TextData.item(itemId, collective, None, Some(notes.getOrElse("")))
TextData.item(itemId, collective, None, None, Some(notes.getOrElse("")))
)
def updateAttachmentName(
@ -83,12 +84,20 @@ trait FtsClient[F[_]] {
itemId,
attachId,
collective,
None,
Language.English,
Some(name.getOrElse("")),
None
)
)
def updateFolder(
logger: Logger[F],
itemId: Ident,
collective: Ident,
folder: Option[Ident]
): F[Unit]
def removeItem(logger: Logger[F], itemId: Ident): F[Unit]
def removeAttachment(logger: Logger[F], attachId: Ident): F[Unit]
@ -107,8 +116,8 @@ object FtsClient {
new FtsClient[F] {
private[this] val logger = Logger.log4s[F](getLogger)
def initialize: F[Unit] =
logger.info("Full-text search is disabled!")
def initialize: List[FtsMigration[F]] =
Nil
def search(q: FtsQuery): F[FtsResult] =
logger.warn("Full-text search is disabled!") *> FtsResult.empty.pure[F]
@ -116,6 +125,14 @@ object FtsClient {
def updateIndex(logger: Logger[F], data: Stream[F, TextData]): F[Unit] =
logger.warn("Full-text search is disabled!")
def updateFolder(
logger: Logger[F],
itemId: Ident,
collective: Ident,
folder: Option[Ident]
): F[Unit] =
logger.warn("Full-text search is disabled!")
def indexData(logger: Logger[F], data: Stream[F, TextData]): F[Unit] =
logger.warn("Full-text search is disabled!")

View File

@ -0,0 +1,24 @@
package docspell.ftsclient
import docspell.common._
final case class FtsMigration[F[_]](
version: Int,
engine: Ident,
description: String,
task: F[FtsMigration.Result]
)
object FtsMigration {
sealed trait Result
object Result {
case object WorkDone extends Result
case object ReIndexAll extends Result
case object IndexAll extends Result
def workDone: Result = WorkDone
def reIndexAll: Result = ReIndexAll
def indexAll: Result = IndexAll
}
}

View File

@ -10,11 +10,16 @@ import docspell.common._
* Searches must only look for given collective and in the given list
* of item ids, if it is non-empty. If the item set is empty, then
* don't restrict the result in this way.
*
* The set of folders must be used to restrict the results only to
* items that have one of the folders set or no folder set. If the
* set is empty, the restriction does not apply.
*/
final case class FtsQuery(
q: String,
collective: Ident,
items: Set[Ident],
folders: Set[Ident],
limit: Int,
offset: Int,
highlight: FtsQuery.HighlightSetting
@ -22,6 +27,9 @@ final case class FtsQuery(
def nextPage: FtsQuery =
copy(offset = limit + offset)
def withFolders(fs: Set[Ident]): FtsQuery =
copy(folders = fs)
}
object FtsQuery {

View File

@ -10,6 +10,8 @@ sealed trait TextData {
def collective: Ident
def folder: Option[Ident]
final def fold[A](f: TextData.Attachment => A, g: TextData.Item => A): A =
this match {
case a: TextData.Attachment => f(a)
@ -23,6 +25,7 @@ object TextData {
item: Ident,
attachId: Ident,
collective: Ident,
folder: Option[Ident],
lang: Language,
name: Option[String],
text: Option[String]
@ -36,15 +39,17 @@ object TextData {
item: Ident,
attachId: Ident,
collective: Ident,
folder: Option[Ident],
lang: Language,
name: Option[String],
text: Option[String]
): TextData =
Attachment(item, attachId, collective, lang, name, text)
Attachment(item, attachId, collective, folder, lang, name, text)
final case class Item(
item: Ident,
collective: Ident,
folder: Option[Ident],
name: Option[String],
notes: Option[String]
) extends TextData {
@ -56,8 +61,9 @@ object TextData {
def item(
item: Ident,
collective: Ident,
folder: Option[Ident],
name: Option[String],
notes: Option[String]
): TextData =
Item(item, collective, name, notes)
Item(item, collective, folder, name, notes)
}

View File

@ -0,0 +1,9 @@
package docspell.ftssolr
import docspell.common._
final case class DocIdResult(ids: List[Ident]) {
def toSetFolder(folder: Option[Ident]): List[SetFolder] =
ids.map(id => SetFolder(id, folder))
}

View File

@ -25,6 +25,7 @@ object Field {
val content_en = Field("content_en")
val itemName = Field("itemName")
val itemNotes = Field("itemNotes")
val folderId = Field("folder")
def contentField(lang: Language): Field =
lang match {

View File

@ -1,5 +1,7 @@
package docspell.ftssolr
import cats.implicits._
import docspell.common._
import docspell.ftsclient._
@ -21,6 +23,7 @@ trait JsonCodec {
(Field.id.name, enc(td.id)),
(Field.itemId.name, enc(td.item)),
(Field.collectiveId.name, enc(td.collective)),
(Field.folderId.name, td.folder.getOrElse(Ident.unsafe("")).asJson),
(Field.attachmentId.name, enc(td.attachId)),
(Field.attachmentName.name, Json.fromString(td.name.getOrElse(""))),
(Field.discriminator.name, Json.fromString("attachment"))
@ -37,6 +40,7 @@ trait JsonCodec {
(Field.id.name, enc(td.id)),
(Field.itemId.name, enc(td.item)),
(Field.collectiveId.name, enc(td.collective)),
(Field.folderId.name, td.folder.getOrElse(Ident.unsafe("")).asJson),
(Field.itemName.name, Json.fromString(td.name.getOrElse(""))),
(Field.itemNotes.name, Json.fromString(td.notes.getOrElse(""))),
(Field.discriminator.name, Json.fromString("item"))
@ -49,6 +53,18 @@ trait JsonCodec {
): Encoder[TextData] =
Encoder(_.fold(ae.apply, ie.apply))
implicit def docIdResultsDecoder: Decoder[DocIdResult] =
new Decoder[DocIdResult] {
final def apply(c: HCursor): Decoder.Result[DocIdResult] =
c.downField("response")
.downField("docs")
.values
.getOrElse(Nil)
.toList
.traverse(_.hcursor.get[Ident](Field.id.name))
.map(DocIdResult.apply)
}
implicit def ftsResultDecoder: Decoder[FtsResult] =
new Decoder[FtsResult] {
final def apply(c: HCursor): Decoder.Result[FtsResult] =
@ -89,6 +105,12 @@ trait JsonCodec {
} yield md
}
implicit def decodeEverythingToUnit: Decoder[Unit] =
new Decoder[Unit] {
final def apply(c: HCursor): Decoder.Result[Unit] =
Right(())
}
implicit def identKeyEncoder: KeyEncoder[Ident] =
new KeyEncoder[Ident] {
override def apply(ident: Ident): String = ident.id
@ -129,9 +151,24 @@ trait JsonCodec {
}
}
implicit def textDataEncoder: Encoder[SetFields] =
implicit def setTextDataFieldsEncoder: Encoder[SetFields] =
Encoder(_.td.fold(setAttachmentEncoder.apply, setItemEncoder.apply))
implicit def setFolderEncoder(implicit
enc: Encoder[Option[Ident]]
): Encoder[SetFolder] =
new Encoder[SetFolder] {
final def apply(td: SetFolder): Json =
Json.fromFields(
List(
(Field.id.name, td.docId.asJson),
(
Field.folderId.name,
Map("set" -> td.folder.asJson).asJson
)
)
)
}
}
object JsonCodec extends JsonCodec

View File

@ -40,16 +40,26 @@ object QueryData {
fields: List[Field],
fq: FtsQuery
): QueryData = {
val q = sanitize(fq.q)
val extQ = search.map(f => s"${f.name}:($q)").mkString(" OR ")
val items = fq.items.map(_.id).mkString(" ")
val collQ = s"""${Field.collectiveId.name}:"${fq.collective.id}""""
val filterQ = fq.items match {
case s if s.isEmpty =>
collQ
case _ =>
(collQ :: List(s"""${Field.itemId.name}:($items)""")).mkString(" AND ")
}
val q = sanitize(fq.q)
val extQ = search.map(f => s"${f.name}:($q)").mkString(" OR ")
val items = fq.items.map(_.id).mkString(" ")
val folders = fq.folders.map(_.id).mkString(" ")
val filterQ = List(
s"""${Field.collectiveId.name}:"${fq.collective.id}"""",
fq.items match {
case s if s.isEmpty =>
""
case _ =>
s"""${Field.itemId.name}:($items)"""
},
fq.folders match {
case s if s.isEmpty =>
""
case _ =>
s"""${Field.folderId.name}:($folders) OR (*:* NOT ${Field.folderId.name}:*)"""
}
).filterNot(_.isEmpty).map(t => s"($t)").mkString(" AND ")
QueryData(
extQ,
filterQ,

View File

@ -0,0 +1,5 @@
package docspell.ftssolr
import docspell.common._
final case class SetFolder(docId: Ident, folder: Option[Ident])

View File

@ -17,7 +17,7 @@ final class SolrFtsClient[F[_]: Effect](
solrQuery: SolrQuery[F]
) extends FtsClient[F] {
def initialize: F[Unit] =
def initialize: List[FtsMigration[F]] =
solrSetup.setupSchema
def search(q: FtsQuery): F[FtsResult] =
@ -29,6 +29,17 @@ final class SolrFtsClient[F[_]: Effect](
def updateIndex(logger: Logger[F], data: Stream[F, TextData]): F[Unit] =
modifyIndex(logger, data)(solrUpdate.update)
def updateFolder(
logger: Logger[F],
itemId: Ident,
collective: Ident,
folder: Option[Ident]
): F[Unit] =
logger.debug(
s"Update folder in solr index for coll/item ${collective.id}/${itemId.id}"
) *>
solrUpdate.updateFolder(itemId, collective, folder)
def modifyIndex(logger: Logger[F], data: Stream[F, TextData])(
f: List[TextData] => F[Unit]
): F[Unit] =

View File

@ -4,6 +4,7 @@ import cats.effect._
import cats.implicits._
import docspell.common._
import docspell.ftsclient.FtsMigration
import _root_.io.circe._
import _root_.io.circe.generic.semiauto._
@ -15,21 +16,48 @@ import org.http4s.client.dsl.Http4sClientDsl
trait SolrSetup[F[_]] {
def setupSchema: F[Unit]
def setupSchema: List[FtsMigration[F]]
}
object SolrSetup {
private val solrEngine = Ident.unsafe("solr")
def apply[F[_]: ConcurrentEffect](cfg: SolrConfig, client: Client[F]): SolrSetup[F] = {
val dsl = new Http4sClientDsl[F] {}
import dsl._
new SolrSetup[F] {
val url = (Uri.unsafeFromString(cfg.url.asString) / "schema")
.withQueryParam("commitWithin", cfg.commitWithin.toString)
def setupSchema: F[Unit] = {
def setupSchema: List[FtsMigration[F]] =
List(
FtsMigration[F](
1,
solrEngine,
"Initialize",
setupCoreSchema.map(_ => FtsMigration.Result.workDone)
),
FtsMigration[F](
3,
solrEngine,
"Add folder field",
addFolderField.map(_ => FtsMigration.Result.workDone)
),
FtsMigration[F](
4,
solrEngine,
"Index all from database",
FtsMigration.Result.indexAll.pure[F]
)
)
def addFolderField: F[Unit] =
addStringField(Field.folderId)
def setupCoreSchema: F[Unit] = {
val cmds0 =
List(
Field.id,

View File

@ -1,13 +1,16 @@
package docspell.ftssolr
import cats.effect._
import cats.implicits._
import docspell.common._
import docspell.ftsclient._
import docspell.ftssolr.JsonCodec._
import _root_.io.circe._
import _root_.io.circe.syntax._
import org.http4s._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe._
import org.http4s.client.Client
import org.http4s.client.dsl.Http4sClientDsl
@ -18,6 +21,8 @@ trait SolrUpdate[F[_]] {
def update(tds: List[TextData]): F[Unit]
def updateFolder(itemId: Ident, collective: Ident, folder: Option[Ident]): F[Unit]
def delete(q: String, commitWithin: Option[Int]): F[Unit]
}
@ -43,6 +48,29 @@ object SolrUpdate {
client.expect[Unit](req)
}
def updateFolder(
itemId: Ident,
collective: Ident,
folder: Option[Ident]
): F[Unit] = {
val queryUrl = Uri.unsafeFromString(cfg.url.asString) / "query"
val q = QueryData(
"*:*",
s"${Field.itemId.name}:${itemId.id} AND ${Field.collectiveId.name}:${collective.id}",
Int.MaxValue,
0,
List(Field.id),
Map.empty
)
val searchReq = Method.POST(q.asJson, queryUrl)
for {
docIds <- client.expect[DocIdResult](searchReq)
sets = docIds.toSetFolder(folder)
req = Method.POST(sets.asJson, url)
_ <- client.expect[Unit](req)
} yield ()
}
def delete(q: String, commitWithin: Option[Int]): F[Unit] = {
val uri = commitWithin match {
case Some(n) =>

View File

@ -84,6 +84,7 @@ object JoexAppImpl {
joex <- OJoex(client, store)
upload <- OUpload(store, queue, cfg.files, joex)
fts <- createFtsClient(cfg)(httpClient)
itemOps <- OItem(store, fts)
javaEmil =
JavaMailEmil(blocker, Settings.defaultSettings.copy(debug = cfg.mailDebug))
sch <- SchedulerBuilder(cfg.scheduler, blocker, store)
@ -91,7 +92,7 @@ object JoexAppImpl {
.withTask(
JobTask.json(
ProcessItemArgs.taskName,
ItemHandler.newItem[F](cfg, fts),
ItemHandler.newItem[F](cfg, itemOps, fts),
ItemHandler.onCancel[F]
)
)

View File

@ -1,9 +1,8 @@
package docspell.joex.fts
import cats._
import cats.data.{Kleisli, NonEmptyList}
import cats.effect._
import cats.implicits._
import cats.{ApplicativeError, FlatMap, Semigroup}
import docspell.common._
import docspell.ftsclient._
@ -15,6 +14,19 @@ object FtsWork {
def apply[F[_]](f: FtsContext[F] => F[Unit]): FtsWork[F] =
Kleisli(f)
def allInitializeTasks[F[_]: Monad]: FtsWork[F] =
FtsWork[F](_ => ().pure[F]).tap[FtsContext[F]].flatMap { ctx =>
NonEmptyList.fromList(ctx.fts.initialize.map(fm => from[F](fm.task))) match {
case Some(nel) =>
nel.reduce(semigroup[F])
case None =>
FtsWork[F](_ => ().pure[F])
}
}
def from[F[_]: FlatMap: Applicative](t: F[FtsMigration.Result]): FtsWork[F] =
Kleisli.liftF(t).flatMap(transformResult[F])
def all[F[_]: FlatMap](
m0: FtsWork[F],
mn: FtsWork[F]*
@ -24,14 +36,25 @@ object FtsWork {
implicit def semigroup[F[_]: FlatMap]: Semigroup[FtsWork[F]] =
Semigroup.instance((mt1, mt2) => mt1.flatMap(_ => mt2))
private def transformResult[F[_]: Applicative: FlatMap](
r: FtsMigration.Result
): FtsWork[F] =
r match {
case FtsMigration.Result.WorkDone =>
Kleisli.pure(())
case FtsMigration.Result.IndexAll =>
insertAll[F](None)
case FtsMigration.Result.ReIndexAll =>
clearIndex[F](None) >> insertAll[F](None)
}
// some tasks
def log[F[_]](f: Logger[F] => F[Unit]): FtsWork[F] =
FtsWork(ctx => f(ctx.logger))
def initialize[F[_]]: FtsWork[F] =
FtsWork(_.fts.initialize)
def clearIndex[F[_]](coll: Option[Ident]): FtsWork[F] =
coll match {
case Some(cid) =>
@ -40,7 +63,7 @@ object FtsWork {
FtsWork(ctx => ctx.fts.clearAll(ctx.logger))
}
def insertAll[F[_]: Effect](coll: Option[Ident]): FtsWork[F] =
def insertAll[F[_]: FlatMap](coll: Option[Ident]): FtsWork[F] =
FtsWork
.all(
FtsWork(ctx =>
@ -57,6 +80,7 @@ object FtsWork {
caa.item,
caa.id,
caa.collective,
caa.folder,
caa.lang,
caa.name,
caa.content
@ -69,7 +93,9 @@ object FtsWork {
ctx.logger,
ctx.store
.transact(QItem.allNameAndNotes(coll, ctx.cfg.migration.indexAllChunk * 5))
.map(nn => TextData.item(nn.id, nn.collective, Option(nn.name), nn.notes))
.map(nn =>
TextData.item(nn.id, nn.collective, nn.folder, Option(nn.name), nn.notes)
)
)
)
)

View File

@ -1,9 +1,9 @@
package docspell.joex.fts
import cats.Traverse
import cats.data.{Kleisli, OptionT}
import cats.effect._
import cats.implicits._
import cats.{Applicative, FlatMap, Traverse}
import docspell.common._
import docspell.ftsclient._
@ -20,6 +20,9 @@ case class Migration[F[_]](
object Migration {
def from[F[_]: Applicative: FlatMap](fm: FtsMigration[F]): Migration[F] =
Migration(fm.version, fm.engine, fm.description, FtsWork.from(fm.task))
def apply[F[_]: Effect](
cfg: Config.FullTextSearch,
fts: FtsClient[F],

View File

@ -21,7 +21,7 @@ object MigrationTask {
.flatMap(_ =>
Task(ctx =>
Migration[F](cfg, fts, ctx.store, ctx.logger)
.run(migrationTasks[F])
.run(migrationTasks[F](fts))
)
)
@ -44,11 +44,7 @@ object MigrationTask {
Some(DocspellSystem.migrationTaskTracker)
)
private val solrEngine = Ident.unsafe("solr")
def migrationTasks[F[_]: Effect]: List[Migration[F]] =
List(
Migration[F](1, solrEngine, "initialize", FtsWork.initialize[F]),
Migration[F](2, solrEngine, "Index all from database", FtsWork.insertAll[F](None))
)
def migrationTasks[F[_]: Effect](fts: FtsClient[F]): List[Migration[F]] =
fts.initialize.map(fm => Migration.from(fm))
}

View File

@ -21,13 +21,7 @@ object ReIndexTask {
Task
.log[F, Args](_.info(s"Running full-text re-index now"))
.flatMap(_ =>
Task(ctx =>
(clearData[F](ctx.args.collective) ++
FtsWork.log[F](_.info("Inserting data from database")) ++
FtsWork.insertAll[F](
ctx.args.collective
)).forContext(cfg, fts).run(ctx)
)
Task(ctx => clearData[F](ctx.args.collective).forContext(cfg, fts).run(ctx))
)
def onCancel[F[_]: Sync]: Task[F, Args, Unit] =
@ -41,7 +35,9 @@ object ReIndexTask {
.clearIndex(collective)
.recoverWith(
FtsWork.log[F](_.info("Clearing data failed. Continue re-indexing."))
)
) ++
FtsWork.log[F](_.info("Inserting data from database")) ++
FtsWork.insertAll[F](collective)
case None =>
FtsWork
@ -50,6 +46,6 @@ object ReIndexTask {
FtsWork.log[F](_.info("Clearing data failed. Continue re-indexing."))
) ++
FtsWork.log[F](_.info("Running index initialize")) ++
FtsWork.initialize[F]
FtsWork.allInitializeTasks[F]
})
}

View File

@ -71,7 +71,7 @@ object NotifyDueItemsTask {
now <- Timestamp.current[F]
q =
QItem.Query
.empty(ctx.args.account.collective)
.empty(ctx.args.account)
.copy(
states = ItemState.validStates.toList,
tagsInclude = ctx.args.tagsInclude,

View File

@ -251,7 +251,6 @@ object ExtractArchive {
this
case Some(nel) =>
val sorted = nel.sorted
println(s"---------------------------- $sorted ")
val offset = sorted.head.first
val pos =
sorted.zipWithIndex.map({ case (p, i) => p.id -> (i + offset) }).toList.toMap

View File

@ -5,6 +5,7 @@ import cats.effect._
import cats.implicits._
import fs2.Stream
import docspell.backend.ops.OItem
import docspell.common.{ItemState, ProcessItemArgs}
import docspell.ftsclient.FtsClient
import docspell.joex.Config
@ -27,11 +28,12 @@ object ItemHandler {
def newItem[F[_]: ConcurrentEffect: ContextShift](
cfg: Config,
itemOps: OItem[F],
fts: FtsClient[F]
): Task[F, Args, Unit] =
CreateItem[F]
.flatMap(itemStateTask(ItemState.Processing))
.flatMap(safeProcess[F](cfg, fts))
.flatMap(safeProcess[F](cfg, itemOps, fts))
.map(_ => ())
def itemStateTask[F[_]: Sync, A](
@ -48,11 +50,12 @@ object ItemHandler {
def safeProcess[F[_]: ConcurrentEffect: ContextShift](
cfg: Config,
itemOps: OItem[F],
fts: FtsClient[F]
)(data: ItemData): Task[F, Args, ItemData] =
isLastRetry[F].flatMap {
case true =>
ProcessItem[F](cfg, fts)(data).attempt.flatMap({
ProcessItem[F](cfg, itemOps, fts)(data).attempt.flatMap({
case Right(d) =>
Task.pure(d)
case Left(ex) =>
@ -62,7 +65,7 @@ object ItemHandler {
.andThen(_ => Sync[F].raiseError(ex))
})
case false =>
ProcessItem[F](cfg, fts)(data).flatMap(itemStateTask(ItemState.Created))
ProcessItem[F](cfg, itemOps, fts)(data).flatMap(itemStateTask(ItemState.Created))
}
private def markItemCreated[F[_]: Sync]: Task[F, Args, Boolean] =

View File

@ -2,6 +2,7 @@ package docspell.joex.process
import cats.effect._
import docspell.backend.ops.OItem
import docspell.common.ProcessItemArgs
import docspell.ftsclient.FtsClient
import docspell.joex.Config
@ -11,6 +12,7 @@ object ProcessItem {
def apply[F[_]: ConcurrentEffect: ContextShift](
cfg: Config,
itemOps: OItem[F],
fts: FtsClient[F]
)(item: ItemData): Task[F, ProcessItemArgs, ItemData] =
ExtractArchive(item)
@ -22,6 +24,7 @@ object ProcessItem {
.flatMap(analysisOnly[F](cfg))
.flatMap(Task.setProgress(80))
.flatMap(LinkProposal[F])
.flatMap(SetGivenData[F](itemOps))
.flatMap(Task.setProgress(99))
def analysisOnly[F[_]: Sync](

View File

@ -0,0 +1,35 @@
package docspell.joex.process
import cats.effect._
import cats.implicits._
import docspell.backend.ops.OItem
import docspell.common._
import docspell.joex.scheduler.Task
object SetGivenData {
def apply[F[_]: Sync](
ops: OItem[F]
)(data: ItemData): Task[F, ProcessItemArgs, ItemData] =
if (data.item.state.isValid)
Task
.log[F, ProcessItemArgs](_.debug(s"Not setting data on existing item"))
.map(_ => data)
else
Task { ctx =>
val itemId = data.item.id
val folderId = ctx.args.meta.folderId
val collective = ctx.args.meta.collective
for {
_ <- ctx.logger.info("Starting setting given data")
_ <- ctx.logger.debug(s"Set item folder: '${folderId.map(_.id)}'")
e <- ops.setFolder(itemId, folderId, collective).attempt
_ <- e.fold(
ex => ctx.logger.warn(s"Error setting folder: ${ex.getMessage}"),
_ => ().pure[F]
)
} yield data
}
}

View File

@ -33,8 +33,13 @@ object TextExtraction {
)
_ <- ctx.logger.debug("Storing extracted texts")
_ <- txt.toList.traverse(rm => ctx.store.transact(RAttachmentMeta.upsert(rm._1)))
idxItem =
TextData.item(item.item.id, ctx.args.meta.collective, item.item.name.some, None)
idxItem = TextData.item(
item.item.id,
ctx.args.meta.collective,
None, //folder
item.item.name.some,
None
)
_ <- fts.indexData(ctx.logger, (idxItem +: txt.map(_._2)).toSeq: _*)
dur <- start
_ <- ctx.logger.info(s"Text extraction finished in ${dur.formatExact}")
@ -55,6 +60,7 @@ object TextExtraction {
item.item.id,
ra.id,
collective,
None, //folder
lang,
ra.name,
rm.content

View File

@ -143,7 +143,7 @@ object ScanMailboxTask {
folder <- requireFolder(a)(name)
search <- searchMails(a)(folder)
headers <- Kleisli.liftF(filterMessageIds(search.mails))
_ <- headers.traverse(handleOne(a, upload))
_ <- headers.traverse(handleOne(ctx.args, a, upload))
} yield ScanResult(name, search.mails.size, search.count - search.mails.size)
def requireFolder[C](a: Access[F, C])(name: String): MailOp[F, C, MailFolder] =
@ -239,7 +239,9 @@ object ScanMailboxTask {
MailOp.pure(())
}
def submitMail(upload: OUpload[F])(mail: Mail[F]): F[OUpload.UploadResult] = {
def submitMail(upload: OUpload[F], args: Args)(
mail: Mail[F]
): F[OUpload.UploadResult] = {
val file = OUpload.File(
Some(mail.header.subject + ".eml"),
Some(MimeType.emls.head),
@ -251,6 +253,7 @@ object ScanMailboxTask {
meta = OUpload.UploadMeta(
Some(dir),
s"mailbox-${ctx.args.account.user.id}",
args.itemFolder,
Seq.empty
)
data = OUpload.UploadData(
@ -264,14 +267,14 @@ object ScanMailboxTask {
} yield res
}
def handleOne[C](a: Access[F, C], upload: OUpload[F])(
def handleOne[C](args: Args, a: Access[F, C], upload: OUpload[F])(
mh: MailHeader
): MailOp[F, C, Unit] =
for {
mail <- a.loadMail(mh)
res <- mail match {
case Some(m) =>
Kleisli.liftF(submitMail(upload)(m).attempt)
Kleisli.liftF(submitMail(upload, args)(m).attempt)
case None =>
MailOp.pure[F, C, Either[Throwable, OUpload.UploadResult]](
Either.left(new Exception(s"Mail not found"))

View File

@ -144,6 +144,7 @@ structure:
```
{ multiple: Bool
, direction: Maybe String
, folder: Maybe String
}
```
@ -156,6 +157,11 @@ Furthermore, the direction of the document (one of `incoming` or
`outgoing`) can be given. It is optional, it can be left out or
`null`.
A `folder` id can be specified. Each item created by this request will
be placed into this folder. Errors are logged (for example, the folder
may have been deleted before the task is executed) and the item is
then not put into any folder.
This kind of request is very common and most programming languages
have support for this. For example, here is another curl command
uploading two files with meta data:

View File

@ -795,6 +795,140 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/folder:
get:
tags: [ Folder ]
summary: Get a list of folders.
description: |
Return a list of folders for the current collective.
All folders are returned, including those not owned by the
current user.
It is possible to restrict the results by a substring match of
the name.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/owning"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/FolderList"
post:
tags: [ Folder ]
summary: Create a new folder
description: |
Create a new folder owned by the current user. If a folder with
the same name already exists, an error is thrown.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/NewFolder"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/IdResult"
/sec/folder/{id}:
get:
tags: [ Folder ]
summary: Get folder details.
description: |
Return details about a folder.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/FolderDetail"
put:
tags: [ Folder ]
summary: Change the name of a folder
description: |
Changes the name of a folder. The new name must not exists.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/NewFolder"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
delete:
tags: [ Folder ]
summary: Delete a folder by its id.
description: |
Deletes a folder.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/folder/{id}/member/{userId}:
put:
tags: [ Folder ]
summary: Add a member to this folder
description: |
Adds a member to this folder (identified by `id`).
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/userId"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
delete:
tags: [ Folder ]
summary: Removes a member from this folder.
description: |
Removes a member from this folder.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/userId"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/collective:
get:
tags: [ Collective ]
@ -850,7 +984,7 @@ paths:
summary: Get some insights regarding your items.
description: |
Returns some information about how many items there are, how
much space they occupy etc.
much folder they occupy etc.
security:
- authTokenHeader: []
responses:
@ -1231,6 +1365,31 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/{id}/folder:
put:
tags: [ Item ]
summary: Set a folder for this item.
description: |
Updates the folder property for this item to "place" the item
into a folder. If the request contains an empty object or an
`id` property of `null`, the item is moved into the "public"
or "root" folder.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/OptionalId"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/{id}/corrOrg:
put:
tags: [ Item ]
@ -2358,6 +2517,90 @@ paths:
components:
schemas:
FolderList:
description: |
A list of folders with their member counts.
required:
- items
properties:
items:
type: array
items:
$ref: "#/components/schemas/FolderItem"
FolderItem:
description: |
An item in a folder list.
required:
- id
- name
- owner
- created
- isMember
- memberCount
properties:
id:
type: string
format: ident
name:
type: string
owner:
$ref: "#/components/schemas/IdName"
created:
type: integer
format: date-time
isMember:
type: boolean
memberCount:
type: integer
format: int32
NewFolder:
description: |
Data required to create a new folder.
required:
- name
properties:
name:
type: string
FolderDetail:
description: |
Details about a folder.
required:
- id
- name
- owner
- created
- isMember
- memberCount
- members
properties:
id:
type: string
format: ident
name:
type: string
owner:
$ref: "#/components/schemas/IdName"
created:
type: integer
format: date-time
isMember:
type: boolean
memberCount:
type: integer
format: int32
members:
type: array
items:
$ref: "#/components/schemas/IdName"
FolderMember:
description: |
Information to add or remove a folder member.
required:
- userId
properties:
userId:
type: string
format: ident
ItemFtsSearch:
description: |
Query description for a full-text only search.
@ -2451,6 +2694,13 @@ components:
The direction to apply to items resulting from importing
mails. If not set, the value is guessed based on the from
and to mail headers and your address book.
itemFolder:
type: string
format: ident
description: |
The folder id that is applied to items resulting from
importing mails. If the folder id is not valid when the
task executes, items have no folder set.
ImapSettingsList:
description: |
A list of user email settings.
@ -2949,6 +3199,8 @@ components:
$ref: "#/components/schemas/IdName"
inReplyTo:
$ref: "#/components/schemas/IdName"
folder:
$ref: "#/components/schemas/IdName"
dueDate:
type: integer
format: date-time
@ -3153,11 +3405,15 @@ components:
description: |
A user of a collective.
required:
- id
- login
- state
- loginCount
- created
properties:
id:
type: string
format: ident
login:
type: string
format: ident
@ -3188,9 +3444,15 @@ components:
Meta information for an item upload. The user can specify some
structured information with a binary file.
Additional metadata is not required. However, you have to
specifiy whether the corresponding files should become one
single item or if an item is created for each file.
Additional metadata is not required. However, if there is some
specified, you have to specifiy whether the corresponding
files should become one single item or if an item is created
for each file.
A direction can be given, `Incoming` is used if not specified.
A folderId can be given, the item is placed into this folder
after creation.
required:
- multiple
properties:
@ -3200,6 +3462,9 @@ components:
direction:
type: string
format: direction
folder:
type: string
format: ident
Collective:
description: |
Information about a collective.
@ -3270,6 +3535,9 @@ components:
priority:
type: string
format: priority
folder:
type: string
format: ident
created:
description: DateTime
type: integer
@ -3527,6 +3795,9 @@ components:
concEquip:
type: string
format: ident
folder:
type: string
format: ident
dateFrom:
type: integer
format: date-time
@ -3580,6 +3851,8 @@ components:
$ref: "#/components/schemas/IdName"
concEquip:
$ref: "#/components/schemas/IdName"
folder:
$ref: "#/components/schemas/IdName"
fileCount:
type: integer
format: int32
@ -3633,6 +3906,22 @@ components:
type: boolean
message:
type: string
IdResult:
description: |
Some basic result of an operation with an ID as payload. If
success if `false` the id is not usable.
required:
- success
- message
- id
properties:
success:
type: boolean
message:
type: string
id:
type: string
format: ident
Tag:
description: |
A tag used to annotate items. A tag may have a category which
@ -3739,6 +4028,13 @@ components:
required: true
schema:
type: string
userId:
name: userId
in: path
description: An identifier
required: true
schema:
type: string
itemId:
name: itemId
in: path
@ -3753,6 +4049,13 @@ components:
required: false
schema:
type: boolean
owning:
name: full
in: query
description: Whether to get owning folders
required: false
schema:
type: boolean
checksum:
name: checksum
in: path

View File

@ -81,7 +81,8 @@ object RestServer {
"usertask/notifydueitems" -> NotifyDueItemsRoutes(cfg, restApp.backend, token),
"usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token),
"calevent/check" -> CalEventCheckRoutes(),
"fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token)
"fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token),
"folder" -> FolderRoutes(restApp.backend, token)
)
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =

View File

@ -15,8 +15,8 @@ import docspell.common.syntax.all._
import docspell.ftsclient.FtsResult
import docspell.restapi.model._
import docspell.restserver.conv.Conversions._
import docspell.store.AddResult
import docspell.store.records._
import docspell.store.{AddResult, UpdateResult}
import bitpeace.FileMeta
import org.http4s.headers.`Content-Type`
@ -85,6 +85,7 @@ trait Conversions {
data.concPerson.map(p => IdName(p.pid, p.name)),
data.concEquip.map(e => IdName(e.eid, e.name)),
data.inReplyTo.map(mkIdName),
data.folder.map(mkIdName),
data.item.dueDate,
data.item.notes,
data.attachments.map((mkAttachment(data) _).tupled).toList,
@ -109,9 +110,9 @@ trait Conversions {
// item list
def mkQuery(m: ItemSearch, coll: Ident): OItemSearch.Query =
def mkQuery(m: ItemSearch, account: AccountId): OItemSearch.Query =
OItemSearch.Query(
coll,
account,
m.name,
if (m.inbox) Seq(ItemState.Created)
else ItemState.validStates.toList,
@ -120,6 +121,7 @@ trait Conversions {
m.corrOrg,
m.concPerson,
m.concEquip,
m.folder,
m.tagsInclude.map(Ident.unsafe),
m.tagsExclude.map(Ident.unsafe),
m.dateFrom,
@ -192,6 +194,7 @@ trait Conversions {
i.corrPerson.map(mkIdName),
i.concPerson.map(mkIdName),
i.concEquip.map(mkIdName),
i.folder.map(mkIdName),
i.fileCount,
Nil,
Nil
@ -284,9 +287,11 @@ trait Conversions {
.find(_.name.exists(_.equalsIgnoreCase("meta")))
.map(p => parseMeta(p.body))
.map(fm =>
fm.map(m => (m.multiple, UploadMeta(m.direction, "webapp", validFileTypes)))
fm.map(m =>
(m.multiple, UploadMeta(m.direction, "webapp", m.folder, validFileTypes))
)
)
.getOrElse((true, UploadMeta(None, "webapp", validFileTypes)).pure[F])
.getOrElse((true, UploadMeta(None, "webapp", None, validFileTypes)).pure[F])
val files = mp.parts
.filter(p => p.name.forall(s => !s.equalsIgnoreCase("meta")))
@ -431,7 +436,16 @@ trait Conversions {
// users
def mkUser(ru: RUser): User =
User(ru.login, ru.state, None, ru.email, ru.lastLogin, ru.loginCount, ru.created)
User(
ru.uid,
ru.login,
ru.state,
None,
ru.email,
ru.lastLogin,
ru.loginCount,
ru.created
)
def newUser[F[_]: Sync](u: User, cid: Ident): F[RUser] =
timeId.map {
@ -451,7 +465,7 @@ trait Conversions {
def changeUser(u: User, cid: Ident): RUser =
RUser(
Ident.unsafe(""),
u.id,
u.login,
cid,
u.password.getOrElse(Password.empty),
@ -479,12 +493,21 @@ trait Conversions {
// sources
def mkSource(s: RSource): Source =
Source(s.sid, s.abbrev, s.description, s.counter, s.enabled, s.priority, s.created)
Source(
s.sid,
s.abbrev,
s.description,
s.counter,
s.enabled,
s.priority,
s.folderId,
s.created
)
def newSource[F[_]: Sync](s: Source, cid: Ident): F[RSource] =
timeId.map({
case (id, now) =>
RSource(id, cid, s.abbrev, s.description, 0, s.enabled, s.priority, now)
RSource(id, cid, s.abbrev, s.description, 0, s.enabled, s.priority, now, s.folder)
})
def changeSource[F[_]: Sync](s: Source, coll: Ident): RSource =
@ -496,7 +519,8 @@ trait Conversions {
s.counter,
s.enabled,
s.priority,
s.created
s.created,
s.folder
)
// equipment
@ -528,6 +552,14 @@ trait Conversions {
BasicResult(true, "The job has been removed from the queue.")
}
def idResult(ar: AddResult, id: Ident, successMsg: String): IdResult =
ar match {
case AddResult.Success => IdResult(true, successMsg, id)
case AddResult.EntityExists(msg) => IdResult(false, msg, Ident.unsafe(""))
case AddResult.Failure(ex) =>
IdResult(false, s"Internal error: ${ex.getMessage}", Ident.unsafe(""))
}
def basicResult(ar: AddResult, successMsg: String): BasicResult =
ar match {
case AddResult.Success => BasicResult(true, successMsg)
@ -536,6 +568,14 @@ trait Conversions {
BasicResult(false, s"Internal error: ${ex.getMessage}")
}
def basicResult(ar: UpdateResult, successMsg: String): BasicResult =
ar match {
case UpdateResult.Success => BasicResult(true, successMsg)
case UpdateResult.NotFound => BasicResult(false, "Not found")
case UpdateResult.Failure(ex) =>
BasicResult(false, s"Internal error: ${ex.getMessage}")
}
def basicResult(ur: OUpload.UploadResult): BasicResult =
ur match {
case UploadResult.Success => BasicResult(true, "Files submitted.")

View File

@ -24,6 +24,8 @@ object QueryParam {
object FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full")
object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning")
object ContactKindOpt extends OptionalQueryParamDecoderMatcher[ContactKind]("kind")
object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q")

View File

@ -0,0 +1,113 @@
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.OFolder
import docspell.common._
import docspell.restapi.model._
import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._
import docspell.store.records.RFolder
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object FolderRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case GET -> Root :? QueryParam.QueryOpt(q) :? QueryParam.OwningOpt(owning) =>
val login =
owning.filter(identity).map(_ => user.account.user)
for {
all <- backend.folder.findAll(user.account, login, q.map(_.q))
resp <- Ok(FolderList(all.map(mkFolder).toList))
} yield resp
case req @ POST -> Root =>
for {
data <- req.as[NewFolder]
nfolder <- newFolder(data, user.account)
res <- backend.folder.add(nfolder, Some(user.account.user))
resp <-
Ok(Conversions.idResult(res, nfolder.id, "Folder successfully created."))
} yield resp
case GET -> Root / Ident(id) =>
(for {
folder <- OptionT(backend.folder.findById(id, user.account))
resp <- OptionT.liftF(Ok(mkFolderDetail(folder)))
} yield resp).getOrElseF(NotFound())
case req @ PUT -> Root / Ident(id) =>
for {
data <- req.as[NewFolder]
res <- backend.folder.changeName(id, user.account, data.name)
resp <- Ok(mkFolderChangeResult(res))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
res <- backend.folder.delete(id, user.account)
resp <- Ok(mkFolderChangeResult(res))
} yield resp
case PUT -> Root / Ident(id) / "member" / Ident(userId) =>
for {
res <- backend.folder.addMember(id, user.account, userId)
resp <- Ok(mkFolderChangeResult(res))
} yield resp
case DELETE -> Root / Ident(id) / "member" / Ident(userId) =>
for {
res <- backend.folder.removeMember(id, user.account, userId)
resp <- Ok(mkFolderChangeResult(res))
} yield resp
}
}
private def newFolder[F[_]: Sync](ns: NewFolder, account: AccountId): F[RFolder] =
RFolder.newFolder(ns.name, account)
private def mkFolder(item: OFolder.FolderItem): FolderItem =
FolderItem(
item.id,
item.name,
Conversions.mkIdName(item.owner),
item.created,
item.member,
item.memberCount
)
private def mkFolderDetail(item: OFolder.FolderDetail): FolderDetail =
FolderDetail(
item.id,
item.name,
Conversions.mkIdName(item.owner),
item.created,
item.member,
item.memberCount,
item.members.map(Conversions.mkIdName)
)
private def mkFolderChangeResult(r: OFolder.FolderChangeResult): BasicResult =
r match {
case OFolder.FolderChangeResult.Success =>
BasicResult(true, "Successfully changed folder.")
case OFolder.FolderChangeResult.NotFound =>
BasicResult(false, "Folder or user not found.")
case OFolder.FolderChangeResult.Forbidden =>
BasicResult(false, "Not allowed to edit folder.")
case OFolder.FolderChangeResult.Exists =>
BasicResult(false, "The member already exists.")
}
}

View File

@ -35,7 +35,7 @@ object ItemRoutes {
for {
mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask")
query = Conversions.mkQuery(mask, user.account.collective)
query = Conversions.mkQuery(mask, user.account)
_ <- logger.ftrace(s"Running query: $query")
resp <- mask.fullText match {
case Some(fq) if cfg.fullTextSearch.enabled =>
@ -62,7 +62,7 @@ object ItemRoutes {
for {
mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask")
query = Conversions.mkQuery(mask, user.account.collective)
query = Conversions.mkQuery(mask, user.account)
_ <- logger.ftrace(s"Running query: $query")
resp <- mask.fullText match {
case Some(fq) if cfg.fullTextSearch.enabled =>
@ -94,7 +94,7 @@ object ItemRoutes {
for {
items <- backend.fulltext.findIndexOnly(
ftsIn,
user.account.collective,
user.account,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items))
@ -149,6 +149,13 @@ object ItemRoutes {
resp <- Ok(Conversions.basicResult(res, "Direction updated"))
} yield resp
case req @ PUT -> Root / Ident(id) / "folder" =>
for {
idref <- req.as[OptionalId]
res <- backend.item.setFolder(id, idref.id, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Folder updated"))
} yield resp
case req @ PUT -> Root / Ident(id) / "corrOrg" =>
for {
idref <- req.as[OptionalId]

View File

@ -112,7 +112,8 @@ object ScanMailboxRoutes {
settings.receivedSinceHours.map(_.toLong).map(Duration.hours),
settings.targetFolder,
settings.deleteMail,
settings.direction
settings.direction,
settings.itemFolder
)
)
)
@ -139,6 +140,7 @@ object ScanMailboxRoutes {
task.args.receivedSince.map(_.hours.toInt),
task.args.targetFolder,
task.args.deleteMail,
task.args.direction
task.args.direction,
task.args.itemFolder
)
}

View File

@ -0,0 +1,34 @@
CREATE TABLE `folder` (
`id` varchar(254) not null primary key,
`name` varchar(254) not null,
`cid` varchar(254) not null,
`owner` varchar(254) not null,
`created` timestamp not null,
unique (`name`, `cid`),
foreign key (`cid`) references `collective`(`cid`),
foreign key (`owner`) references `user_`(`uid`)
);
CREATE TABLE `folder_member` (
`id` varchar(254) not null primary key,
`folder_id` varchar(254) not null,
`user_id` varchar(254) not null,
`created` timestamp not null,
unique (`folder_id`, `user_id`),
foreign key (`folder_id`) references `folder`(`id`),
foreign key (`user_id`) references `user_`(`uid`)
);
ALTER TABLE `item`
ADD COLUMN `folder_id` varchar(254) NULL;
ALTER TABLE `item`
ADD FOREIGN KEY (`folder_id`)
REFERENCES `folder`(`id`);
ALTER TABLE `source`
ADD COLUMN `folder_id` varchar(254) NULL;
ALTER TABLE `source`
ADD FOREIGN KEY (`folder_id`)
REFERENCES `folder`(`id`);

View File

@ -0,0 +1,34 @@
CREATE TABLE "folder" (
"id" varchar(254) not null primary key,
"name" varchar(254) not null,
"cid" varchar(254) not null,
"owner" varchar(254) not null,
"created" timestamp not null,
unique ("name", "cid"),
foreign key ("cid") references "collective"("cid"),
foreign key ("owner") references "user_"("uid")
);
CREATE TABLE "folder_member" (
"id" varchar(254) not null primary key,
"folder_id" varchar(254) not null,
"user_id" varchar(254) not null,
"created" timestamp not null,
unique ("folder_id", "user_id"),
foreign key ("folder_id") references "folder"("id"),
foreign key ("user_id") references "user_"("uid")
);
ALTER TABLE "item"
ADD COLUMN "folder_id" varchar(254) NULL;
ALTER TABLE "item"
ADD FOREIGN KEY ("folder_id")
REFERENCES "folder"("id");
ALTER TABLE "source"
ADD COLUMN "folder_id" varchar(254) NULL;
ALTER TABLE "source"
ADD FOREIGN KEY ("folder_id")
REFERENCES "folder"("id");

View File

@ -0,0 +1,29 @@
package docspell.store
import cats.ApplicativeError
import cats.implicits._
sealed trait UpdateResult
object UpdateResult {
case object Success extends UpdateResult
case object NotFound extends UpdateResult
final case class Failure(ex: Throwable) extends UpdateResult
def success: UpdateResult = Success
def notFound: UpdateResult = NotFound
def failure(ex: Throwable): UpdateResult = Failure(ex)
def fromUpdateRows(n: Int): UpdateResult =
if (n > 0) success
else notFound
def fromUpdate[F[_]](
fn: F[Int]
)(implicit ev: ApplicativeError[F, Throwable]): F[UpdateResult] =
fn.attempt.map {
case Right(n) => fromUpdateRows(n)
case Left(ex) => failure(ex)
}
}

View File

@ -145,6 +145,7 @@ object QAttachment {
id: Ident,
item: Ident,
collective: Ident,
folder: Option[Ident],
lang: Language,
name: Option[String],
content: Option[String]
@ -160,10 +161,11 @@ object QAttachment {
val mContent = RAttachmentMeta.Columns.content.prefix("m")
val iId = RItem.Columns.id.prefix("i")
val iColl = RItem.Columns.cid.prefix("i")
val iFolder = RItem.Columns.folder.prefix("i")
val cId = RCollective.Columns.id.prefix("c")
val cLang = RCollective.Columns.language.prefix("c")
val cols = Seq(aId, aItem, iColl, cLang, aName, mContent)
val cols = Seq(aId, aItem, iColl, iFolder, cLang, aName, mContent)
val from = RAttachment.table ++ fr"a INNER JOIN" ++
RAttachmentMeta.table ++ fr"m ON" ++ aId.is(mId) ++
fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ iId.is(aItem) ++

View File

@ -0,0 +1,279 @@
package docspell.store.queries
import cats.data.OptionT
import cats.implicits._
import docspell.common._
import docspell.store.impl.Implicits._
import docspell.store.records._
import doobie._
import doobie.implicits._
object QFolder {
final case class FolderItem(
id: Ident,
name: String,
owner: IdRef,
created: Timestamp,
member: Boolean,
memberCount: Int
) {
def withMembers(members: List[IdRef]): FolderDetail =
FolderDetail(id, name, owner, created, member, memberCount, members)
}
final case class FolderDetail(
id: Ident,
name: String,
owner: IdRef,
created: Timestamp,
member: Boolean,
memberCount: Int,
members: List[IdRef]
)
sealed trait FolderChangeResult
object FolderChangeResult {
case object Success extends FolderChangeResult
def success: FolderChangeResult = Success
case object NotFound extends FolderChangeResult
def notFound: FolderChangeResult = NotFound
case object Forbidden extends FolderChangeResult
def forbidden: FolderChangeResult = Forbidden
case object Exists extends FolderChangeResult
def exists: FolderChangeResult = Exists
}
def delete(id: Ident, account: AccountId): ConnectionIO[FolderChangeResult] = {
def tryDelete =
for {
_ <- RItem.removeFolder(id)
_ <- RSource.removeFolder(id)
_ <- RFolderMember.deleteAll(id)
_ <- RFolder.delete(id)
} yield FolderChangeResult.success
(for {
uid <- OptionT(findUserId(account))
folder <- OptionT(RFolder.findById(id))
res <- OptionT.liftF(
if (folder.owner == uid) tryDelete
else FolderChangeResult.forbidden.pure[ConnectionIO]
)
} yield res).getOrElse(FolderChangeResult.notFound)
}
def changeName(
folder: Ident,
account: AccountId,
name: String
): ConnectionIO[FolderChangeResult] = {
def tryUpdate(ns: RFolder): ConnectionIO[FolderChangeResult] =
for {
n <- RFolder.update(ns)
res =
if (n == 0) FolderChangeResult.notFound
else FolderChangeResult.Success
} yield res
(for {
uid <- OptionT(findUserId(account))
folder <- OptionT(RFolder.findById(folder))
res <- OptionT.liftF(
if (folder.owner == uid) tryUpdate(folder.copy(name = name))
else FolderChangeResult.forbidden.pure[ConnectionIO]
)
} yield res).getOrElse(FolderChangeResult.notFound)
}
def removeMember(
folder: Ident,
account: AccountId,
member: Ident
): ConnectionIO[FolderChangeResult] = {
def tryRemove: ConnectionIO[FolderChangeResult] =
for {
n <- RFolderMember.delete(member, folder)
res =
if (n == 0) FolderChangeResult.notFound
else FolderChangeResult.Success
} yield res
(for {
uid <- OptionT(findUserId(account))
folder <- OptionT(RFolder.findById(folder))
res <- OptionT.liftF(
if (folder.owner == uid) tryRemove
else FolderChangeResult.forbidden.pure[ConnectionIO]
)
} yield res).getOrElse(FolderChangeResult.notFound)
}
def addMember(
folder: Ident,
account: AccountId,
member: Ident
): ConnectionIO[FolderChangeResult] = {
def tryAdd: ConnectionIO[FolderChangeResult] =
for {
spm <- RFolderMember.findByUserId(member, folder)
mem <- RFolderMember.newMember[ConnectionIO](folder, member)
res <-
if (spm.isDefined) FolderChangeResult.exists.pure[ConnectionIO]
else RFolderMember.insert(mem).map(_ => FolderChangeResult.Success)
} yield res
(for {
uid <- OptionT(findUserId(account))
folder <- OptionT(RFolder.findById(folder))
res <- OptionT.liftF(
if (folder.owner == uid) tryAdd
else FolderChangeResult.forbidden.pure[ConnectionIO]
)
} yield res).getOrElse(FolderChangeResult.notFound)
}
def findById(id: Ident, account: AccountId): ConnectionIO[Option[FolderDetail]] = {
val mUserId = RFolderMember.Columns.user.prefix("m")
val mFolderId = RFolderMember.Columns.folder.prefix("m")
val uId = RUser.Columns.uid.prefix("u")
val uLogin = RUser.Columns.login.prefix("u")
val sColl = RFolder.Columns.collective.prefix("s")
val sId = RFolder.Columns.id.prefix("s")
val from = RFolderMember.table ++ fr"m INNER JOIN" ++
RUser.table ++ fr"u ON" ++ mUserId.is(uId) ++ fr"INNER JOIN" ++
RFolder.table ++ fr"s ON" ++ mFolderId.is(sId)
val memberQ = selectSimple(
Seq(uId, uLogin),
from,
and(mFolderId.is(id), sColl.is(account.collective))
).query[IdRef].to[Vector]
(for {
folder <- OptionT(findAll(account, Some(id), None, None).map(_.headOption))
memb <- OptionT.liftF(memberQ)
} yield folder.withMembers(memb.toList)).value
}
def findAll(
account: AccountId,
idQ: Option[Ident],
ownerLogin: Option[Ident],
nameQ: Option[String]
): ConnectionIO[Vector[FolderItem]] = {
// with memberlogin as
// (select m.folder_id,u.login
// from folder_member m
// inner join user_ u on u.uid = m.user_id
// inner join folder s on s.id = m.folder_id
// where s.cid = 'eike'
// union all
// select s.id,u.login
// from folder s
// inner join user_ u on u.uid = s.owner
// where s.cid = 'eike')
// select s.id
// ,s.name
// ,s.owner
// ,u.login
// ,s.created
// ,(select count(*) > 0 from memberlogin where folder_id = s.id and login = 'eike') as member
// ,(select count(*) - 1 from memberlogin where folder_id = s.id) as member_count
// from folder s
// inner join user_ u on u.uid = s.owner
// where s.cid = 'eike';
val uId = RUser.Columns.uid.prefix("u")
val uLogin = RUser.Columns.login.prefix("u")
val sId = RFolder.Columns.id.prefix("s")
val sOwner = RFolder.Columns.owner.prefix("s")
val sName = RFolder.Columns.name.prefix("s")
val sColl = RFolder.Columns.collective.prefix("s")
val mUser = RFolderMember.Columns.user.prefix("m")
val mFolder = RFolderMember.Columns.folder.prefix("m")
//CTE
val cte: Fragment = {
val from1 = RFolderMember.table ++ fr"m INNER JOIN" ++
RUser.table ++ fr"u ON" ++ uId.is(mUser) ++ fr"INNER JOIN" ++
RFolder.table ++ fr"s ON" ++ sId.is(mFolder)
val from2 = RFolder.table ++ fr"s INNER JOIN" ++
RUser.table ++ fr"u ON" ++ uId.is(sOwner)
withCTE(
"memberlogin" ->
(selectSimple(Seq(mFolder, uLogin), from1, sColl.is(account.collective)) ++
fr"UNION ALL" ++
selectSimple(Seq(sId, uLogin), from2, sColl.is(account.collective)))
)
}
val isMember =
fr"SELECT COUNT(*) > 0 FROM memberlogin WHERE" ++ mFolder.prefix("").is(sId) ++
fr"AND" ++ uLogin.prefix("").is(account.user)
val memberCount =
fr"SELECT COUNT(*) - 1 FROM memberlogin WHERE" ++ mFolder.prefix("").is(sId)
//Query
val cols = Seq(
sId.f,
sName.f,
sOwner.f,
uLogin.f,
RFolder.Columns.created.prefix("s").f,
fr"(" ++ isMember ++ fr") as mem",
fr"(" ++ memberCount ++ fr") as cnt"
)
val from = RFolder.table ++ fr"s INNER JOIN" ++
RUser.table ++ fr"u ON" ++ uId.is(sOwner)
val where =
sColl.is(account.collective) :: idQ.toList
.map(id => sId.is(id)) ::: nameQ.toList.map(q =>
sName.lowerLike(s"%${q.toLowerCase}%")
) ::: ownerLogin.toList.map(login => uLogin.is(login))
(cte ++ selectSimple(commas(cols), from, and(where) ++ orderBy(sName.asc)))
.query[FolderItem]
.to[Vector]
}
/** Select all folder_id where the given account is member or owner. */
def findMemberFolderIds(account: AccountId): Fragment = {
val fId = RFolder.Columns.id.prefix("f")
val fOwner = RFolder.Columns.owner.prefix("f")
val fColl = RFolder.Columns.collective.prefix("f")
val uId = RUser.Columns.uid.prefix("u")
val uLogin = RUser.Columns.login.prefix("u")
val mFolder = RFolderMember.Columns.folder.prefix("m")
val mUser = RFolderMember.Columns.user.prefix("m")
selectSimple(
Seq(fId),
RFolder.table ++ fr"f INNER JOIN" ++ RUser.table ++ fr"u ON" ++ fOwner.is(uId),
and(fColl.is(account.collective), uLogin.is(account.user))
) ++
fr"UNION ALL" ++
selectSimple(
Seq(mFolder),
RFolderMember.table ++ fr"m INNER JOIN" ++ RFolder.table ++ fr"f ON" ++ fId.is(
mFolder
) ++
fr"INNER JOIN" ++ RUser.table ++ fr"u ON" ++ uId.is(mUser),
and(fColl.is(account.collective), uLogin.is(account.user))
)
}
def getMemberFolders(account: AccountId): ConnectionIO[Set[Ident]] =
findMemberFolderIds(account).query[Ident].to[Set]
private def findUserId(account: AccountId): ConnectionIO[Option[Ident]] =
RUser.findByAccount(account).map(_.map(_.uid))
}

View File

@ -66,6 +66,7 @@ object QItem {
concPerson: Option[RPerson],
concEquip: Option[REquipment],
inReplyTo: Option[IdRef],
folder: Option[IdRef],
tags: Vector[RTag],
attachments: Vector[(RAttachment, FileMeta)],
sources: Vector[(RAttachmentSource, FileMeta)],
@ -83,10 +84,11 @@ object QItem {
val P1C = RPerson.Columns.all.map(_.prefix("p1"))
val EC = REquipment.Columns.all.map(_.prefix("e"))
val ICC = List(RItem.Columns.id, RItem.Columns.name).map(_.prefix("ref"))
val FC = List(RFolder.Columns.id, RFolder.Columns.name).map(_.prefix("f"))
val cq =
selectSimple(
IC ++ OC ++ P0C ++ P1C ++ EC ++ ICC,
IC ++ OC ++ P0C ++ P1C ++ EC ++ ICC ++ FC,
RItem.table ++ fr"i",
Fragment.empty
) ++
@ -105,6 +107,9 @@ object QItem {
fr"LEFT JOIN" ++ RItem.table ++ fr"ref ON" ++ RItem.Columns.inReplyTo
.prefix("i")
.is(RItem.Columns.id.prefix("ref")) ++
fr"LEFT JOIN" ++ RFolder.table ++ fr"f ON" ++ RItem.Columns.folder
.prefix("i")
.is(RFolder.Columns.id.prefix("f")) ++
fr"WHERE" ++ RItem.Columns.id.prefix("i").is(id)
val q = cq
@ -115,6 +120,7 @@ object QItem {
Option[RPerson],
Option[RPerson],
Option[REquipment],
Option[IdRef],
Option[IdRef]
)
]
@ -132,7 +138,7 @@ object QItem {
arch <- archives
ts <- tags
} yield data.map(d =>
ItemData(d._1, d._2, d._3, d._4, d._5, d._6, ts, att, srcs, arch)
ItemData(d._1, d._2, d._3, d._4, d._5, d._6, d._7, ts, att, srcs, arch)
)
}
@ -149,11 +155,12 @@ object QItem {
corrOrg: Option[IdRef],
corrPerson: Option[IdRef],
concPerson: Option[IdRef],
concEquip: Option[IdRef]
concEquip: Option[IdRef],
folder: Option[IdRef]
)
case class Query(
collective: Ident,
account: AccountId,
name: Option[String],
states: Seq[ItemState],
direction: Option[Direction],
@ -161,6 +168,7 @@ object QItem {
corrOrg: Option[Ident],
concPerson: Option[Ident],
concEquip: Option[Ident],
folder: Option[Ident],
tagsInclude: List[Ident],
tagsExclude: List[Ident],
dateFrom: Option[Timestamp],
@ -173,9 +181,9 @@ object QItem {
)
object Query {
def empty(collective: Ident): Query =
def empty(account: AccountId): Query =
Query(
collective,
account,
None,
Seq.empty,
None,
@ -183,6 +191,7 @@ object QItem {
None,
None,
None,
None,
Nil,
Nil,
None,
@ -227,10 +236,12 @@ object QItem {
val PC = RPerson.Columns
val OC = ROrganization.Columns
val EC = REquipment.Columns
val FC = RFolder.Columns
val itemCols = IC.all
val personCols = List(RPerson.Columns.pid, RPerson.Columns.name)
val orgCols = List(ROrganization.Columns.oid, ROrganization.Columns.name)
val equipCols = List(REquipment.Columns.eid, REquipment.Columns.name)
val personCols = List(PC.pid, PC.name)
val orgCols = List(OC.oid, OC.name)
val equipCols = List(EC.eid, EC.name)
val folderCols = List(FC.id, FC.name)
val finalCols = commas(
Seq(
@ -251,6 +262,8 @@ object QItem {
PC.name.prefix("p1").f,
EC.eid.prefix("e1").f,
EC.name.prefix("e1").f,
FC.id.prefix("f1").f,
FC.name.prefix("f1").f,
q.orderAsc match {
case Some(co) =>
coalesce(co(IC).prefix("i").f, IC.created.prefix("i").f)
@ -260,21 +273,27 @@ object QItem {
) ++ moreCols
)
val withItem = selectSimple(itemCols, RItem.table, IC.cid.is(q.collective))
val withPerson = selectSimple(personCols, RPerson.table, PC.cid.is(q.collective))
val withOrgs = selectSimple(orgCols, ROrganization.table, OC.cid.is(q.collective))
val withEquips = selectSimple(equipCols, REquipment.table, EC.cid.is(q.collective))
val withItem = selectSimple(itemCols, RItem.table, IC.cid.is(q.account.collective))
val withPerson =
selectSimple(personCols, RPerson.table, PC.cid.is(q.account.collective))
val withOrgs =
selectSimple(orgCols, ROrganization.table, OC.cid.is(q.account.collective))
val withEquips =
selectSimple(equipCols, REquipment.table, EC.cid.is(q.account.collective))
val withFolder =
selectSimple(folderCols, RFolder.table, FC.collective.is(q.account.collective))
val withAttach = fr"SELECT COUNT(" ++ AC.id.f ++ fr") as num, " ++ AC.itemId.f ++
fr"from" ++ RAttachment.table ++ fr"GROUP BY (" ++ AC.itemId.f ++ fr")"
val selectKW = if (distinct) fr"SELECT DISTINCT" else fr"SELECT"
val query = withCTE(
withCTE(
(Seq(
"items" -> withItem,
"persons" -> withPerson,
"orgs" -> withOrgs,
"equips" -> withEquips,
"attachs" -> withAttach
"attachs" -> withAttach,
"folders" -> withFolder
) ++ ctes): _*
) ++
selectKW ++ finalCols ++ fr" FROM items i" ++
@ -282,8 +301,10 @@ object QItem {
fr"LEFT JOIN persons p0 ON" ++ IC.corrPerson.prefix("i").is(PC.pid.prefix("p0")) ++
fr"LEFT JOIN orgs o0 ON" ++ IC.corrOrg.prefix("i").is(OC.oid.prefix("o0")) ++
fr"LEFT JOIN persons p1 ON" ++ IC.concPerson.prefix("i").is(PC.pid.prefix("p1")) ++
fr"LEFT JOIN equips e1 ON" ++ IC.concEquipment.prefix("i").is(EC.eid.prefix("e1"))
query
fr"LEFT JOIN equips e1 ON" ++ IC.concEquipment
.prefix("i")
.is(EC.eid.prefix("e1")) ++
fr"LEFT JOIN folders f1 ON" ++ IC.folder.prefix("i").is(FC.id.prefix("f1"))
}
def findItems(q: Query, batch: Batch): Stream[ConnectionIO, ListItem] = {
@ -315,10 +336,11 @@ object QItem {
RTagItem.Columns.tagId.isOneOf(q.tagsExclude)
)
val iFolder = IC.folder.prefix("i")
val name = q.name.map(_.toLowerCase).map(queryWildcard)
val allNames = q.allNames.map(_.toLowerCase).map(queryWildcard)
val cond = and(
IC.cid.prefix("i").is(q.collective),
IC.cid.prefix("i").is(q.account.collective),
IC.state.prefix("i").isOneOf(q.states),
IC.incoming.prefix("i").isOrDiscard(q.direction),
name
@ -340,6 +362,7 @@ object QItem {
ROrganization.Columns.oid.prefix("o0").isOrDiscard(q.corrOrg),
RPerson.Columns.pid.prefix("p1").isOrDiscard(q.concPerson),
REquipment.Columns.eid.prefix("e1").isOrDiscard(q.concEquip),
RFolder.Columns.id.prefix("f1").isOrDiscard(q.folder),
if (q.tagsInclude.isEmpty) Fragment.empty
else
IC.id.prefix("i") ++ sql" IN (" ++ tagSelectsIncl
@ -365,7 +388,8 @@ object QItem {
.map(nel => IC.id.prefix("i").isIn(nel))
.getOrElse(IC.id.prefix("i").is(""))
)
.getOrElse(Fragment.empty)
.getOrElse(Fragment.empty),
or(iFolder.isNull, iFolder.isIn(QFolder.findMemberFolderIds(q.account)))
)
val order = q.orderAsc match {
@ -456,7 +480,10 @@ object QItem {
n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective))
} yield tn + rn + n + mn
private def findByFileIdsQuery(fileMetaIds: NonEmptyList[Ident], limit: Option[Int]) = {
private def findByFileIdsQuery(
fileMetaIds: NonEmptyList[Ident],
limit: Option[Int]
): Fragment = {
val IC = RItem.Columns.all.map(_.prefix("i"))
val aItem = RAttachment.Columns.itemId.prefix("a")
val aId = RAttachment.Columns.id.prefix("a")
@ -558,6 +585,7 @@ object QItem {
final case class NameAndNotes(
id: Ident,
collective: Ident,
folder: Option[Ident],
name: String,
notes: Option[String]
)
@ -565,12 +593,13 @@ object QItem {
coll: Option[Ident],
chunkSize: Int
): Stream[ConnectionIO, NameAndNotes] = {
val iId = RItem.Columns.id
val iColl = RItem.Columns.cid
val iName = RItem.Columns.name
val iNotes = RItem.Columns.notes
val iId = RItem.Columns.id
val iColl = RItem.Columns.cid
val iName = RItem.Columns.name
val iFolder = RItem.Columns.folder
val iNotes = RItem.Columns.notes
val cols = Seq(iId, iColl, iName, iNotes)
val cols = Seq(iId, iColl, iFolder, iName, iNotes)
val where = coll.map(cid => iColl.is(cid)).getOrElse(Fragment.empty)
selectSimple(cols, RItem.table, where)
.query[NameAndNotes]

View File

@ -0,0 +1,86 @@
package docspell.store.records
import cats.effect._
import cats.implicits._
import docspell.common._
import docspell.store.impl.Column
import docspell.store.impl.Implicits._
import doobie._
import doobie.implicits._
case class RFolder(
id: Ident,
name: String,
collectiveId: Ident,
owner: Ident,
created: Timestamp
)
object RFolder {
def newFolder[F[_]: Sync](name: String, account: AccountId): F[RFolder] =
for {
nId <- Ident.randomId[F]
now <- Timestamp.current[F]
} yield RFolder(nId, name, account.collective, account.user, now)
val table = fr"folder"
object Columns {
val id = Column("id")
val name = Column("name")
val collective = Column("cid")
val owner = Column("owner")
val created = Column("created")
val all = List(id, name, collective, owner, created)
}
import Columns._
def insert(value: RFolder): ConnectionIO[Int] = {
val sql = insertRow(
table,
all,
fr"${value.id},${value.name},${value.collectiveId},${value.owner},${value.created}"
)
sql.update.run
}
def update(v: RFolder): ConnectionIO[Int] =
updateRow(
table,
and(id.is(v.id), collective.is(v.collectiveId), owner.is(v.owner)),
name.setTo(v.name)
).update.run
def existsByName(coll: Ident, folderName: String): ConnectionIO[Boolean] =
selectCount(id, table, and(collective.is(coll), name.is(folderName)))
.query[Int]
.unique
.map(_ > 0)
def findById(folderId: Ident): ConnectionIO[Option[RFolder]] = {
val sql = selectSimple(all, table, id.is(folderId))
sql.query[RFolder].option
}
def findAll(
coll: Ident,
nameQ: Option[String],
order: Columns.type => Column
): ConnectionIO[Vector[RFolder]] = {
val q = Seq(collective.is(coll)) ++ (nameQ match {
case Some(str) => Seq(name.lowerLike(s"%${str.toLowerCase}%"))
case None => Seq.empty
})
val sql = selectSimple(all, table, and(q)) ++ orderBy(order(Columns).f)
sql.query[RFolder].to[Vector]
}
def delete(folderId: Ident): ConnectionIO[Int] =
deleteFrom(table, id.is(folderId)).update.run
}

View File

@ -0,0 +1,61 @@
package docspell.store.records
import cats.effect._
import cats.implicits._
import docspell.common._
import docspell.store.impl.Column
import docspell.store.impl.Implicits._
import doobie._
import doobie.implicits._
case class RFolderMember(
id: Ident,
folderId: Ident,
userId: Ident,
created: Timestamp
)
object RFolderMember {
def newMember[F[_]: Sync](folder: Ident, user: Ident): F[RFolderMember] =
for {
nId <- Ident.randomId[F]
now <- Timestamp.current[F]
} yield RFolderMember(nId, folder, user, now)
val table = fr"folder_member"
object Columns {
val id = Column("id")
val folder = Column("folder_id")
val user = Column("user_id")
val created = Column("created")
val all = List(id, folder, user, created)
}
import Columns._
def insert(value: RFolderMember): ConnectionIO[Int] = {
val sql = insertRow(
table,
all,
fr"${value.id},${value.folderId},${value.userId},${value.created}"
)
sql.update.run
}
def findByUserId(userId: Ident, folderId: Ident): ConnectionIO[Option[RFolderMember]] =
selectSimple(all, table, and(folder.is(folderId), user.is(userId)))
.query[RFolderMember]
.option
def delete(userId: Ident, folderId: Ident): ConnectionIO[Int] =
deleteFrom(table, and(folder.is(folderId), user.is(userId))).update.run
def deleteAll(folderId: Ident): ConnectionIO[Int] =
deleteFrom(table, folder.is(folderId)).update.run
}

View File

@ -27,7 +27,8 @@ case class RItem(
dueDate: Option[Timestamp],
created: Timestamp,
updated: Timestamp,
notes: Option[String]
notes: Option[String],
folderId: Option[Ident]
) {}
object RItem {
@ -58,6 +59,7 @@ object RItem {
None,
now,
now,
None,
None
)
@ -80,6 +82,7 @@ object RItem {
val created = Column("created")
val updated = Column("updated")
val notes = Column("notes")
val folder = Column("folder_id")
val all = List(
id,
cid,
@ -96,7 +99,8 @@ object RItem {
dueDate,
created,
updated,
notes
notes,
folder
)
}
import Columns._
@ -107,7 +111,7 @@ object RItem {
all,
fr"${v.id},${v.cid},${v.name},${v.itemDate},${v.source},${v.direction},${v.state}," ++
fr"${v.corrOrg},${v.corrPerson},${v.concPerson},${v.concEquipment},${v.inReplyTo},${v.dueDate}," ++
fr"${v.created},${v.updated},${v.notes}"
fr"${v.created},${v.updated},${v.notes},${v.folderId}"
).update.run
def getCollective(itemId: Ident): ConnectionIO[Option[Ident]] =
@ -239,7 +243,21 @@ object RItem {
n <- updateRow(
table,
and(cid.is(coll), concEquipment.is(Some(currentEquip))),
commas(concPerson.setTo(None: Option[Ident]), updated.setTo(t))
commas(concEquipment.setTo(None: Option[Ident]), updated.setTo(t))
).update.run
} yield n
def updateFolder(
itemId: Ident,
coll: Ident,
folderId: Option[Ident]
): ConnectionIO[Int] =
for {
t <- currentTime
n <- updateRow(
table,
and(cid.is(coll), id.is(itemId)),
commas(folder.setTo(folderId), updated.setTo(t))
).update.run
} yield n
@ -295,4 +313,9 @@ object RItem {
def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] =
selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option
def removeFolder(folderId: Ident): ConnectionIO[Int] = {
val empty: Option[Ident] = None
updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run
}
}

View File

@ -15,7 +15,8 @@ case class RSource(
counter: Int,
enabled: Boolean,
priority: Priority,
created: Timestamp
created: Timestamp,
folderId: Option[Ident]
) {}
object RSource {
@ -32,8 +33,10 @@ object RSource {
val enabled = Column("enabled")
val priority = Column("priority")
val created = Column("created")
val folder = Column("folder_id")
val all = List(sid, cid, abbrev, description, counter, enabled, priority, created)
val all =
List(sid, cid, abbrev, description, counter, enabled, priority, created, folder)
}
import Columns._
@ -42,7 +45,7 @@ object RSource {
val sql = insertRow(
table,
all,
fr"${v.sid},${v.cid},${v.abbrev},${v.description},${v.counter},${v.enabled},${v.priority},${v.created}"
fr"${v.sid},${v.cid},${v.abbrev},${v.description},${v.counter},${v.enabled},${v.priority},${v.created},${v.folderId}"
)
sql.update.run
}
@ -56,7 +59,8 @@ object RSource {
abbrev.setTo(v.abbrev),
description.setTo(v.description),
enabled.setTo(v.enabled),
priority.setTo(v.priority)
priority.setTo(v.priority),
folder.setTo(v.folderId)
)
)
sql.update.run
@ -97,4 +101,9 @@ object RSource {
def delete(sourceId: Ident, coll: Ident): ConnectionIO[Int] =
deleteFrom(table, and(sid.is(sourceId), cid.is(coll))).update.run
def removeFolder(folderId: Ident): ConnectionIO[Int] = {
val empty: Option[Ident] = None
updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run
}
}

View File

@ -3,16 +3,20 @@ module Api exposing
, addConcPerson
, addCorrOrg
, addCorrPerson
, addMember
, addTag
, cancelJob
, changeFolderName
, changePassword
, checkCalEvent
, createImapSettings
, createMailSettings
, createNewFolder
, createNotifyDueItems
, createScanMailbox
, deleteAttachment
, deleteEquip
, deleteFolder
, deleteImapSettings
, deleteItem
, deleteMailSettings
@ -28,6 +32,8 @@ module Api exposing
, getCollectiveSettings
, getContacts
, getEquipments
, getFolderDetail
, getFolders
, getImapSettings
, getInsights
, getItemProposals
@ -61,6 +67,7 @@ module Api exposing
, putUser
, refreshSession
, register
, removeMember
, sendMail
, setAttachmentName
, setCollectiveSettings
@ -70,6 +77,7 @@ module Api exposing
, setCorrOrg
, setCorrPerson
, setDirection
, setFolder
, setItemDate
, setItemDueDate
, setItemName
@ -101,7 +109,10 @@ import Api.Model.EmailSettings exposing (EmailSettings)
import Api.Model.EmailSettingsList exposing (EmailSettingsList)
import Api.Model.Equipment exposing (Equipment)
import Api.Model.EquipmentList exposing (EquipmentList)
import Api.Model.FolderDetail exposing (FolderDetail)
import Api.Model.FolderList exposing (FolderList)
import Api.Model.GenInvite exposing (GenInvite)
import Api.Model.IdResult exposing (IdResult)
import Api.Model.ImapSettings exposing (ImapSettings)
import Api.Model.ImapSettingsList exposing (ImapSettingsList)
import Api.Model.InviteResult exposing (InviteResult)
@ -114,6 +125,7 @@ import Api.Model.ItemSearch exposing (ItemSearch)
import Api.Model.ItemUploadMeta exposing (ItemUploadMeta)
import Api.Model.JobQueueState exposing (JobQueueState)
import Api.Model.MoveAttachment exposing (MoveAttachment)
import Api.Model.NewFolder exposing (NewFolder)
import Api.Model.NotificationSettings exposing (NotificationSettings)
import Api.Model.NotificationSettingsList exposing (NotificationSettingsList)
import Api.Model.OptionalDate exposing (OptionalDate)
@ -150,6 +162,85 @@ import Util.Http as Http2
--- Folders
deleteFolder : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
deleteFolder flags id receive =
Http2.authDelete
{ url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id
, account = getAccount flags
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
removeMember : Flags -> String -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
removeMember flags id user receive =
Http2.authDelete
{ url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id ++ "/member/" ++ user
, account = getAccount flags
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
addMember : Flags -> String -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
addMember flags id user receive =
Http2.authPut
{ url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id ++ "/member/" ++ user
, account = getAccount flags
, body = Http.emptyBody
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
changeFolderName : Flags -> String -> NewFolder -> (Result Http.Error BasicResult -> msg) -> Cmd msg
changeFolderName flags id ns receive =
Http2.authPut
{ url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id
, account = getAccount flags
, body = Http.jsonBody (Api.Model.NewFolder.encode ns)
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
createNewFolder : Flags -> NewFolder -> (Result Http.Error IdResult -> msg) -> Cmd msg
createNewFolder flags ns receive =
Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/folder"
, account = getAccount flags
, body = Http.jsonBody (Api.Model.NewFolder.encode ns)
, expect = Http.expectJson receive Api.Model.IdResult.decoder
}
getFolderDetail : Flags -> String -> (Result Http.Error FolderDetail -> msg) -> Cmd msg
getFolderDetail flags id receive =
Http2.authGet
{ url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id
, account = getAccount flags
, expect = Http.expectJson receive Api.Model.FolderDetail.decoder
}
getFolders : Flags -> String -> Bool -> (Result Http.Error FolderList -> msg) -> Cmd msg
getFolders flags query owningOnly receive =
Http2.authGet
{ url =
flags.config.baseUrl
++ "/api/v1/sec/folder?q="
++ Url.percentEncode query
++ (if owningOnly then
"&owning=true"
else
""
)
, account = getAccount flags
, expect = Http.expectJson receive Api.Model.FolderList.decoder
}
--- Full-Text
@ -1172,6 +1263,16 @@ setDirection flags item dir receive =
}
setFolder : Flags -> String -> OptionalId -> (Result Http.Error BasicResult -> msg) -> Cmd msg
setFolder flags item id receive =
Http2.authPut
{ url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ item ++ "/folder"
, account = getAccount flags
, body = Http.jsonBody (Api.Model.OptionalId.encode id)
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
setCorrOrg : Flags -> String -> OptionalId -> (Result Http.Error BasicResult -> msg) -> Cmd msg
setCorrOrg flags item id receive =
Http2.authPut

View File

@ -57,6 +57,12 @@ init key url flags settings =
( um, uc ) =
Page.UserSettings.Data.init flags settings
( mdm, mdc ) =
Page.ManageData.Data.init flags
( csm, csc ) =
Page.CollectiveSettings.Data.init flags
in
( { flags = flags
, key = key
@ -64,8 +70,8 @@ init key url flags settings =
, version = Api.Model.VersionInfo.empty
, homeModel = Page.Home.Data.init flags
, loginModel = Page.Login.Data.emptyModel
, manageDataModel = Page.ManageData.Data.emptyModel
, collSettingsModel = Page.CollectiveSettings.Data.emptyModel
, manageDataModel = mdm
, collSettingsModel = csm
, userSettingsModel = um
, queueModel = Page.Queue.Data.emptyModel
, registerModel = Page.Register.Data.emptyModel
@ -76,7 +82,11 @@ init key url flags settings =
, subs = Sub.none
, uiSettings = settings
}
, Cmd.map UserSettingsMsg uc
, Cmd.batch
[ Cmd.map UserSettingsMsg uc
, Cmd.map ManageDataMsg mdc
, Cmd.map CollSettingsMsg csc
]
)

View File

@ -160,7 +160,11 @@ viewCollectiveSettings model =
viewManageData : Model -> Html Msg
viewManageData model =
Html.map ManageDataMsg (Page.ManageData.View.view model.uiSettings model.manageDataModel)
Html.map ManageDataMsg
(Page.ManageData.View.view model.flags
model.uiSettings
model.manageDataModel
)
viewLogin : Model -> Html Msg

View File

@ -49,7 +49,7 @@ emptyModel =
, city = ""
, country =
Comp.Dropdown.makeSingleList
{ makeOption = \c -> { value = c.code, text = c.label }
{ makeOption = \c -> { value = c.code, text = c.label, additional = "" }
, placeholder = "Select Country"
, options = countries
, selected = Nothing

View File

@ -43,6 +43,7 @@ init settings =
\l ->
{ value = Data.Language.toIso3 l
, text = Data.Language.toName l
, additional = ""
}
, placeholder = ""
, options = Data.Language.all

View File

@ -32,6 +32,7 @@ emptyModel =
\ct ->
{ value = Data.ContactType.toString ct
, text = Data.ContactType.toString ct
, additional = ""
}
, placeholder = ""
, options = Data.ContactType.all

View File

@ -8,6 +8,8 @@ module Comp.Dropdown exposing
, makeMultiple
, makeSingle
, makeSingleList
, mkOption
, setMkOption
, update
, view
)
@ -27,9 +29,15 @@ import Util.List
type alias Option =
{ value : String
, text : String
, additional : String
}
mkOption : String -> String -> Option
mkOption value text =
Option value text ""
type alias Item a =
{ value : a
, option : Option
@ -63,6 +71,11 @@ type alias Model a =
}
setMkOption : (a -> Option) -> Model a -> Model a
setMkOption mkopt model =
{ model | makeOption = mkopt }
makeModel :
{ multiple : Bool
, searchable : Int -> Bool
@ -508,4 +521,7 @@ renderOption item =
, onClick (AddItem item)
]
[ text item.option.text
, span [ class "small-info right-float" ]
[ text item.option.additional
]
]

View File

@ -51,7 +51,12 @@ emptyModel =
, replyTo = Nothing
, sslType =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s }
{ makeOption =
\s ->
{ value = Data.SSLType.toString s
, text = Data.SSLType.label s
, additional = ""
}
, placeholder = ""
, options = Data.SSLType.all
, selected = Just Data.SSLType.None
@ -74,7 +79,12 @@ init ems =
, replyTo = ems.replyTo
, sslType =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s }
{ makeOption =
\s ->
{ value = Data.SSLType.toString s
, text = Data.SSLType.label s
, additional = ""
}
, placeholder = ""
, options = Data.SSLType.all
, selected =

View File

@ -0,0 +1,418 @@
module Comp.FolderDetail exposing
( Model
, Msg
, init
, initEmpty
, update
, view
)
import Api
import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.FolderDetail exposing (FolderDetail)
import Api.Model.IdName exposing (IdName)
import Api.Model.IdResult exposing (IdResult)
import Api.Model.NewFolder exposing (NewFolder)
import Api.Model.User exposing (User)
import Api.Model.UserList exposing (UserList)
import Comp.FixedDropdown
import Comp.YesNoDimmer
import Data.Flags exposing (Flags)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput)
import Http
import Util.Http
import Util.Maybe
type alias Model =
{ result : Maybe BasicResult
, folder : FolderDetail
, name : Maybe String
, members : List IdName
, users : List User
, memberDropdown : Comp.FixedDropdown.Model IdName
, selectedMember : Maybe IdName
, loading : Bool
, deleteDimmer : Comp.YesNoDimmer.Model
}
type Msg
= SetName String
| MemberDropdownMsg (Comp.FixedDropdown.Msg IdName)
| SaveName
| NewFolderResp (Result Http.Error IdResult)
| ChangeFolderResp (Result Http.Error BasicResult)
| ChangeNameResp (Result Http.Error BasicResult)
| FolderDetailResp (Result Http.Error FolderDetail)
| AddMember
| RemoveMember IdName
| RequestDelete
| DeleteMsg Comp.YesNoDimmer.Msg
| DeleteResp (Result Http.Error BasicResult)
| GoBack
init : List User -> FolderDetail -> Model
init users folder =
{ result = Nothing
, folder = folder
, name = Util.Maybe.fromString folder.name
, members = folder.members
, users = users
, memberDropdown =
Comp.FixedDropdown.initMap .name
(makeOptions users folder)
, selectedMember = Nothing
, loading = False
, deleteDimmer = Comp.YesNoDimmer.emptyModel
}
initEmpty : List User -> Model
initEmpty users =
init users Api.Model.FolderDetail.empty
makeOptions : List User -> FolderDetail -> List IdName
makeOptions users folder =
let
toIdName u =
IdName u.id u.login
notMember idn =
List.member idn (folder.owner :: folder.members) |> not
in
List.map toIdName users
|> List.filter notMember
--- Update
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Bool )
update flags msg model =
case msg of
GoBack ->
( model, Cmd.none, True )
SetName str ->
( { model | name = Util.Maybe.fromString str }
, Cmd.none
, False
)
MemberDropdownMsg lmsg ->
let
( mm, sel ) =
Comp.FixedDropdown.update lmsg model.memberDropdown
in
( { model
| memberDropdown = mm
, selectedMember =
case sel of
Just _ ->
sel
Nothing ->
model.selectedMember
}
, Cmd.none
, False
)
SaveName ->
case model.name of
Just name ->
let
cmd =
if model.folder.id == "" then
Api.createNewFolder flags (NewFolder name) NewFolderResp
else
Api.changeFolderName flags
model.folder.id
(NewFolder name)
ChangeNameResp
in
( { model
| loading = True
, result = Nothing
}
, cmd
, False
)
Nothing ->
( model, Cmd.none, False )
NewFolderResp (Ok ir) ->
if ir.success then
( model, Api.getFolderDetail flags ir.id FolderDetailResp, False )
else
( { model
| loading = False
, result = Just (BasicResult ir.success ir.message)
}
, Cmd.none
, False
)
NewFolderResp (Err err) ->
( { model
| loading = False
, result = Just (BasicResult False (Util.Http.errorToString err))
}
, Cmd.none
, False
)
ChangeFolderResp (Ok r) ->
if r.success then
( model
, Api.getFolderDetail flags model.folder.id FolderDetailResp
, False
)
else
( { model | loading = False, result = Just r }
, Cmd.none
, False
)
ChangeFolderResp (Err err) ->
( { model
| loading = False
, result = Just (BasicResult False (Util.Http.errorToString err))
}
, Cmd.none
, False
)
ChangeNameResp (Ok r) ->
let
model_ =
{ model | result = Just r, loading = False }
in
( model_, Cmd.none, False )
ChangeNameResp (Err err) ->
( { model
| result = Just (BasicResult False (Util.Http.errorToString err))
, loading = False
}
, Cmd.none
, False
)
FolderDetailResp (Ok sd) ->
( init model.users sd, Cmd.none, False )
FolderDetailResp (Err err) ->
( { model
| loading = False
, result = Just (BasicResult False (Util.Http.errorToString err))
}
, Cmd.none
, False
)
AddMember ->
case model.selectedMember of
Just mem ->
( { model | loading = True }
, Api.addMember flags model.folder.id mem.id ChangeFolderResp
, False
)
Nothing ->
( model, Cmd.none, False )
RemoveMember idname ->
( { model | loading = True }
, Api.removeMember flags model.folder.id idname.id ChangeFolderResp
, False
)
RequestDelete ->
let
( dm, _ ) =
Comp.YesNoDimmer.update Comp.YesNoDimmer.activate model.deleteDimmer
in
( { model | deleteDimmer = dm }, Cmd.none, False )
DeleteMsg lm ->
let
( dm, flag ) =
Comp.YesNoDimmer.update lm model.deleteDimmer
cmd =
if flag then
Api.deleteFolder flags model.folder.id DeleteResp
else
Cmd.none
in
( { model | deleteDimmer = dm }, cmd, False )
DeleteResp (Ok r) ->
( { model | result = Just r }, Cmd.none, r.success )
DeleteResp (Err err) ->
( { model | result = Just (BasicResult False (Util.Http.errorToString err)) }
, Cmd.none
, False
)
--- View
view : Flags -> Model -> Html Msg
view flags model =
let
isOwner =
Maybe.map .user flags.account
|> Maybe.map ((==) model.folder.owner.name)
|> Maybe.withDefault False
in
div []
([ Html.map DeleteMsg (Comp.YesNoDimmer.view model.deleteDimmer)
, if model.folder.id == "" then
div []
[ text "Create a new folder. You are automatically set as owner of this new folder."
]
else
div []
[ text "Modify this folder by changing the name or add/remove members."
]
, if model.folder.id /= "" && not isOwner then
div [ class "ui info message" ]
[ text "You are not the owner of this folder and therefore are not allowed to edit it."
]
else
div [] []
, div
[ classList
[ ( "ui message", True )
, ( "invisible hidden", model.result == Nothing )
, ( "error", Maybe.map .success model.result == Just False )
, ( "success", Maybe.map .success model.result == Just True )
]
]
[ Maybe.map .message model.result
|> Maybe.withDefault ""
|> text
]
, div [ class "ui header" ]
[ text "Owner"
]
, div [ class "" ]
[ text model.folder.owner.name
]
, div [ class "ui header" ]
[ text "Name"
]
, div [ class "ui action input" ]
[ input
[ type_ "text"
, onInput SetName
, Maybe.withDefault "" model.name
|> value
]
[]
, button
[ class "ui icon button"
, onClick SaveName
]
[ i [ class "save icon" ] []
]
]
]
++ viewMembers model
++ viewButtons model
)
viewButtons : Model -> List (Html Msg)
viewButtons model =
[ div [ class "ui divider" ] []
, button
[ class "ui button"
, onClick GoBack
]
[ text "Back"
]
, button
[ classList
[ ( "ui red button", True )
, ( "invisible hidden", model.folder.id == "" )
]
, onClick RequestDelete
]
[ text "Delete"
]
]
viewMembers : Model -> List (Html Msg)
viewMembers model =
if model.folder.id == "" then
[]
else
[ div [ class "ui header" ]
[ text "Members"
]
, div [ class "ui form" ]
[ div [ class "inline field" ]
[ Html.map MemberDropdownMsg
(Comp.FixedDropdown.view
(Maybe.map makeItem model.selectedMember)
model.memberDropdown
)
, button
[ class "ui primary button"
, title "Add a new member"
, onClick AddMember
]
[ text "Add"
]
]
]
, div
[ class "ui list"
]
(List.map viewMember model.members)
]
makeItem : IdName -> Comp.FixedDropdown.Item IdName
makeItem idn =
Comp.FixedDropdown.Item idn idn.name
viewMember : IdName -> Html Msg
viewMember member =
div
[ class "item"
]
[ a
[ class "link icon"
, href "#"
, title "Remove this member"
, onClick (RemoveMember member)
]
[ i [ class "red trash icon" ] []
]
, span []
[ text member.name
]
]

View File

@ -0,0 +1,237 @@
module Comp.FolderManage exposing
( Model
, Msg
, empty
, init
, update
, view
)
import Api
import Api.Model.FolderDetail exposing (FolderDetail)
import Api.Model.FolderItem exposing (FolderItem)
import Api.Model.FolderList exposing (FolderList)
import Api.Model.User exposing (User)
import Api.Model.UserList exposing (UserList)
import Comp.FolderDetail
import Comp.FolderTable
import Data.Flags exposing (Flags)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onClick, onInput)
import Http
type alias Model =
{ tableModel : Comp.FolderTable.Model
, detailModel : Maybe Comp.FolderDetail.Model
, folders : List FolderItem
, users : List User
, query : String
, owningOnly : Bool
, loading : Bool
}
type Msg
= TableMsg Comp.FolderTable.Msg
| DetailMsg Comp.FolderDetail.Msg
| UserListResp (Result Http.Error UserList)
| FolderListResp (Result Http.Error FolderList)
| FolderDetailResp (Result Http.Error FolderDetail)
| SetQuery String
| InitNewFolder
| ToggleOwningOnly
empty : Model
empty =
{ tableModel = Comp.FolderTable.init
, detailModel = Nothing
, folders = []
, users = []
, query = ""
, owningOnly = True
, loading = False
}
init : Flags -> ( Model, Cmd Msg )
init flags =
( empty
, Cmd.batch
[ Api.getUsers flags UserListResp
, Api.getFolders flags empty.query empty.owningOnly FolderListResp
]
)
--- Update
update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
update flags msg model =
case msg of
TableMsg lm ->
let
( tm, action ) =
Comp.FolderTable.update lm model.tableModel
cmd =
case action of
Comp.FolderTable.EditAction item ->
Api.getFolderDetail flags item.id FolderDetailResp
Comp.FolderTable.NoAction ->
Cmd.none
in
( { model | tableModel = tm }, cmd )
DetailMsg lm ->
case model.detailModel of
Just detail ->
let
( dm, dc, back ) =
Comp.FolderDetail.update flags lm detail
cmd =
if back then
Api.getFolders flags model.query model.owningOnly FolderListResp
else
Cmd.none
in
( { model
| detailModel =
if back then
Nothing
else
Just dm
}
, Cmd.batch
[ Cmd.map DetailMsg dc
, cmd
]
)
Nothing ->
( model, Cmd.none )
SetQuery str ->
( { model | query = str }
, Api.getFolders flags str model.owningOnly FolderListResp
)
ToggleOwningOnly ->
let
newOwning =
not model.owningOnly
in
( { model | owningOnly = newOwning }
, Api.getFolders flags model.query newOwning FolderListResp
)
UserListResp (Ok ul) ->
( { model | users = ul.items }, Cmd.none )
UserListResp (Err err) ->
( model, Cmd.none )
FolderListResp (Ok sl) ->
( { model | folders = sl.items }, Cmd.none )
FolderListResp (Err err) ->
( model, Cmd.none )
FolderDetailResp (Ok sd) ->
( { model | detailModel = Comp.FolderDetail.init model.users sd |> Just }
, Cmd.none
)
FolderDetailResp (Err err) ->
( model, Cmd.none )
InitNewFolder ->
let
sd =
Comp.FolderDetail.initEmpty model.users
in
( { model | detailModel = Just sd }
, Cmd.none
)
--- View
view : Flags -> Model -> Html Msg
view flags model =
case model.detailModel of
Just dm ->
viewDetail flags dm
Nothing ->
viewTable model
viewDetail : Flags -> Comp.FolderDetail.Model -> Html Msg
viewDetail flags detailModel =
div []
[ Html.map DetailMsg (Comp.FolderDetail.view flags detailModel)
]
viewTable : Model -> Html Msg
viewTable model =
div []
[ div [ class "ui secondary menu" ]
[ div [ class "horizontally fitted item" ]
[ div [ class "ui icon input" ]
[ input
[ type_ "text"
, onInput SetQuery
, value model.query
, placeholder "Search"
]
[]
, i [ class "ui search icon" ]
[]
]
]
, div [ class "item" ]
[ div [ class "ui checkbox" ]
[ input
[ type_ "checkbox"
, onCheck (\_ -> ToggleOwningOnly)
, checked model.owningOnly
]
[]
, label [] [ text "Show owning folders only" ]
]
]
, div [ class "right menu" ]
[ div [ class "item" ]
[ a
[ class "ui primary button"
, href "#"
, onClick InitNewFolder
]
[ i [ class "plus icon" ] []
, text "New Folder"
]
]
]
]
, Html.map TableMsg (Comp.FolderTable.view model.tableModel model.folders)
, div
[ classList
[ ( "ui dimmer", True )
, ( "active", model.loading )
]
]
[ div [ class "ui loader" ] []
]
]

View File

@ -0,0 +1,91 @@
module Comp.FolderTable exposing
( Action(..)
, Model
, Msg
, init
, update
, view
)
import Api.Model.FolderItem exposing (FolderItem)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Util.Html
import Util.Time
type alias Model =
{}
type Msg
= EditItem FolderItem
type Action
= NoAction
| EditAction FolderItem
init : Model
init =
{}
update : Msg -> Model -> ( Model, Action )
update msg model =
case msg of
EditItem item ->
( model, EditAction item )
view : Model -> List FolderItem -> Html Msg
view _ items =
div []
[ table [ class "ui very basic center aligned table" ]
[ thead []
[ th [ class "collapsing" ] []
, th [] [ text "Name" ]
, th [] [ text "Owner" ]
, th [] [ text "Owner or Member" ]
, th [] [ text "#Member" ]
, th [] [ text "Created" ]
]
, tbody []
(List.map viewItem items)
]
]
viewItem : FolderItem -> Html Msg
viewItem item =
tr []
[ td [ class "collapsing" ]
[ a
[ href "#"
, class "ui basic small blue label"
, onClick (EditItem item)
]
[ i [ class "edit icon" ] []
, text "Edit"
]
]
, td []
[ text item.name
]
, td []
[ text item.owner.name
]
, td []
[ Util.Html.checkbox item.isMember
]
, td []
[ String.fromInt item.memberCount
|> text
]
, td []
[ Util.Time.formatDateShort item.created
|> text
]
]

View File

@ -47,7 +47,12 @@ emptyModel =
, password = Nothing
, sslType =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s }
{ makeOption =
\s ->
{ value = Data.SSLType.toString s
, text = Data.SSLType.label s
, additional = ""
}
, placeholder = ""
, options = Data.SSLType.all
, selected = Just Data.SSLType.None
@ -68,7 +73,12 @@ init ems =
, password = ems.imapPassword
, sslType =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s }
{ makeOption =
\s ->
{ value = Data.SSLType.toString s
, text = Data.SSLType.label s
, additional = ""
}
, placeholder = ""
, options = Data.SSLType.all
, selected =

View File

@ -21,7 +21,6 @@ import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Markdown
import Ports
import Util.List
import Util.String
import Util.Time
@ -125,6 +124,10 @@ viewItem settings item =
|> List.intersperse ", "
|> String.concat
folder =
Maybe.map .name item.folder
|> Maybe.withDefault ""
dueDate =
Maybe.map Util.Time.formatDateShort item.dueDate
|> Maybe.withDefault ""
@ -212,6 +215,14 @@ viewItem settings item =
, text " "
, Util.String.withDefault "-" conc |> text
]
, div
[ class "item"
, title "Folder"
]
[ Icons.folderIcon ""
, text " "
, Util.String.withDefault "-" folder |> text
]
]
, div [ class "right floated meta" ]
[ div [ class "ui horizontal list" ]

View File

@ -11,6 +11,8 @@ import Api.Model.Attachment exposing (Attachment)
import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.DirectionValue exposing (DirectionValue)
import Api.Model.EquipmentList exposing (EquipmentList)
import Api.Model.FolderItem exposing (FolderItem)
import Api.Model.FolderList exposing (FolderList)
import Api.Model.IdName exposing (IdName)
import Api.Model.ItemDetail exposing (ItemDetail)
import Api.Model.ItemProposals exposing (ItemProposals)
@ -52,6 +54,7 @@ import Page exposing (Page(..))
import Ports
import Set exposing (Set)
import Util.File exposing (makeFileId)
import Util.Folder exposing (mkFolderOption)
import Util.Http
import Util.List
import Util.Maybe
@ -71,6 +74,8 @@ type alias Model =
, corrPersonModel : Comp.Dropdown.Model IdName
, concPersonModel : Comp.Dropdown.Model IdName
, concEquipModel : Comp.Dropdown.Model IdName
, folderModel : Comp.Dropdown.Model IdName
, allFolders : List FolderItem
, nameModel : String
, notesModel : Maybe String
, notesField : NotesField
@ -140,6 +145,7 @@ emptyModel =
\entry ->
{ value = Data.Direction.toString entry
, text = Data.Direction.toString entry
, additional = ""
}
, options = Data.Direction.all
, placeholder = "Choose a direction"
@ -147,24 +153,30 @@ emptyModel =
}
, corrOrgModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name }
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, corrPersonModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name }
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, concPersonModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name }
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, concEquipModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name }
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, folderModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, allFolders = []
, nameModel = ""
, notesModel = Nothing
, notesField = ViewNotes
@ -268,6 +280,8 @@ type Msg
| EditAttachNameSet String
| EditAttachNameSubmit
| EditAttachNameResp (Result Http.Error BasicResult)
| GetFolderResp (Result Http.Error FolderList)
| FolderDropdownMsg (Comp.Dropdown.Msg IdName)
@ -281,6 +295,7 @@ getOptions flags =
, Api.getOrgLight flags GetOrgResp
, Api.getPersonsLight flags GetPersonResp
, Api.getEquipments flags "" GetEquipResp
, Api.getFolders flags "" False GetFolderResp
]
@ -310,6 +325,16 @@ setDirection flags model =
Cmd.none
setFolder : Flags -> Model -> Maybe IdName -> Cmd Msg
setFolder flags model mref =
let
idref =
Maybe.map .id mref
|> OptionalId
in
Api.setFolder flags model.item.id idref SaveResp
setCorrOrg : Flags -> Model -> Maybe IdName -> Cmd Msg
setCorrOrg flags model mref =
let
@ -523,6 +548,20 @@ update key flags next msg model =
( m7, c7, s7 ) =
update key flags next AddFilesReset m6
( m8, c8, s8 ) =
update key
flags
next
(FolderDropdownMsg
(Comp.Dropdown.SetSelection
(item.folder
|> Maybe.map List.singleton
|> Maybe.withDefault []
)
)
)
m7
proposalCmd =
if item.state == "created" then
Api.getItemProposals flags item.id GetProposalResp
@ -530,7 +569,7 @@ update key flags next msg model =
else
Cmd.none
in
( { m7
( { m8
| item = item
, nameModel = item.name
, notesModel = item.notes
@ -548,11 +587,12 @@ update key flags next msg model =
, c5
, c6
, c7
, c8
, getOptions flags
, proposalCmd
, Api.getSentMails flags item.id SentMailsResp
]
, Sub.batch [ s1, s2, s3, s4, s5, s6, s7 ]
, Sub.batch [ s1, s2, s3, s4, s5, s6, s7, s8 ]
)
SetActiveAttachment pos ->
@ -575,6 +615,26 @@ update key flags next msg model =
else
noSub ( model, Api.itemDetail flags model.item.id GetItemResp )
FolderDropdownMsg m ->
let
( m2, c2 ) =
Comp.Dropdown.update m model.folderModel
newModel =
{ model | folderModel = m2 }
idref =
Comp.Dropdown.getSelected m2 |> List.head
save =
if isDropdownChangeMsg m then
setFolder flags newModel idref
else
Cmd.none
in
noSub ( newModel, Cmd.batch [ save, Cmd.map FolderDropdownMsg c2 ] )
TagDropdownMsg m ->
let
( m2, c2 ) =
@ -827,6 +887,30 @@ update key flags next msg model =
SetDueDateSuggestion date ->
noSub ( model, setDueDate flags model (Just date) )
GetFolderResp (Ok fs) ->
let
model_ =
{ model
| allFolders = fs.items
, folderModel =
Comp.Dropdown.setMkOption
(mkFolderOption flags fs.items)
model.folderModel
}
mkIdName fitem =
IdName fitem.id fitem.name
opts =
fs.items
|> List.map mkIdName
|> Comp.Dropdown.SetOptions
in
update key flags next (FolderDropdownMsg opts) model_
GetFolderResp (Err _) ->
noSub ( model, Cmd.none )
GetTagsResp (Ok tags) ->
let
tagList =
@ -1382,29 +1466,33 @@ update key flags next msg model =
noSub ( { model | attachRename = Nothing }, Cmd.none )
EditAttachNameResp (Ok res) ->
case model.attachRename of
Just m ->
let
changeName a =
if a.id == m.id then
{ a | name = Util.Maybe.fromString m.newName }
if res.success then
case model.attachRename of
Just m ->
let
changeName a =
if a.id == m.id then
{ a | name = Util.Maybe.fromString m.newName }
else
a
else
a
changeItem i =
{ i | attachments = List.map changeName i.attachments }
in
noSub
( { model
| attachRename = Nothing
, item = changeItem model.item
}
, Cmd.none
)
changeItem i =
{ i | attachments = List.map changeName i.attachments }
in
noSub
( { model
| attachRename = Nothing
, item = changeItem model.item
}
, Cmd.none
)
Nothing ->
noSub ( model, Cmd.none )
Nothing ->
noSub ( model, Cmd.none )
else
noSub ( model, Cmd.none )
EditAttachNameResp (Err _) ->
noSub ( model, Cmd.none )
@ -1939,6 +2027,17 @@ renderItemInfo settings model =
|> text
]
itemfolder =
div
[ class "item"
, title "Folder"
]
[ Icons.folderIcon ""
, Maybe.map .name model.item.folder
|> Maybe.withDefault "-"
|> text
]
src =
div
[ class "item"
@ -1972,6 +2071,7 @@ renderItemInfo settings model =
[ date
, corr
, conc
, itemfolder
, src
]
(if Util.Maybe.isEmpty model.item.dueDate then
@ -2061,7 +2161,7 @@ renderEditForm settings model =
]
in
div [ class "ui attached segment" ]
[ div [ class "ui form" ]
[ div [ class "ui form warning" ]
[ div [ class "field" ]
[ label []
[ Icons.tagsIcon "grey"
@ -2082,6 +2182,25 @@ renderEditForm settings model =
]
]
]
, div [ class "field" ]
[ label []
[ Icons.folderIcon "grey"
, text "Folder"
]
, Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel)
, div
[ classList
[ ( "ui warning message", True )
, ( "hidden", isFolderMember model )
]
]
[ Markdown.toHtml [] """
You are **not a member** of this folder. This item will be **hidden**
from any search now. Use a folder where you are a member of to make this
item visible. This message will disappear then.
"""
]
]
, div [ class "field" ]
[ label []
[ Icons.directionIcon "grey"
@ -2407,3 +2526,14 @@ renderEditAttachmentName model attach =
Nothing ->
span [ class "invisible hidden" ] []
isFolderMember : Model -> Bool
isFolderMember model =
let
selected =
Comp.Dropdown.getSelected model.folderModel
|> List.head
|> Maybe.map .id
in
Util.Folder.isFolderMember model.allFolders selected

View File

@ -61,7 +61,7 @@ emptyModel : Model
emptyModel =
{ connectionModel =
Comp.Dropdown.makeSingle
{ makeOption = \a -> { value = a, text = a }
{ makeOption = \a -> { value = a, text = a, additional = "" }
, placeholder = "Select connection..."
}
, subject = ""
@ -124,7 +124,7 @@ update flags msg model =
cm =
Comp.Dropdown.makeSingleList
{ makeOption = \a -> { value = a, text = a }
{ makeOption = \a -> { value = a, text = a, additional = "" }
, placeholder = "Select Connection..."
, options = names
, selected = List.head names

View File

@ -139,7 +139,7 @@ init flags =
( { settings = Api.Model.NotificationSettings.empty
, connectionModel =
Comp.Dropdown.makeSingle
{ makeOption = \a -> { value = a, text = a }
{ makeOption = \a -> { value = a, text = a, additional = "" }
, placeholder = "Select connection..."
}
, tagInclModel = Util.Tag.makeDropdownModel
@ -290,7 +290,7 @@ update flags msg model =
cm =
Comp.Dropdown.makeSingleList
{ makeOption = \a -> { value = a, text = a }
{ makeOption = \a -> { value = a, text = a, additional = "" }
, placeholder = "Select Connection..."
, options = names
, selected = List.head names

View File

@ -10,10 +10,13 @@ module Comp.ScanMailboxForm exposing
import Api
import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.FolderItem exposing (FolderItem)
import Api.Model.FolderList exposing (FolderList)
import Api.Model.IdName exposing (IdName)
import Api.Model.ImapSettingsList exposing (ImapSettingsList)
import Api.Model.ScanMailboxSettings exposing (ScanMailboxSettings)
import Comp.CalEventInput
import Comp.Dropdown
import Comp.Dropdown exposing (isDropdownChangeMsg)
import Comp.IntField
import Comp.StringListInput
import Comp.YesNoDimmer
@ -26,9 +29,12 @@ import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onClick, onInput)
import Http
import Markdown
import Util.Folder exposing (mkFolderOption)
import Util.Http
import Util.List
import Util.Maybe
import Util.Update
type alias Model =
@ -47,6 +53,9 @@ type alias Model =
, formMsg : Maybe BasicResult
, loading : Int
, yesNoDelete : Comp.YesNoDimmer.Model
, folderModel : Comp.Dropdown.Model IdName
, allFolders : List FolderItem
, itemFolderId : Maybe String
}
@ -73,6 +82,8 @@ type Msg
| FoldersMsg Comp.StringListInput.Msg
| DirectionMsg (Maybe Direction)
| YesNoDeleteMsg Comp.YesNoDimmer.Msg
| GetFolderResp (Result Http.Error FolderList)
| FolderDropdownMsg (Comp.Dropdown.Msg IdName)
initWith : Flags -> ScanMailboxSettings -> ( Model, Cmd Msg )
@ -108,11 +119,13 @@ initWith flags s =
, scheduleModel = sm
, formMsg = Nothing
, yesNoDelete = Comp.YesNoDimmer.emptyModel
, itemFolderId = s.itemFolder
}
, Cmd.batch
[ Api.getImapSettings flags "" ConnResp
, nc
, Cmd.map CalEventMsg sc
, Api.getFolders flags "" False GetFolderResp
]
)
@ -129,7 +142,7 @@ init flags =
( { settings = Api.Model.ScanMailboxSettings.empty
, connectionModel =
Comp.Dropdown.makeSingle
{ makeOption = \a -> { value = a, text = a }
{ makeOption = \a -> { value = a, text = a, additional = "" }
, placeholder = "Select connection..."
}
, enabled = False
@ -143,12 +156,20 @@ init flags =
, schedule = initialSchedule
, scheduleModel = sm
, formMsg = Nothing
, loading = 1
, loading = 2
, yesNoDelete = Comp.YesNoDimmer.emptyModel
, folderModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, allFolders = []
, itemFolderId = Nothing
}
, Cmd.batch
[ Api.getImapSettings flags "" ConnResp
, Cmd.map CalEventMsg sc
, Api.getFolders flags "" False GetFolderResp
]
)
@ -186,6 +207,7 @@ makeSettings model =
, folders = folders
, direction = Maybe.map Data.Direction.toString model.direction
, schedule = Data.CalEvent.makeEvent timer
, itemFolder = model.itemFolderId
}
in
Data.Validated.map3 make
@ -260,7 +282,7 @@ update flags msg model =
cm =
Comp.Dropdown.makeSingleList
{ makeOption = \a -> { value = a, text = a }
{ makeOption = \a -> { value = a, text = a, additional = "" }
, placeholder = "Select Connection..."
, options = names
, selected = List.head names
@ -402,6 +424,84 @@ update flags msg model =
, Cmd.none
)
GetFolderResp (Ok fs) ->
let
model_ =
{ model
| allFolders = fs.items
, loading = model.loading - 1
, folderModel =
Comp.Dropdown.setMkOption
(mkFolderOption flags fs.items)
model.folderModel
}
mkIdName fitem =
IdName fitem.id fitem.name
opts =
fs.items
|> List.map mkIdName
|> Comp.Dropdown.SetOptions
mkIdNameFromId id =
List.filterMap
(\f ->
if f.id == id then
Just (IdName id f.name)
else
Nothing
)
fs.items
sel =
case Maybe.map mkIdNameFromId model.itemFolderId of
Just idref ->
idref
Nothing ->
[]
removeAction ( a, _, c ) =
( a, c )
addNoAction ( a, b ) =
( a, NoAction, b )
in
Util.Update.andThen1
[ update flags (FolderDropdownMsg opts) >> removeAction
, update flags (FolderDropdownMsg (Comp.Dropdown.SetSelection sel)) >> removeAction
]
model_
|> addNoAction
GetFolderResp (Err _) ->
( { model | loading = model.loading - 1 }
, NoAction
, Cmd.none
)
FolderDropdownMsg m ->
let
( m2, c2 ) =
Comp.Dropdown.update m model.folderModel
newModel =
{ model | folderModel = m2 }
idref =
Comp.Dropdown.getSelected m2 |> List.head
model_ =
if isDropdownChangeMsg m then
{ newModel | itemFolderId = Maybe.map .id idref }
else
newModel
in
( model_, NoAction, Cmd.map FolderDropdownMsg c2 )
--- View
@ -424,7 +524,7 @@ view : String -> UiSettings -> Model -> Html Msg
view extraClasses settings model =
div
[ classList
[ ( "ui form", True )
[ ( "ui warning form", True )
, ( extraClasses, True )
, ( "error", isFormError model )
, ( "success", isFormSuccess model )
@ -547,6 +647,28 @@ view extraClasses settings model =
]
]
]
, div [ class "field" ]
[ label []
[ text "Item Folder"
]
, Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel)
, span [ class "small-info" ]
[ text "Put all items from this mailbox into the selected folder"
]
, div
[ classList
[ ( "ui warning message", True )
, ( "hidden", isFolderMember model )
]
]
[ Markdown.toHtml [] """
You are **not a member** of this folder. Items created from mails in
this mailbox will be **hidden** from any search results. Use a folder
where you are a member of to make items visible. This message will
disappear then.
"""
]
]
, div [ class "required field" ]
[ label []
[ text "Schedule"
@ -612,3 +734,14 @@ view extraClasses settings model =
[ text "Start Once"
]
]
isFolderMember : Model -> Bool
isFolderMember model =
let
selected =
Comp.Dropdown.getSelected model.folderModel
|> List.head
|> Maybe.map .id
in
Util.Folder.isFolderMember model.allFolders selected

View File

@ -11,6 +11,7 @@ module Comp.SearchMenu exposing
import Api
import Api.Model.Equipment exposing (Equipment)
import Api.Model.EquipmentList exposing (EquipmentList)
import Api.Model.FolderList exposing (FolderList)
import Api.Model.IdName exposing (IdName)
import Api.Model.ItemSearch exposing (ItemSearch)
import Api.Model.ReferenceList exposing (ReferenceList)
@ -45,6 +46,7 @@ type alias Model =
, corrPersonModel : Comp.Dropdown.Model IdName
, concPersonModel : Comp.Dropdown.Model IdName
, concEquipmentModel : Comp.Dropdown.Model Equipment
, folderModel : Comp.Dropdown.Model IdName
, inboxCheckbox : Bool
, fromDateModel : DatePicker
, fromDate : Maybe Int
@ -72,6 +74,7 @@ init =
\entry ->
{ value = Data.Direction.toString entry
, text = Data.Direction.toString entry
, additional = ""
}
, options = Data.Direction.all
, placeholder = "Choose a direction"
@ -81,28 +84,36 @@ init =
Comp.Dropdown.makeModel
{ multiple = False
, searchable = \n -> n > 5
, makeOption = \e -> { value = e.id, text = e.name }
, makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, labelColor = \_ -> \_ -> ""
, placeholder = "Choose an organization"
}
, corrPersonModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name }
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = "Choose a person"
}
, concPersonModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name }
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = "Choose a person"
}
, concEquipmentModel =
Comp.Dropdown.makeModel
{ multiple = False
, searchable = \n -> n > 5
, makeOption = \e -> { value = e.id, text = e.name }
, makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, labelColor = \_ -> \_ -> ""
, placeholder = "Choose an equipment"
}
, folderModel =
Comp.Dropdown.makeModel
{ multiple = False
, searchable = \n -> n > 5
, makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, labelColor = \_ -> \_ -> ""
, placeholder = "Only items in folder"
}
, inboxCheckbox = False
, fromDateModel = Comp.DatePicker.emptyModel
, fromDate = Nothing
@ -144,6 +155,8 @@ type Msg
| ResetForm
| KeyUpMsg (Maybe KeyCode)
| ToggleNameHelp
| FolderMsg (Comp.Dropdown.Msg IdName)
| GetFolderResp (Result Http.Error FolderList)
getDirection : Model -> Maybe Direction
@ -184,6 +197,7 @@ getItemSearch model =
, corrOrg = Comp.Dropdown.getSelected model.orgModel |> List.map .id |> List.head
, concPerson = Comp.Dropdown.getSelected model.concPersonModel |> List.map .id |> List.head
, concEquip = Comp.Dropdown.getSelected model.concEquipmentModel |> List.map .id |> List.head
, folder = Comp.Dropdown.getSelected model.folderModel |> List.map .id |> List.head
, direction = Comp.Dropdown.getSelected model.directionModel |> List.head |> Maybe.map Data.Direction.toString
, inbox = model.inboxCheckbox
, dateFrom = model.fromDate
@ -250,6 +264,7 @@ update flags settings msg model =
, Api.getOrgLight flags GetOrgResp
, Api.getEquipments flags "" GetEquipResp
, Api.getPersonsLight flags GetPersonResp
, Api.getFolders flags "" False GetFolderResp
, cdp
]
)
@ -513,6 +528,29 @@ update flags settings msg model =
ToggleNameHelp ->
NextState ( { model | showNameHelp = not model.showNameHelp }, Cmd.none ) False
GetFolderResp (Ok fs) ->
let
opts =
List.filter .isMember fs.items
|> List.map (\e -> IdName e.id e.name)
|> Comp.Dropdown.SetOptions
in
update flags settings (FolderMsg opts) model
GetFolderResp (Err _) ->
noChange ( model, Cmd.none )
FolderMsg lm ->
let
( m2, c2 ) =
Comp.Dropdown.update lm model.folderModel
in
NextState
( { model | folderModel = m2 }
, Cmd.map FolderMsg c2
)
(isDropdownChangeMsg lm)
-- View
@ -629,6 +667,11 @@ view flags settings model =
[ text "Looks in item name only."
]
]
, formHeader (Icons.folderIcon "") "Folder"
, div [ class "field" ]
[ label [] [ text "Folder" ]
, Html.map FolderMsg (Comp.Dropdown.view settings model.folderModel)
]
, formHeader (Icons.tagsIcon "") "Tags"
, div [ class "field" ]
[ label [] [ text "Include (and)" ]

View File

@ -1,20 +1,29 @@
module Comp.SourceForm exposing
( Model
, Msg(..)
, emptyModel
, getSource
, init
, isValid
, update
, view
)
import Api
import Api.Model.FolderItem exposing (FolderItem)
import Api.Model.FolderList exposing (FolderList)
import Api.Model.IdName exposing (IdName)
import Api.Model.Source exposing (Source)
import Comp.Dropdown exposing (isDropdownChangeMsg)
import Comp.FixedDropdown
import Data.Flags exposing (Flags)
import Data.Priority exposing (Priority)
import Data.UiSettings exposing (UiSettings)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onInput)
import Http
import Markdown
import Util.Folder exposing (mkFolderOption)
type alias Model =
@ -24,6 +33,9 @@ type alias Model =
, priorityModel : Comp.FixedDropdown.Model Priority
, priority : Priority
, enabled : Bool
, folderModel : Comp.Dropdown.Model IdName
, allFolders : List FolderItem
, folderId : Maybe String
}
@ -38,9 +50,23 @@ emptyModel =
Data.Priority.all
, priority = Data.Priority.Low
, enabled = False
, folderModel =
Comp.Dropdown.makeSingle
{ makeOption = \e -> { value = e.id, text = e.name, additional = "" }
, placeholder = ""
}
, allFolders = []
, folderId = Nothing
}
init : Flags -> ( Model, Cmd Msg )
init flags =
( emptyModel
, Api.getFolders flags "" False GetFolderResp
)
isValid : Model -> Bool
isValid model =
model.abbrev /= ""
@ -57,6 +83,7 @@ getSource model =
, description = model.description
, enabled = model.enabled
, priority = Data.Priority.toName model.priority
, folder = model.folderId
}
@ -66,10 +93,12 @@ type Msg
| SetDescr String
| ToggleEnabled
| PrioDropdownMsg (Comp.FixedDropdown.Msg Priority)
| GetFolderResp (Result Http.Error FolderList)
| FolderDropdownMsg (Comp.Dropdown.Msg IdName)
update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
update _ msg model =
update flags msg model =
case msg of
SetSource t ->
let
@ -83,19 +112,41 @@ update _ msg model =
, description = t.description
, priority = t.priority
, enabled = t.enabled
, folder = t.folder
}
newModel =
{ model
| source = np
, abbrev = t.abbrev
, description = t.description
, priority =
Data.Priority.fromString t.priority
|> Maybe.withDefault Data.Priority.Low
, enabled = t.enabled
, folderId = t.folder
}
mkIdName id =
List.filterMap
(\f ->
if f.id == id then
Just (IdName id f.name)
else
Nothing
)
model.allFolders
sel =
case Maybe.map mkIdName t.folder of
Just idref ->
idref
Nothing ->
[]
in
( { model
| source = np
, abbrev = t.abbrev
, description = t.description
, priority =
Data.Priority.fromString t.priority
|> Maybe.withDefault Data.Priority.Low
, enabled = t.enabled
}
, Cmd.none
)
update flags (FolderDropdownMsg (Comp.Dropdown.SetSelection sel)) newModel
ToggleEnabled ->
( { model | enabled = not model.enabled }, Cmd.none )
@ -127,16 +178,60 @@ update _ msg model =
, Cmd.none
)
GetFolderResp (Ok fs) ->
let
model_ =
{ model
| allFolders = fs.items
, folderModel =
Comp.Dropdown.setMkOption
(mkFolderOption flags fs.items)
model.folderModel
}
view : Flags -> Model -> Html Msg
view flags model =
mkIdName fitem =
IdName fitem.id fitem.name
opts =
fs.items
|> List.map mkIdName
|> Comp.Dropdown.SetOptions
in
update flags (FolderDropdownMsg opts) model_
GetFolderResp (Err _) ->
( model, Cmd.none )
FolderDropdownMsg m ->
let
( m2, c2 ) =
Comp.Dropdown.update m model.folderModel
newModel =
{ model | folderModel = m2 }
idref =
Comp.Dropdown.getSelected m2 |> List.head
model_ =
if isDropdownChangeMsg m then
{ newModel | folderId = Maybe.map .id idref }
else
newModel
in
( model_, Cmd.map FolderDropdownMsg c2 )
view : Flags -> UiSettings -> Model -> Html Msg
view flags settings model =
let
priorityItem =
Comp.FixedDropdown.Item
model.priority
(Data.Priority.toName model.priority)
in
div [ class "ui form" ]
div [ class "ui warning form" ]
[ div
[ classList
[ ( "field", True )
@ -179,6 +274,25 @@ view flags model =
model.priorityModel
)
]
, div [ class "field" ]
[ label []
[ text "Folder"
]
, Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel)
, div
[ classList
[ ( "ui warning message", True )
, ( "hidden", isFolderMember model )
]
]
[ Markdown.toHtml [] """
You are **not a member** of this folder. Items created through this
link will be **hidden** from any search results. Use a folder where
you are a member of to make items visible. This message will
disappear then.
"""
]
]
, urlInfoMessage flags model
]
@ -217,3 +331,14 @@ urlInfoMessage flags model =
]
]
]
isFolderMember : Model -> Bool
isFolderMember model =
let
selected =
Comp.Dropdown.getSelected model.folderModel
|> List.head
|> Maybe.map .id
in
Util.Folder.isFolderMember model.allFolders selected

View File

@ -1,7 +1,7 @@
module Comp.SourceManage exposing
( Model
, Msg(..)
, emptyModel
, init
, update
, view
)
@ -14,6 +14,7 @@ import Comp.SourceForm
import Comp.SourceTable
import Comp.YesNoDimmer
import Data.Flags exposing (Flags)
import Data.UiSettings exposing (UiSettings)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onSubmit)
@ -37,15 +38,21 @@ type ViewMode
| Form
emptyModel : Model
emptyModel =
{ tableModel = Comp.SourceTable.emptyModel
, formModel = Comp.SourceForm.emptyModel
, viewMode = Table
, formError = Nothing
, loading = False
, deleteConfirm = Comp.YesNoDimmer.emptyModel
}
init : Flags -> ( Model, Cmd Msg )
init flags =
let
( fm, fc ) =
Comp.SourceForm.init flags
in
( { tableModel = Comp.SourceTable.emptyModel
, formModel = fm
, viewMode = Table
, formError = Nothing
, loading = False
, deleteConfirm = Comp.YesNoDimmer.emptyModel
}
, Cmd.map FormMsg fc
)
type Msg
@ -187,13 +194,13 @@ update flags msg model =
( { model | deleteConfirm = cm }, cmd )
view : Flags -> Model -> Html Msg
view flags model =
view : Flags -> UiSettings -> Model -> Html Msg
view flags settings model =
if model.viewMode == Table then
viewTable model
else
div [] (viewForm flags model)
div [] (viewForm flags settings model)
viewTable : Model -> Html Msg
@ -215,8 +222,8 @@ viewTable model =
]
viewForm : Flags -> Model -> List (Html Msg)
viewForm flags model =
viewForm : Flags -> UiSettings -> Model -> List (Html Msg)
viewForm flags settings model =
let
newSource =
model.formModel.source.id == ""
@ -236,7 +243,7 @@ viewForm flags model =
]
, Html.form [ class "ui attached segment", onSubmit Submit ]
[ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm)
, Html.map FormMsg (Comp.SourceForm.view flags model.formModel)
, Html.map FormMsg (Comp.SourceForm.view flags settings model.formModel)
, div
[ classList
[ ( "ui error message", True )

View File

@ -41,6 +41,7 @@ emptyModel =
\s ->
{ value = Data.UserState.toString s
, text = Data.UserState.toString s
, additional = ""
}
, placeholder = ""
, options = Data.UserState.all
@ -98,7 +99,12 @@ update _ msg model =
let
state =
Comp.Dropdown.makeSingleList
{ makeOption = \s -> { value = Data.UserState.toString s, text = Data.UserState.toString s }
{ makeOption =
\s ->
{ value = Data.UserState.toString s
, text = Data.UserState.toString s
, additional = ""
}
, placeholder = ""
, options = Data.UserState.all
, selected =

View File

@ -15,6 +15,8 @@ module Data.Icons exposing
, editNotesIcon
, equipment
, equipmentIcon
, folder
, folderIcon
, organization
, organizationIcon
, person
@ -29,6 +31,16 @@ import Html exposing (Html, i)
import Html.Attributes exposing (class)
folder : String
folder =
"folder outline icon"
folderIcon : String -> Html msg
folderIcon classes =
i [ class (folder ++ " " ++ classes) ] []
concerned : String
concerned =
"crosshairs icon"

View File

@ -2,7 +2,7 @@ module Page.CollectiveSettings.Data exposing
( Model
, Msg(..)
, Tab(..)
, emptyModel
, init
)
import Api.Model.BasicResult exposing (BasicResult)
@ -11,6 +11,7 @@ import Api.Model.ItemInsights exposing (ItemInsights)
import Comp.CollectiveSettingsForm
import Comp.SourceManage
import Comp.UserManage
import Data.Flags exposing (Flags)
import Http
@ -24,15 +25,21 @@ type alias Model =
}
emptyModel : Model
emptyModel =
{ currentTab = Just InsightsTab
, sourceModel = Comp.SourceManage.emptyModel
, userModel = Comp.UserManage.emptyModel
, settingsModel = Comp.CollectiveSettingsForm.init Api.Model.CollectiveSettings.empty
, insights = Api.Model.ItemInsights.empty
, submitResult = Nothing
}
init : Flags -> ( Model, Cmd Msg )
init flags =
let
( sm, sc ) =
Comp.SourceManage.init flags
in
( { currentTab = Just InsightsTab
, sourceModel = sm
, userModel = Comp.UserManage.emptyModel
, settingsModel = Comp.CollectiveSettingsForm.init Api.Model.CollectiveSettings.empty
, insights = Api.Model.ItemInsights.empty
, submitResult = Nothing
}
, Cmd.map SourceMsg sc
)
type Tab

View File

@ -59,7 +59,7 @@ view flags settings model =
[ div [ class "" ]
(case model.currentTab of
Just SourceTab ->
viewSources flags model
viewSources flags settings model
Just UserTab ->
viewUsers settings model
@ -153,15 +153,15 @@ makeTagStats nc =
]
viewSources : Flags -> Model -> List (Html Msg)
viewSources flags model =
viewSources : Flags -> UiSettings -> Model -> List (Html Msg)
viewSources flags settings model =
[ h2 [ class "ui header" ]
[ i [ class "ui upload icon" ] []
, div [ class "content" ]
[ text "Sources"
]
]
, Html.map SourceMsg (Comp.SourceManage.view flags model.sourceModel)
, Html.map SourceMsg (Comp.SourceManage.view flags settings model.sourceModel)
]

View File

@ -2,13 +2,15 @@ module Page.ManageData.Data exposing
( Model
, Msg(..)
, Tab(..)
, emptyModel
, init
)
import Comp.EquipmentManage
import Comp.FolderManage
import Comp.OrgManage
import Comp.PersonManage
import Comp.TagManage
import Data.Flags exposing (Flags)
type alias Model =
@ -17,17 +19,21 @@ type alias Model =
, equipManageModel : Comp.EquipmentManage.Model
, orgManageModel : Comp.OrgManage.Model
, personManageModel : Comp.PersonManage.Model
, folderManageModel : Comp.FolderManage.Model
}
emptyModel : Model
emptyModel =
{ currentTab = Nothing
, tagManageModel = Comp.TagManage.emptyModel
, equipManageModel = Comp.EquipmentManage.emptyModel
, orgManageModel = Comp.OrgManage.emptyModel
, personManageModel = Comp.PersonManage.emptyModel
}
init : Flags -> ( Model, Cmd Msg )
init _ =
( { currentTab = Nothing
, tagManageModel = Comp.TagManage.emptyModel
, equipManageModel = Comp.EquipmentManage.emptyModel
, orgManageModel = Comp.OrgManage.emptyModel
, personManageModel = Comp.PersonManage.emptyModel
, folderManageModel = Comp.FolderManage.empty
}
, Cmd.none
)
type Tab
@ -35,6 +41,7 @@ type Tab
| EquipTab
| OrgTab
| PersonTab
| FolderTab
type Msg
@ -43,3 +50,4 @@ type Msg
| EquipManageMsg Comp.EquipmentManage.Msg
| OrgManageMsg Comp.OrgManage.Msg
| PersonManageMsg Comp.PersonManage.Msg
| FolderMsg Comp.FolderManage.Msg

View File

@ -1,6 +1,7 @@
module Page.ManageData.Update exposing (update)
import Comp.EquipmentManage
import Comp.FolderManage
import Comp.OrgManage
import Comp.PersonManage
import Comp.TagManage
@ -29,6 +30,13 @@ update flags msg model =
PersonTab ->
update flags (PersonManageMsg Comp.PersonManage.LoadPersons) m
FolderTab ->
let
( sm, sc ) =
Comp.FolderManage.init flags
in
( { m | folderManageModel = sm }, Cmd.map FolderMsg sc )
TagManageMsg m ->
let
( m2, c2 ) =
@ -56,3 +64,12 @@ update flags msg model =
Comp.PersonManage.update flags m model.personManageModel
in
( { model | personManageModel = m2 }, Cmd.map PersonManageMsg c2 )
FolderMsg lm ->
let
( m2, c2 ) =
Comp.FolderManage.update flags lm model.folderManageModel
in
( { model | folderManageModel = m2 }
, Cmd.map FolderMsg c2
)

View File

@ -1,9 +1,11 @@
module Page.ManageData.View exposing (view)
import Comp.EquipmentManage
import Comp.FolderManage
import Comp.OrgManage
import Comp.PersonManage
import Comp.TagManage
import Data.Flags exposing (Flags)
import Data.Icons as Icons
import Data.UiSettings exposing (UiSettings)
import Html exposing (..)
@ -13,8 +15,8 @@ import Page.ManageData.Data exposing (..)
import Util.Html exposing (classActive)
view : UiSettings -> Model -> Html Msg
view settings model =
view : Flags -> UiSettings -> Model -> Html Msg
view flags settings model =
div [ class "managedata-page ui padded grid" ]
[ div [ class "sixteen wide mobile four wide tablet four wide computer column" ]
[ h4 [ class "ui top attached ablue-comp header" ]
@ -50,6 +52,13 @@ view settings model =
[ Icons.personIcon ""
, text "Person"
]
, div
[ classActive (model.currentTab == Just FolderTab) "link icon item"
, onClick (SetTab FolderTab)
]
[ Icons.folderIcon ""
, text "Folder"
]
]
]
]
@ -68,6 +77,9 @@ view settings model =
Just PersonTab ->
viewPerson settings model
Just FolderTab ->
viewFolder flags settings model
Nothing ->
[]
)
@ -75,6 +87,22 @@ view settings model =
]
viewFolder : Flags -> UiSettings -> Model -> List (Html Msg)
viewFolder flags _ model =
[ h2
[ class "ui header"
]
[ Icons.folderIcon ""
, div
[ class "content"
]
[ text "Folders"
]
]
, Html.map FolderMsg (Comp.FolderManage.view flags model.folderManageModel)
]
viewTags : Model -> List (Html Msg)
viewTags model =
[ h2 [ class "ui header" ]

View File

@ -0,0 +1,53 @@
module Util.Folder exposing
( isFolderMember
, mkFolderOption
)
import Api.Model.FolderItem exposing (FolderItem)
import Api.Model.IdName exposing (IdName)
import Comp.Dropdown
import Data.Flags exposing (Flags)
mkFolderOption : Flags -> List FolderItem -> IdName -> Comp.Dropdown.Option
mkFolderOption flags allFolders idref =
let
folder =
List.filter (\e -> e.id == idref.id) allFolders
|> List.head
isMember =
folder
|> Maybe.map .isMember
|> Maybe.withDefault False
isOwner =
Maybe.map .owner folder
|> Maybe.map .name
|> (==) (Maybe.map .user flags.account)
adds =
if isOwner then
"owner"
else if isMember then
"member"
else
""
in
{ value = idref.id, text = idref.name, additional = adds }
isFolderMember : List FolderItem -> Maybe String -> Bool
isFolderMember allFolders selected =
let
findFolder id =
List.filter (\e -> e.id == id) allFolders
|> List.head
folder =
Maybe.andThen findFolder selected
in
Maybe.map .isMember folder
|> Maybe.withDefault True

View File

@ -10,7 +10,7 @@ makeDropdownModel =
Comp.Dropdown.makeModel
{ multiple = True
, searchable = \n -> n > 5
, makeOption = \tag -> { value = tag.id, text = tag.name }
, makeOption = \tag -> { value = tag.id, text = tag.name, additional = "" }
, labelColor =
\tag ->
\settings ->