diff --git a/build.sbt b/build.sbt index d9c31f2c..62a8fe5e 100644 --- a/build.sbt +++ b/build.sbt @@ -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") diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index a9f6274c..72ce0138 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -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]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala new file mode 100644 index 00000000..41576378 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala @@ -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)) + }) +} diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala index 77f9fab4..1f88d740 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala @@ -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, diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala index 7c170230..d17b453b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -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)) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index de517fb5..e4b42b24 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -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 diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala b/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala index 6d9f0669..7c4b043c 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala @@ -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) diff --git a/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala b/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala index a4b209dd..9e3faf2b 100644 --- a/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala +++ b/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala @@ -36,6 +36,7 @@ object ProcessItemArgs { language: Language, direction: Option[Direction], sourceAbbrev: String, + folderId: Option[Ident], validFileTypes: Seq[MimeType] ) diff --git a/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala b/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala index c4687a41..fa86b903 100644 --- a/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala +++ b/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala @@ -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 { diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala index 3a0e3d17..dcf2d88f 100644 --- a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala +++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala @@ -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!") diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsMigration.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsMigration.scala new file mode 100644 index 00000000..3e8fae4e --- /dev/null +++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsMigration.scala @@ -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 + } +} diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsQuery.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsQuery.scala index 785d2e20..f5027867 100644 --- a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsQuery.scala +++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsQuery.scala @@ -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 { diff --git a/modules/fts-client/src/main/scala/docspell/ftsclient/TextData.scala b/modules/fts-client/src/main/scala/docspell/ftsclient/TextData.scala index 625411ad..3f043599 100644 --- a/modules/fts-client/src/main/scala/docspell/ftsclient/TextData.scala +++ b/modules/fts-client/src/main/scala/docspell/ftsclient/TextData.scala @@ -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) } diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/DocIdResult.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/DocIdResult.scala new file mode 100644 index 00000000..a6070443 --- /dev/null +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/DocIdResult.scala @@ -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)) +} diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/Field.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/Field.scala index 053eb5c8..6031cd61 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/Field.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/Field.scala @@ -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 { diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala index e532bf6b..4c639668 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/JsonCodec.scala @@ -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 diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/QueryData.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/QueryData.scala index 1ca3e483..0c332630 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/QueryData.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/QueryData.scala @@ -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, diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SetFolder.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SetFolder.scala new file mode 100644 index 00000000..5dedb968 --- /dev/null +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SetFolder.scala @@ -0,0 +1,5 @@ +package docspell.ftssolr + +import docspell.common._ + +final case class SetFolder(docId: Ident, folder: Option[Ident]) diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala index 635c0d97..f8f7fd3b 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala @@ -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] = diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala index 6952c823..932519c8 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrSetup.scala @@ -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, diff --git a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala index 88089d51..b5b5e642 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala @@ -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) => diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index ee59f2f9..965659b7 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -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] ) ) diff --git a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala index a4952271..88369f9f 100644 --- a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala +++ b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala @@ -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) + ) ) ) ) diff --git a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala index 4eb7df6c..ff0f9c7c 100644 --- a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala +++ b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala @@ -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], diff --git a/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala b/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala index 4189fc25..b8b27b5e 100644 --- a/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/fts/MigrationTask.scala @@ -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)) } diff --git a/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala b/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala index 205f31c8..c1d794e4 100644 --- a/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/fts/ReIndexTask.scala @@ -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] }) } diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala index 218b4d0d..1eb24a75 100644 --- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala @@ -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, diff --git a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala index 92add9d6..f489ae3c 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala @@ -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 diff --git a/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala b/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala index f18f29e7..4da8f779 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala @@ -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] = diff --git a/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala b/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala index c81230bf..139ec8f6 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala @@ -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]( diff --git a/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala b/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala new file mode 100644 index 00000000..ba51af23 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala @@ -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 + } + +} diff --git a/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala b/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala index 23024d4e..912507a5 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala @@ -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 diff --git a/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala b/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala index 7c0ef6bb..e98ef3ea 100644 --- a/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala @@ -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")) diff --git a/modules/microsite/docs/doc/uploading.md b/modules/microsite/docs/doc/uploading.md index 5233a4f3..bd2d45d9 100644 --- a/modules/microsite/docs/doc/uploading.md +++ b/modules/microsite/docs/doc/uploading.md @@ -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: diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index df434dc6..02a673cc 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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 diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 71934336..501628ea 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -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] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index caf92d9d..7c57b5e3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -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.") diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index 1c8ff477..b83296a1 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -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") diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/FolderRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/FolderRoutes.scala new file mode 100644 index 00000000..0a9305bc --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/FolderRoutes.scala @@ -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.") + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index ba1003d5..02eabd9c 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -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] diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala index 7e3ab8cc..4a1a738c 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala @@ -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 ) } diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.8.0__folders.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.8.0__folders.sql new file mode 100644 index 00000000..f94af805 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.8.0__folders.sql @@ -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`); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__folders.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__folders.sql new file mode 100644 index 00000000..0eec9067 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__folders.sql @@ -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"); diff --git a/modules/store/src/main/scala/docspell/store/UpdateResult.scala b/modules/store/src/main/scala/docspell/store/UpdateResult.scala new file mode 100644 index 00000000..09ae064a --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/UpdateResult.scala @@ -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) + } +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala b/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala index 81c734c3..b0a479ce 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala @@ -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) ++ diff --git a/modules/store/src/main/scala/docspell/store/queries/QFolder.scala b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala new file mode 100644 index 00000000..9c922d48 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala @@ -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)) +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 84c81e9a..bc6dc7ce 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -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] diff --git a/modules/store/src/main/scala/docspell/store/records/RFolder.scala b/modules/store/src/main/scala/docspell/store/records/RFolder.scala new file mode 100644 index 00000000..0b3b0ebb --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RFolder.scala @@ -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 +} diff --git a/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala b/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala new file mode 100644 index 00000000..cb7b5f21 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala @@ -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 +} diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index 987a77c0..97b87d84 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -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 + } } diff --git a/modules/store/src/main/scala/docspell/store/records/RSource.scala b/modules/store/src/main/scala/docspell/store/records/RSource.scala index 8e529e95..ea76a919 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSource.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSource.scala @@ -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 + } } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index b84880b0..2934547d 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -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 diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index f8574248..ba9fe730 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -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 + ] ) diff --git a/modules/webapp/src/main/elm/App/View.elm b/modules/webapp/src/main/elm/App/View.elm index bbf90084..376f5153 100644 --- a/modules/webapp/src/main/elm/App/View.elm +++ b/modules/webapp/src/main/elm/App/View.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/AddressForm.elm b/modules/webapp/src/main/elm/Comp/AddressForm.elm index 8c8c4baf..a9d5a1b6 100644 --- a/modules/webapp/src/main/elm/Comp/AddressForm.elm +++ b/modules/webapp/src/main/elm/Comp/AddressForm.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm index 04fbe4de..342473c1 100644 --- a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm @@ -43,6 +43,7 @@ init settings = \l -> { value = Data.Language.toIso3 l , text = Data.Language.toName l + , additional = "" } , placeholder = "" , options = Data.Language.all diff --git a/modules/webapp/src/main/elm/Comp/ContactField.elm b/modules/webapp/src/main/elm/Comp/ContactField.elm index 987a49a9..c349009a 100644 --- a/modules/webapp/src/main/elm/Comp/ContactField.elm +++ b/modules/webapp/src/main/elm/Comp/ContactField.elm @@ -32,6 +32,7 @@ emptyModel = \ct -> { value = Data.ContactType.toString ct , text = Data.ContactType.toString ct + , additional = "" } , placeholder = "" , options = Data.ContactType.all diff --git a/modules/webapp/src/main/elm/Comp/Dropdown.elm b/modules/webapp/src/main/elm/Comp/Dropdown.elm index 212d28c1..458057d7 100644 --- a/modules/webapp/src/main/elm/Comp/Dropdown.elm +++ b/modules/webapp/src/main/elm/Comp/Dropdown.elm @@ -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 + ] ] diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm index 1dc9502e..980da2e3 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm @@ -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 = diff --git a/modules/webapp/src/main/elm/Comp/FolderDetail.elm b/modules/webapp/src/main/elm/Comp/FolderDetail.elm new file mode 100644 index 00000000..6bf3aac7 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/FolderDetail.elm @@ -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 + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/FolderManage.elm b/modules/webapp/src/main/elm/Comp/FolderManage.elm new file mode 100644 index 00000000..6ffad0d6 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/FolderManage.elm @@ -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" ] [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/FolderTable.elm b/modules/webapp/src/main/elm/Comp/FolderTable.elm new file mode 100644 index 00000000..a44f5e59 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/FolderTable.elm @@ -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 + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ImapSettingsForm.elm b/modules/webapp/src/main/elm/Comp/ImapSettingsForm.elm index 241d68e3..5a51188b 100644 --- a/modules/webapp/src/main/elm/Comp/ImapSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/ImapSettingsForm.elm @@ -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 = diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm index 47d2c345..78d21a89 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm @@ -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" ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 718e5d80..4b3351ea 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/ItemMail.elm b/modules/webapp/src/main/elm/Comp/ItemMail.elm index c8ffd821..83f5ccd8 100644 --- a/modules/webapp/src/main/elm/Comp/ItemMail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemMail.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/NotificationForm.elm b/modules/webapp/src/main/elm/Comp/NotificationForm.elm index 119cff34..08eccb4a 100644 --- a/modules/webapp/src/main/elm/Comp/NotificationForm.elm +++ b/modules/webapp/src/main/elm/Comp/NotificationForm.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm b/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm index 61413311..46d842e5 100644 --- a/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm +++ b/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 5e1b93d9..b3411b28 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -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)" ] diff --git a/modules/webapp/src/main/elm/Comp/SourceForm.elm b/modules/webapp/src/main/elm/Comp/SourceForm.elm index 73e7ee17..a5202ef4 100644 --- a/modules/webapp/src/main/elm/Comp/SourceForm.elm +++ b/modules/webapp/src/main/elm/Comp/SourceForm.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/SourceManage.elm b/modules/webapp/src/main/elm/Comp/SourceManage.elm index 021f3225..11184e42 100644 --- a/modules/webapp/src/main/elm/Comp/SourceManage.elm +++ b/modules/webapp/src/main/elm/Comp/SourceManage.elm @@ -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 ) diff --git a/modules/webapp/src/main/elm/Comp/UserForm.elm b/modules/webapp/src/main/elm/Comp/UserForm.elm index 25121d03..a982da0b 100644 --- a/modules/webapp/src/main/elm/Comp/UserForm.elm +++ b/modules/webapp/src/main/elm/Comp/UserForm.elm @@ -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 = diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index 8d891221..86999931 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -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" diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm index d12aa44f..1b1bd53b 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm index 57209673..92cf739c 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm @@ -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) ] diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm index 35b92d79..69178dac 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Data.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Data.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page/ManageData/Update.elm b/modules/webapp/src/main/elm/Page/ManageData/Update.elm index a7239ab2..f229e2ad 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm @@ -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 + ) diff --git a/modules/webapp/src/main/elm/Page/ManageData/View.elm b/modules/webapp/src/main/elm/Page/ManageData/View.elm index 9d048d2a..b7d853fb 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/View.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/View.elm @@ -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" ] diff --git a/modules/webapp/src/main/elm/Util/Folder.elm b/modules/webapp/src/main/elm/Util/Folder.elm new file mode 100644 index 00000000..64ea2572 --- /dev/null +++ b/modules/webapp/src/main/elm/Util/Folder.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Util/Tag.elm b/modules/webapp/src/main/elm/Util/Tag.elm index 413a09eb..a4bed92a 100644 --- a/modules/webapp/src/main/elm/Util/Tag.elm +++ b/modules/webapp/src/main/elm/Util/Tag.elm @@ -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 ->