From 13ad5e32196344291133e4f42924fc24a2d7bb40 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 30 Jun 2020 01:10:33 +0200 Subject: [PATCH 01/27] Setup space entities --- .../migration/postgresql/V1.8.0__spaces.sql | 30 +++++++++++++ .../scala/docspell/store/records/RSpace.scala | 44 +++++++++++++++++++ .../docspell/store/records/RSpaceItem.scala | 42 ++++++++++++++++++ .../docspell/store/records/RSpaceMember.scala | 42 ++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RSpace.scala create mode 100644 modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala create mode 100644 modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql new file mode 100644 index 00000000..e3741345 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql @@ -0,0 +1,30 @@ +CREATE TABLE "space" ( + "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 "space_member" ( + "id" varchar(254) not null primary key, + "space_id" varchar(254) not null, + "user_id" varchar(254) not null, + "created" timestamp not null, + unique ("space_id", "user_id"), + foreign key ("space_id") references "space"("id"), + foreign key ("user_id") references "user_"("uid") +); + +CREATE TABLE "space_item" ( + "id" varchar(254) not null primary key, + "space_id" varchar(254) not null, + "item_id" varchar(254) not null, + "created" timestamp not null, + unique ("space_id", "item_id"), + foreign key ("space_id") references "space"("id"), + foreign key ("item_id") references "item"("itemid") +); diff --git a/modules/store/src/main/scala/docspell/store/records/RSpace.scala b/modules/store/src/main/scala/docspell/store/records/RSpace.scala new file mode 100644 index 00000000..d6efe2c6 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RSpace.scala @@ -0,0 +1,44 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RSpace( + id: Ident, + name: String, + collectiveId: Ident, + owner: Ident, + created: Timestamp +) + +object RSpace { + + val table = fr"space" + + 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: RSpace): ConnectionIO[Int] = { + val sql = insertRow( + table, + all, + fr"${value.id},${value.name},${value.collectiveId},${value.owner},${value.created}" + ) + sql.update.run + } + +} diff --git a/modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala b/modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala new file mode 100644 index 00000000..ab7187fd --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala @@ -0,0 +1,42 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RSpaceItem( + id: Ident, + spaceId: Ident, + itemId: Ident, + created: Timestamp +) + +object RSpaceItem { + + val table = fr"space" + + object Columns { + + val id = Column("id") + val space = Column("space_id") + val item = Column("user_id") + val created = Column("created") + + val all = List(id, space, user, created) + } + + import Columns._ + + def insert(value: RSpaceItem): ConnectionIO[Int] = { + val sql = insertRow( + table, + all, + fr"${value.id},${value.spaceId},${value.itemId},${value.created}" + ) + sql.update.run + } + +} diff --git a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala new file mode 100644 index 00000000..4a13249e --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala @@ -0,0 +1,42 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RSpaceMember( + id: Ident, + spaceId: Ident, + userId: Ident, + created: Timestamp +) + +object RSpaceMember { + + val table = fr"space" + + object Columns { + + val id = Column("id") + val space = Column("space_id") + val user = Column("user_id") + val created = Column("created") + + val all = List(id, space, user, created) + } + + import Columns._ + + def insert(value: RSpaceMember): ConnectionIO[Int] = { + val sql = insertRow( + table, + all, + fr"${value.id},${value.spaceId},${value.userId},${value.created}" + ) + sql.update.run + } + +} From 7ec0fc2593eff78261d94488d8548ba300ba59ca Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 2 Jul 2020 23:11:42 +0200 Subject: [PATCH 02/27] Add endpoints for managing spaces to openapi spec --- .../src/main/resources/docspell-openapi.yml | 214 ++++++++++++++++++ .../migration/postgresql/V1.8.0__spaces.sql | 11 +- .../scala/docspell/store/records/RItem.scala | 10 +- .../scala/docspell/store/records/RSpace.scala | 39 ++++ .../docspell/store/records/RSpaceItem.scala | 42 ---- 5 files changed, 262 insertions(+), 54 deletions(-) delete mode 100644 modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index df434dc6..da318274 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -795,6 +795,139 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" + /sec/space: + get: + tags: [ Space ] + summary: Get a list of spaces. + description: | + Return a list of spaces for the current collective. + + All spaces 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" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/SpaceList" + post: + tags: [ Space ] + summary: Create a new space + description: | + Create a new space owned by the current user. If a space with + the same name already exists, an error is thrown. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NewSpace" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/space/{id}: + get: + tags: [ Space ] + summary: Get space details. + description: | + Return details about a space. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/SpaceDetail" + put: + tags: [ Space ] + summary: Change the name of a space + description: | + Changes the name of a space. The new name must not exists. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NewSpace" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + tags: [ Space ] + summary: Delete a space by its id. + description: | + Deletes a space. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/space/{id}/member/{userId}: + put: + tags: [ Space ] + summary: Add a member to this space + description: | + Adds a member to this space (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: [ Space ] + summary: Removes a member from this space. + description: | + Removes a member from this space. + 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 ] @@ -2358,6 +2491,80 @@ paths: components: schemas: + SpaceList: + description: | + A list of spaces with their member counts. + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/SpaceItem" + SpaceItem: + description: | + An item in a space list. + required: + - id + - name + - owner + - created + - members + properties: + id: + type: string + format: ident + name: + type: string + owner: + $ref: "#/components/schemas/IdName" + created: + type: integer + format: date-time + members: + type: integer + format: int32 + NewSpace: + description: | + Data required to create a new space. + required: + - name + properties: + name: + type: string + SpaceDetail: + description: | + Details about a space. + required: + - id + - name + - owner + - created + - members + properties: + id: + type: string + format: ident + name: + type: string + owner: + $ref: "#/components/schemas/IdName" + created: + type: integer + format: date-time + members: + type: array + items: + $ref: "#/components/schemas/IdName" + SpaceMember: + description: | + Information to add or remove a space member. + required: + - userId + properties: + userId: + type: string + format: ident ItemFtsSearch: description: | Query description for a full-text only search. @@ -3739,6 +3946,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 diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql index e3741345..8a43c097 100644 --- a/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql @@ -19,12 +19,5 @@ CREATE TABLE "space_member" ( foreign key ("user_id") references "user_"("uid") ); -CREATE TABLE "space_item" ( - "id" varchar(254) not null primary key, - "space_id" varchar(254) not null, - "item_id" varchar(254) not null, - "created" timestamp not null, - unique ("space_id", "item_id"), - foreign key ("space_id") references "space"("id"), - foreign key ("item_id") references "item"("itemid") -); +ALTER TABLE "item" +ADD COLUMN "space_id" varchar(254) NULL; 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..3e319ffa 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], + spaceId: 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 space = Column("space_id") val all = List( id, cid, @@ -96,7 +99,8 @@ object RItem { dueDate, created, updated, - notes + notes, + space ) } 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.spaceId}" ).update.run def getCollective(itemId: Ident): ConnectionIO[Option[Ident]] = diff --git a/modules/store/src/main/scala/docspell/store/records/RSpace.scala b/modules/store/src/main/scala/docspell/store/records/RSpace.scala index d6efe2c6..1da2ef0d 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSpace.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSpace.scala @@ -1,5 +1,7 @@ package docspell.store.records +import cats.effect._ +import cats.implicits._ import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ @@ -17,6 +19,12 @@ case class RSpace( object RSpace { + def newSpace[F[_]: Sync](name: String, account: AccountId): F[RSpace] = + for { + nId <- Ident.randomId[F] + now <- Timestamp.current[F] + } yield RSpace(nId, name, account.collective, account.user, now) + val table = fr"space" object Columns { @@ -41,4 +49,35 @@ object RSpace { sql.update.run } + def update(v: RSpace): 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, spaceName: String): ConnectionIO[Boolean] = + selectCount(id, table, and(collective.is(coll), name.is(spaceName))) + .query[Int] + .unique + .map(_ > 0) + + def findById(spaceId: Ident): ConnectionIO[Option[RSpace]] = { + val sql = selectSimple(all, table, id.is(spaceId)) + sql.query[RSpace].option + } + + def findAll( + coll: Ident, + nameQ: Option[String], + order: Columns.type => Column + ): ConnectionIO[Vector[RSpace]] = { + 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[RSpace].to[Vector] + } + } diff --git a/modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala b/modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala deleted file mode 100644 index ab7187fd..00000000 --- a/modules/store/src/main/scala/docspell/store/records/RSpaceItem.scala +++ /dev/null @@ -1,42 +0,0 @@ -package docspell.store.records - -import docspell.common._ -import docspell.store.impl.Column -import docspell.store.impl.Implicits._ - -import doobie._ -import doobie.implicits._ - -case class RSpaceItem( - id: Ident, - spaceId: Ident, - itemId: Ident, - created: Timestamp -) - -object RSpaceItem { - - val table = fr"space" - - object Columns { - - val id = Column("id") - val space = Column("space_id") - val item = Column("user_id") - val created = Column("created") - - val all = List(id, space, user, created) - } - - import Columns._ - - def insert(value: RSpaceItem): ConnectionIO[Int] = { - val sql = insertRow( - table, - all, - fr"${value.id},${value.spaceId},${value.itemId},${value.created}" - ) - sql.update.run - } - -} From c12201c4a585e3b3fb80d6a9ac4509414b5125f5 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Fri, 3 Jul 2020 00:08:32 +0200 Subject: [PATCH 03/27] Add routes to manage spaces --- .../scala/docspell/backend/BackendApp.scala | 3 + .../scala/docspell/backend/ops/OSpace.scala | 84 ++++++++++++++ .../docspell/restserver/RestServer.scala | 3 +- .../restserver/routes/SpaceRoutes.scala | 108 ++++++++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index a9f6274c..d62dddd1 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 space: OSpace[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) + spaceImpl <- OSpace(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 space = spaceImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala new file mode 100644 index 00000000..2962c55e --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala @@ -0,0 +1,84 @@ +package docspell.backend.ops + +import cats.effect._ + +import docspell.common._ +import docspell.store.{AddResult, Store} +import docspell.store.records.RSpace + +trait OSpace[F[_]] { + + def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[OSpace.SpaceItem]] + + def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] + + def delete(id: Ident, collective: Ident): F[Int] + + def add(space: RSpace): F[AddResult] + + def changeName(space: Ident, account: AccountId, name: String): F[AddResult] + + def addMember( + space: Ident, + account: AccountId, + member: Ident + ): F[OSpace.MemberChangeResult] + def removeMember( + space: Ident, + account: AccountId, + member: Ident + ): F[OSpace.MemberChangeResult] +} + +object OSpace { + + sealed trait MemberChangeResult + object MemberChangeResult { + case object Success extends MemberChangeResult + case object NotFound extends MemberChangeResult + case object Forbidden extends MemberChangeResult + } + + final case class SpaceItem( + id: Ident, + name: String, + owner: IdRef, + created: Timestamp, + members: Int + ) + + final case class SpaceDetail( + id: Ident, + name: String, + owner: IdRef, + created: Timestamp, + members: List[IdRef] + ) + + def apply[F[_]: Effect](store: Store[F]): Resource[F, OSpace[F]] = + Resource.pure[F, OSpace[F]](new OSpace[F] { + println(s"$store") + def findAll( + account: AccountId, + nameQuery: Option[String] + ): F[Vector[OSpace.SpaceItem]] = ??? + + def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] = ??? + def add(space: RSpace): F[AddResult] = ??? + def changeName(space: Ident, account: AccountId, name: String): F[AddResult] = ??? + def delete(id: Ident, collective: Ident): F[Int] = ??? + def addMember( + space: Ident, + account: AccountId, + member: Ident + ): F[MemberChangeResult] = + ??? + def removeMember( + space: Ident, + account: AccountId, + member: Ident + ): F[MemberChangeResult] = + ??? + + }) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 71934336..d31becf3 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), + "space" -> SpaceRoutes(restApp.backend, token) ) def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala new file mode 100644 index 00000000..1fb9e7a3 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala @@ -0,0 +1,108 @@ +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.OSpace +import docspell.common._ +import docspell.store.records.RSpace +import docspell.restapi.model._ +import docspell.restserver.conv.Conversions +import docspell.restserver.http4s._ + +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object SpaceRoutes { + + 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) => + for { + all <- backend.space.findAll(user.account, q.map(_.q)) + resp <- Ok(SpaceList(all.map(mkSpace).toList)) + } yield resp + + case req @ POST -> Root => + for { + data <- req.as[NewSpace] + tag <- newSpace(data, user.account) + res <- backend.space.add(tag) + resp <- Ok(Conversions.basicResult(res, "Space successfully created.")) + } yield resp + + case GET -> Root / Ident(id) => + (for { + space <- OptionT(backend.space.findById(id, user.account.collective)) + resp <- OptionT.liftF(Ok(mkSpaceDetail(space))) + } yield resp).getOrElseF(NotFound()) + + case req @ PUT -> Root / Ident(id) => + for { + data <- req.as[NewSpace] + res <- backend.space.changeName(id, user.account, data.name) + resp <- Ok(Conversions.basicResult(res, "Space successfully updated.")) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + del <- backend.space.delete(id, user.account.collective) + resp <- Ok( + if (del > 0) BasicResult(true, "Successfully deleted space") + else BasicResult(false, "Could not delete space") + ) + } yield resp + + case PUT -> Root / Ident(id) / "member" / Ident(userId) => + for { + res <- backend.space.addMember(id, user.account, userId) + resp <- Ok(mkMemberResult(res)) + } yield resp + + case DELETE -> Root / Ident(id) / "member" / Ident(userId) => + for { + res <- backend.space.removeMember(id, user.account, userId) + resp <- Ok(mkMemberResult(res)) + } yield resp + } + } + + private def newSpace[F[_]: Sync](ns: NewSpace, account: AccountId): F[RSpace] = + RSpace.newSpace(ns.name, account) + + private def mkSpace(item: OSpace.SpaceItem): SpaceItem = + SpaceItem( + item.id, + item.name, + Conversions.mkIdName(item.owner), + item.created, + item.members + ) + + private def mkSpaceDetail(item: OSpace.SpaceDetail): SpaceDetail = + SpaceDetail( + item.id, + item.name, + Conversions.mkIdName(item.owner), + item.created, + item.members.map(Conversions.mkIdName) + ) + + private def mkMemberResult(r: OSpace.MemberChangeResult): BasicResult = + r match { + case OSpace.MemberChangeResult.Success => + BasicResult(true, "Successfully changed space") + case OSpace.MemberChangeResult.NotFound => + BasicResult(false, "Space or user not found") + case OSpace.MemberChangeResult.Forbidden => + BasicResult(false, "Not allowed to edit space") + } +} From d43e17d9fbf36b66d05a612dc4330a91d7846bd4 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 5 Jul 2020 00:18:40 +0200 Subject: [PATCH 04/27] Transport user-id to client --- .../restapi/src/main/resources/docspell-openapi.yml | 4 ++++ .../docspell/restserver/conv/Conversions.scala | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index da318274..a9dd01a7 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3360,11 +3360,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 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..8f180531 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -431,7 +431,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 +460,7 @@ trait Conversions { def changeUser(u: User, cid: Ident): RUser = RUser( - Ident.unsafe(""), + u.id, u.login, cid, u.password.getOrElse(Password.empty), From 0e8c9b1819b673f651bb533246d7212399ebdde7 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 5 Jul 2020 00:37:27 +0200 Subject: [PATCH 05/27] Initial outline for managing spaces --- modules/webapp/src/main/elm/Api.elm | 15 ++ modules/webapp/src/main/elm/App/Data.elm | 10 +- .../webapp/src/main/elm/Comp/SpaceDetail.elm | 154 ++++++++++++++++++ .../webapp/src/main/elm/Comp/SpaceManage.elm | 118 ++++++++++++++ .../webapp/src/main/elm/Comp/SpaceTable.elm | 89 ++++++++++ modules/webapp/src/main/elm/Data/Icons.elm | 12 ++ .../src/main/elm/Page/ManageData/Data.elm | 26 ++- .../src/main/elm/Page/ManageData/Update.elm | 17 ++ .../src/main/elm/Page/ManageData/View.elm | 27 +++ 9 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/SpaceDetail.elm create mode 100644 modules/webapp/src/main/elm/Comp/SpaceManage.elm create mode 100644 modules/webapp/src/main/elm/Comp/SpaceTable.elm diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index b84880b0..f09d3a0c 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -42,6 +42,7 @@ module Api exposing , getScanMailbox , getSentMails , getSources + , getSpaces , getTags , getUsers , itemDetail @@ -132,6 +133,7 @@ import Api.Model.SentMails exposing (SentMails) import Api.Model.SimpleMail exposing (SimpleMail) import Api.Model.Source exposing (Source) import Api.Model.SourceList exposing (SourceList) +import Api.Model.SpaceList exposing (SpaceList) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) import Api.Model.User exposing (User) @@ -150,6 +152,19 @@ import Util.Http as Http2 +--- Spaces + + +getSpaces : Flags -> (Result Http.Error SpaceList -> msg) -> Cmd msg +getSpaces flags receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/space" + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.SpaceList.decoder + } + + + --- Full-Text diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index f8574248..58660587 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -57,6 +57,9 @@ init key url flags settings = ( um, uc ) = Page.UserSettings.Data.init flags settings + + ( mdm, mdc ) = + Page.ManageData.Data.init flags in ( { flags = flags , key = key @@ -64,7 +67,7 @@ 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 + , manageDataModel = mdm , collSettingsModel = Page.CollectiveSettings.Data.emptyModel , userSettingsModel = um , queueModel = Page.Queue.Data.emptyModel @@ -76,7 +79,10 @@ init key url flags settings = , subs = Sub.none , uiSettings = settings } - , Cmd.map UserSettingsMsg uc + , Cmd.batch + [ Cmd.map UserSettingsMsg uc + , Cmd.map ManageDataMsg mdc + ] ) diff --git a/modules/webapp/src/main/elm/Comp/SpaceDetail.elm b/modules/webapp/src/main/elm/Comp/SpaceDetail.elm new file mode 100644 index 00000000..3df6c4cf --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/SpaceDetail.elm @@ -0,0 +1,154 @@ +module Comp.SpaceDetail exposing + ( Model + , Msg + , init + , update + , view + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.IdName exposing (IdName) +import Api.Model.SpaceDetail exposing (SpaceDetail) +import Api.Model.User exposing (User) +import Api.Model.UserList exposing (UserList) +import Comp.FixedDropdown +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 + , name : Maybe String + , members : List IdName + , users : List User + , memberDropdown : Comp.FixedDropdown.Model IdName + , selectedMember : Maybe IdName + } + + +type Msg + = SetName String + | MemberDropdownMsg (Comp.FixedDropdown.Msg IdName) + + +init : List User -> SpaceDetail -> Model +init users space = + { result = Nothing + , name = Util.Maybe.fromString space.name + , members = space.members + , users = users + , memberDropdown = + Comp.FixedDropdown.initMap .name + (makeOptions users space.members) + , selectedMember = Nothing + } + + +makeOptions : List User -> List IdName -> List IdName +makeOptions users members = + let + toIdName u = + IdName u.id u.login + + notMember idn = + List.member idn members |> not + in + List.map toIdName users + |> List.filter notMember + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + SetName str -> + ( { model | name = Util.Maybe.fromString str } + , Cmd.none + ) + + MemberDropdownMsg lmsg -> + let + ( mm, sel ) = + Comp.FixedDropdown.update lmsg model.memberDropdown + in + ( { model + | memberDropdown = mm + , selectedMember = sel + } + , Cmd.none + ) + + + +--- View + + +view : Model -> Html Msg +view model = + div [] + [ 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" + ] + [ i [ class "save icon" ] [] + ] + ] + , 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" + ] + [ 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" + ] + [ button + [ class "ui primary icon button" + ] + [ i [ class "delete icon" ] [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/SpaceManage.elm b/modules/webapp/src/main/elm/Comp/SpaceManage.elm new file mode 100644 index 00000000..a2af0fda --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/SpaceManage.elm @@ -0,0 +1,118 @@ +module Comp.SpaceManage exposing + ( Model + , Msg + , empty + , init + , update + , view + ) + +import Api +import Api.Model.SpaceDetail exposing (SpaceDetail) +import Api.Model.SpaceItem exposing (SpaceItem) +import Api.Model.SpaceList exposing (SpaceList) +import Api.Model.User exposing (User) +import Api.Model.UserList exposing (UserList) +import Comp.SpaceDetail +import Comp.SpaceTable +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Http + + +type alias Model = + { tableModel : Comp.SpaceTable.Model + , detailModel : Maybe Comp.SpaceDetail.Model + , spaces : List SpaceItem + , users : List User + , query : String + , loading : Bool + } + + +type Msg + = TableMsg Comp.SpaceTable.Msg + | DetailMsg Comp.SpaceDetail.Msg + | UserListResp (Result Http.Error UserList) + | SpaceListResp (Result Http.Error SpaceList) + | SpaceDetailResp (Result Http.Error SpaceDetail) + | SetQuery String + | InitNewSpace + + +empty : Model +empty = + { tableModel = Comp.SpaceTable.init + , detailModel = Nothing + , spaces = [] + , users = [] + , query = "" + , loading = False + } + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( empty + , Cmd.batch + [ Api.getUsers flags UserListResp + , Api.getSpaces flags SpaceListResp + ] + ) + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + ( model, Cmd.none ) + + + +--- View + + +view : Model -> Html Msg +view 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 "right menu" ] + [ div [ class "item" ] + [ a + [ class "ui primary button" + , href "#" + , onClick InitNewSpace + ] + [ i [ class "plus icon" ] [] + , text "New Space" + ] + ] + ] + ] + , Html.map TableMsg (Comp.SpaceTable.view model.tableModel model.spaces) + , div + [ classList + [ ( "ui dimmer", True ) + , ( "active", model.loading ) + ] + ] + [ div [ class "ui loader" ] [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/SpaceTable.elm b/modules/webapp/src/main/elm/Comp/SpaceTable.elm new file mode 100644 index 00000000..6d707c06 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/SpaceTable.elm @@ -0,0 +1,89 @@ +module Comp.SpaceTable exposing + ( Action(..) + , Model + , Msg + , init + , update + , view + ) + +import Api.Model.SpaceItem exposing (SpaceItem) +import Api.Model.SpaceList exposing (SpaceList) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Util.Time + + +type alias Model = + {} + + +type Msg + = EditItem SpaceItem + + +type Action + = NoAction + | EditAction SpaceItem + + +init : Model +init = + {} + + +update : Msg -> Model -> ( Model, Action ) +update msg model = + case msg of + EditItem item -> + ( model, EditAction item ) + + +view : Model -> List SpaceItem -> 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 "Members" ] + , th [] [ text "Created" ] + ] + , tbody [] + (List.map viewItem items) + ] + ] + + +viewItem : SpaceItem -> 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 [] + [ code [] + [ text item.name + ] + ] + , td [] + [ text item.owner.name + ] + , td [] + [ String.fromInt item.members + |> text + ] + , td [] + [ Util.Time.formatDateShort item.created + |> text + ] + ] diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index 8d891221..77badc14 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -19,6 +19,8 @@ module Data.Icons exposing , organizationIcon , person , personIcon + , space + , spaceIcon , tag , tagIcon , tags @@ -29,6 +31,16 @@ import Html exposing (Html, i) import Html.Attributes exposing (class) +space : String +space = + "folder outline icon" + + +spaceIcon : String -> Html msg +spaceIcon classes = + i [ class (space ++ " " ++ classes) ] [] + + concerned : String concerned = "crosshairs icon" diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm index 35b92d79..8ab230a4 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.OrgManage import Comp.PersonManage +import Comp.SpaceManage 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 + , spaceManageModel : Comp.SpaceManage.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 + , spaceManageModel = Comp.SpaceManage.empty + } + , Cmd.none + ) type Tab @@ -35,6 +41,7 @@ type Tab | EquipTab | OrgTab | PersonTab + | SpaceTab type Msg @@ -43,3 +50,4 @@ type Msg | EquipManageMsg Comp.EquipmentManage.Msg | OrgManageMsg Comp.OrgManage.Msg | PersonManageMsg Comp.PersonManage.Msg + | SpaceMsg Comp.SpaceManage.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..d50f0042 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm @@ -3,6 +3,7 @@ module Page.ManageData.Update exposing (update) import Comp.EquipmentManage import Comp.OrgManage import Comp.PersonManage +import Comp.SpaceManage import Comp.TagManage import Data.Flags exposing (Flags) import Page.ManageData.Data exposing (..) @@ -29,6 +30,13 @@ update flags msg model = PersonTab -> update flags (PersonManageMsg Comp.PersonManage.LoadPersons) m + SpaceTab -> + let + ( sm, sc ) = + Comp.SpaceManage.init flags + in + ( { m | spaceManageModel = sm }, Cmd.map SpaceMsg 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 ) + + SpaceMsg lm -> + let + ( m2, c2 ) = + Comp.SpaceManage.update flags lm model.spaceManageModel + in + ( { model | spaceManageModel = m2 } + , Cmd.map SpaceMsg 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..ae3fa852 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/View.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/View.elm @@ -3,6 +3,7 @@ module Page.ManageData.View exposing (view) import Comp.EquipmentManage import Comp.OrgManage import Comp.PersonManage +import Comp.SpaceManage import Comp.TagManage import Data.Icons as Icons import Data.UiSettings exposing (UiSettings) @@ -50,6 +51,13 @@ view settings model = [ Icons.personIcon "" , text "Person" ] + , div + [ classActive (model.currentTab == Just SpaceTab) "link icon item" + , onClick (SetTab SpaceTab) + ] + [ Icons.spaceIcon "" + , text "Space" + ] ] ] ] @@ -68,6 +76,9 @@ view settings model = Just PersonTab -> viewPerson settings model + Just SpaceTab -> + viewSpace settings model + Nothing -> [] ) @@ -75,6 +86,22 @@ view settings model = ] +viewSpace : UiSettings -> Model -> List (Html Msg) +viewSpace _ model = + [ h2 + [ class "ui header" + ] + [ Icons.spaceIcon "" + , div + [ class "content" + ] + [ text "Spaces" + ] + ] + , Html.map SpaceMsg (Comp.SpaceManage.view model.spaceManageModel) + ] + + viewTags : Model -> List (Html Msg) viewTags model = [ h2 [ class "ui header" ] From 752a94a9e24c43d9ebe28de512b27e39ba73a16e Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Wed, 8 Jul 2020 00:21:48 +0200 Subject: [PATCH 06/27] Implement space operations --- .../scala/docspell/backend/ops/OSpace.scala | 99 +++++---- .../joex/process/ExtractArchive.scala | 1 - .../src/main/resources/docspell-openapi.yml | 22 +- .../restserver/conv/Conversions.scala | 18 +- .../restserver/routes/SpaceRoutes.scala | 42 ++-- .../scala/docspell/store/UpdateResult.scala | 29 +++ .../scala/docspell/store/queries/QSpace.scala | 188 ++++++++++++++++++ .../scala/docspell/store/records/RItem.scala | 5 + .../scala/docspell/store/records/RSpace.scala | 2 + .../docspell/store/records/RSpaceMember.scala | 21 +- .../webapp/src/main/elm/Comp/SpaceTable.elm | 5 - 11 files changed, 358 insertions(+), 74 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/UpdateResult.scala create mode 100644 modules/store/src/main/scala/docspell/store/queries/QSpace.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala index 2962c55e..b42522bb 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala @@ -4,81 +4,102 @@ import cats.effect._ import docspell.common._ import docspell.store.{AddResult, Store} -import docspell.store.records.RSpace +import docspell.store.records.{RSpace, RUser} +import docspell.store.queries.QSpace trait OSpace[F[_]] { - def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[OSpace.SpaceItem]] + def findAll(collective: Ident, nameQuery: Option[String]): F[Vector[OSpace.SpaceItem]] def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] - def delete(id: Ident, collective: Ident): F[Int] + /** Adds a new space. If `login` is non-empty, the `space.user` + * property is ignored and the user-id is determined by the given + * login name. + */ + def add(space: RSpace, login: Option[Ident]): F[AddResult] - def add(space: RSpace): F[AddResult] - - def changeName(space: Ident, account: AccountId, name: String): F[AddResult] + def changeName( + space: Ident, + account: AccountId, + name: String + ): F[OSpace.SpaceChangeResult] def addMember( space: Ident, account: AccountId, member: Ident - ): F[OSpace.MemberChangeResult] + ): F[OSpace.SpaceChangeResult] + def removeMember( space: Ident, account: AccountId, member: Ident - ): F[OSpace.MemberChangeResult] + ): F[OSpace.SpaceChangeResult] + + def delete(id: Ident, account: AccountId): F[OSpace.SpaceChangeResult] } object OSpace { - sealed trait MemberChangeResult - object MemberChangeResult { - case object Success extends MemberChangeResult - case object NotFound extends MemberChangeResult - case object Forbidden extends MemberChangeResult - } + type SpaceChangeResult = QSpace.SpaceChangeResult + val SpaceChangeResult = QSpace.SpaceChangeResult - final case class SpaceItem( - id: Ident, - name: String, - owner: IdRef, - created: Timestamp, - members: Int - ) + type SpaceItem = QSpace.SpaceItem + val SpaceItem = QSpace.SpaceItem - final case class SpaceDetail( - id: Ident, - name: String, - owner: IdRef, - created: Timestamp, - members: List[IdRef] - ) + type SpaceDetail = QSpace.SpaceDetail + val SpaceDetail = QSpace.SpaceDetail def apply[F[_]: Effect](store: Store[F]): Resource[F, OSpace[F]] = Resource.pure[F, OSpace[F]](new OSpace[F] { - println(s"$store") def findAll( - account: AccountId, + collective: Ident, nameQuery: Option[String] - ): F[Vector[OSpace.SpaceItem]] = ??? + ): F[Vector[SpaceItem]] = + store.transact(QSpace.findAll(collective, None, nameQuery)) + + def findById(id: Ident, collective: Ident): F[Option[SpaceDetail]] = + store.transact(QSpace.findById(id, collective)) + + def add(space: RSpace, login: Option[Ident]): F[AddResult] = { + val insert = login match { + case Some(n) => + for { + user <- RUser.findByAccount(AccountId(space.collectiveId, n)) + s = user.map(u => space.copy(owner = u.uid)).getOrElse(space) + n <- RSpace.insert(s) + } yield n + + case None => + RSpace.insert(space) + } + val exists = RSpace.existsByName(space.collectiveId, space.name) + store.add(insert, exists) + } + + def changeName( + space: Ident, + account: AccountId, + name: String + ): F[SpaceChangeResult] = + store.transact(QSpace.changeName(space, account, name)) - def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] = ??? - def add(space: RSpace): F[AddResult] = ??? - def changeName(space: Ident, account: AccountId, name: String): F[AddResult] = ??? - def delete(id: Ident, collective: Ident): F[Int] = ??? def addMember( space: Ident, account: AccountId, member: Ident - ): F[MemberChangeResult] = - ??? + ): F[SpaceChangeResult] = + store.transact(QSpace.addMember(space, account, member)) + def removeMember( space: Ident, account: AccountId, member: Ident - ): F[MemberChangeResult] = - ??? + ): F[SpaceChangeResult] = + store.transact(QSpace.removeMember(space, account, member)) + def delete(id: Ident, account: AccountId): F[SpaceChangeResult] = + store.transact(QSpace.delete(id, account)) }) } 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/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index a9dd01a7..d1e30503 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -837,7 +837,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BasicResult" + $ref: "#/components/schemas/IdResult" /sec/space/{id}: get: tags: [ Space ] @@ -2509,7 +2509,6 @@ components: - name - owner - created - - members properties: id: type: string @@ -2521,9 +2520,6 @@ components: created: type: integer format: date-time - members: - type: integer - format: int32 NewSpace: description: | Data required to create a new space. @@ -3844,6 +3840,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 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 8f180531..19997c74 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -15,7 +15,7 @@ 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.{AddResult, UpdateResult} import docspell.store.records._ import bitpeace.FileMeta @@ -537,6 +537,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) @@ -545,6 +553,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/routes/SpaceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala index 1fb9e7a3..480d41d2 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala @@ -27,16 +27,16 @@ object SpaceRoutes { HttpRoutes.of { case GET -> Root :? QueryParam.QueryOpt(q) => for { - all <- backend.space.findAll(user.account, q.map(_.q)) + all <- backend.space.findAll(user.account.collective, q.map(_.q)) resp <- Ok(SpaceList(all.map(mkSpace).toList)) } yield resp case req @ POST -> Root => for { - data <- req.as[NewSpace] - tag <- newSpace(data, user.account) - res <- backend.space.add(tag) - resp <- Ok(Conversions.basicResult(res, "Space successfully created.")) + data <- req.as[NewSpace] + nspace <- newSpace(data, user.account) + res <- backend.space.add(nspace, Some(user.account.user)) + resp <- Ok(Conversions.idResult(res, nspace.id, "Space successfully created.")) } yield resp case GET -> Root / Ident(id) => @@ -49,28 +49,25 @@ object SpaceRoutes { for { data <- req.as[NewSpace] res <- backend.space.changeName(id, user.account, data.name) - resp <- Ok(Conversions.basicResult(res, "Space successfully updated.")) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp case DELETE -> Root / Ident(id) => for { - del <- backend.space.delete(id, user.account.collective) - resp <- Ok( - if (del > 0) BasicResult(true, "Successfully deleted space") - else BasicResult(false, "Could not delete space") - ) + res <- backend.space.delete(id, user.account) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp case PUT -> Root / Ident(id) / "member" / Ident(userId) => for { res <- backend.space.addMember(id, user.account, userId) - resp <- Ok(mkMemberResult(res)) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp case DELETE -> Root / Ident(id) / "member" / Ident(userId) => for { res <- backend.space.removeMember(id, user.account, userId) - resp <- Ok(mkMemberResult(res)) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp } } @@ -83,8 +80,7 @@ object SpaceRoutes { item.id, item.name, Conversions.mkIdName(item.owner), - item.created, - item.members + item.created ) private def mkSpaceDetail(item: OSpace.SpaceDetail): SpaceDetail = @@ -96,13 +92,15 @@ object SpaceRoutes { item.members.map(Conversions.mkIdName) ) - private def mkMemberResult(r: OSpace.MemberChangeResult): BasicResult = + private def mkSpaceChangeResult(r: OSpace.SpaceChangeResult): BasicResult = r match { - case OSpace.MemberChangeResult.Success => - BasicResult(true, "Successfully changed space") - case OSpace.MemberChangeResult.NotFound => - BasicResult(false, "Space or user not found") - case OSpace.MemberChangeResult.Forbidden => - BasicResult(false, "Not allowed to edit space") + case OSpace.SpaceChangeResult.Success => + BasicResult(true, "Successfully changed space.") + case OSpace.SpaceChangeResult.NotFound => + BasicResult(false, "Space or user not found.") + case OSpace.SpaceChangeResult.Forbidden => + BasicResult(false, "Not allowed to edit space.") + case OSpace.SpaceChangeResult.Exists => + BasicResult(false, "The member already exists.") } } 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..6ea55842 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/UpdateResult.scala @@ -0,0 +1,29 @@ +package docspell.store + +import cats.implicits._ +import cats.ApplicativeError + +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/QSpace.scala b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala new file mode 100644 index 00000000..bd3febe3 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala @@ -0,0 +1,188 @@ +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 QSpace { + + final case class SpaceItem( + id: Ident, + name: String, + owner: IdRef, + created: Timestamp + ) { + def withMembers(members: List[IdRef]): SpaceDetail = + SpaceDetail(id, name, owner, created, members) + } + + final case class SpaceDetail( + id: Ident, + name: String, + owner: IdRef, + created: Timestamp, + members: List[IdRef] + ) + + sealed trait SpaceChangeResult + object SpaceChangeResult { + case object Success extends SpaceChangeResult + def success: SpaceChangeResult = Success + case object NotFound extends SpaceChangeResult + def notFound: SpaceChangeResult = NotFound + case object Forbidden extends SpaceChangeResult + def forbidden: SpaceChangeResult = Forbidden + case object Exists extends SpaceChangeResult + def exists: SpaceChangeResult = Exists + } + + def delete(id: Ident, account: AccountId): ConnectionIO[SpaceChangeResult] = { + def tryDelete = + for { + _ <- RItem.removeSpace(id) + _ <- RSpaceMember.deleteAll(id) + _ <- RSpace.delete(id) + } yield SpaceChangeResult.success + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(id)) + res <- OptionT.liftF( + if (space.owner == uid) tryDelete + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def changeName( + space: Ident, + account: AccountId, + name: String + ): ConnectionIO[SpaceChangeResult] = { + def tryUpdate(ns: RSpace): ConnectionIO[SpaceChangeResult] = + for { + n <- RSpace.update(ns) + res = + if (n == 0) SpaceChangeResult.notFound + else SpaceChangeResult.Success + } yield res + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(space)) + res <- OptionT.liftF( + if (space.owner == uid) tryUpdate(space.copy(name = name)) + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def removeMember( + space: Ident, + account: AccountId, + member: Ident + ): ConnectionIO[SpaceChangeResult] = { + def tryRemove: ConnectionIO[SpaceChangeResult] = + for { + n <- RSpaceMember.delete(member, space) + res = + if (n == 0) SpaceChangeResult.notFound + else SpaceChangeResult.Success + } yield res + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(space)) + res <- OptionT.liftF( + if (space.owner == uid) tryRemove + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def addMember( + space: Ident, + account: AccountId, + member: Ident + ): ConnectionIO[SpaceChangeResult] = { + def tryAdd: ConnectionIO[SpaceChangeResult] = + for { + spm <- RSpaceMember.findByUserId(member, space) + mem <- RSpaceMember.newMember[ConnectionIO](space, member) + res <- + if (spm.isDefined) SpaceChangeResult.exists.pure[ConnectionIO] + else RSpaceMember.insert(mem).map(_ => SpaceChangeResult.Success) + } yield res + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(space)) + res <- OptionT.liftF( + if (space.owner == uid) tryAdd + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def findById(id: Ident, collective: Ident): ConnectionIO[Option[SpaceDetail]] = { + val mUserId = RSpaceMember.Columns.user.prefix("m") + val mSpaceId = RSpaceMember.Columns.space.prefix("m") + val uId = RUser.Columns.uid.prefix("u") + val uLogin = RUser.Columns.login.prefix("u") + val sColl = RSpace.Columns.collective.prefix("s") + val sId = RSpace.Columns.id.prefix("s") + + val from = RSpaceMember.table ++ fr"m INNER JOIN" ++ + RUser.table ++ fr"u ON" ++ mUserId.is(uId) ++ fr"INNER JOIN" ++ + RSpace.table ++ fr"s ON" ++ mSpaceId.is(sId) + + val memberQ = selectSimple( + Seq(uId, uLogin), + from, + and(mSpaceId.is(id), sColl.is(collective)) + ).query[IdRef].to[Vector] + + (for { + space <- OptionT(findAll(collective, Some(id), None).map(_.headOption)) + memb <- OptionT.liftF(memberQ) + } yield space.withMembers(memb.toList)).value + } + + def findAll( + collective: Ident, + idQ: Option[Ident], + nameQ: Option[String] + ): ConnectionIO[Vector[SpaceItem]] = { + val uId = RUser.Columns.uid.prefix("u") + val sId = RSpace.Columns.id.prefix("s") + val sOwner = RSpace.Columns.owner.prefix("s") + val sName = RSpace.Columns.name.prefix("s") + val sColl = RSpace.Columns.collective.prefix("s") + val cols = Seq( + sId, + sName, + uId, + RUser.Columns.login.prefix("u"), + RSpace.Columns.created.prefix("s") + ) + + val from = RSpace.table ++ fr"s INNER JOIN" ++ + RUser.table ++ fr"u ON" ++ uId.is(sOwner) + + val where = + sColl.is(collective) :: idQ.toList.map(id => sId.is(id)) ::: nameQ.toList.map(q => + sName.lowerLike(s"%${q.toLowerCase}%") + ) + + selectSimple(cols, from, and(where) ++ orderBy(sName.asc)).query[SpaceItem].to[Vector] + } + + private def findUserId(account: AccountId): ConnectionIO[Option[Ident]] = + RUser.findByAccount(account).map(_.map(_.uid)) +} 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 3e319ffa..a8b33509 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -299,4 +299,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 removeSpace(spaceId: Ident): ConnectionIO[Int] = { + val empty: Option[Ident] = None + updateRow(table, space.is(spaceId), space.setTo(empty)).update.run + } } diff --git a/modules/store/src/main/scala/docspell/store/records/RSpace.scala b/modules/store/src/main/scala/docspell/store/records/RSpace.scala index 1da2ef0d..00bc802f 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSpace.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSpace.scala @@ -80,4 +80,6 @@ object RSpace { sql.query[RSpace].to[Vector] } + def delete(spaceId: Ident): ConnectionIO[Int] = + deleteFrom(table, id.is(spaceId)).update.run } diff --git a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala index 4a13249e..839c6267 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala @@ -1,5 +1,8 @@ package docspell.store.records +import cats.effect._ +import cats.implicits._ + import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ @@ -16,7 +19,13 @@ case class RSpaceMember( object RSpaceMember { - val table = fr"space" + def newMember[F[_]: Sync](space: Ident, user: Ident): F[RSpaceMember] = + for { + nId <- Ident.randomId[F] + now <- Timestamp.current[F] + } yield RSpaceMember(nId, space, user, now) + + val table = fr"space_member" object Columns { @@ -39,4 +48,14 @@ object RSpaceMember { sql.update.run } + def findByUserId(userId: Ident, spaceId: Ident): ConnectionIO[Option[RSpaceMember]] = + selectSimple(all, table, and(space.is(spaceId), user.is(userId))) + .query[RSpaceMember] + .option + + def delete(userId: Ident, spaceId: Ident): ConnectionIO[Int] = + deleteFrom(table, and(space.is(spaceId), user.is(userId))).update.run + + def deleteAll(spaceId: Ident): ConnectionIO[Int] = + deleteFrom(table, space.is(spaceId)).update.run } diff --git a/modules/webapp/src/main/elm/Comp/SpaceTable.elm b/modules/webapp/src/main/elm/Comp/SpaceTable.elm index 6d707c06..10432ea2 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceTable.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceTable.elm @@ -48,7 +48,6 @@ view _ items = [ th [ class "collapsing" ] [] , th [] [ text "Name" ] , th [] [ text "Owner" ] - , th [] [ text "Members" ] , th [] [ text "Created" ] ] , tbody [] @@ -78,10 +77,6 @@ viewItem item = , td [] [ text item.owner.name ] - , td [] - [ String.fromInt item.members - |> text - ] , td [] [ Util.Time.formatDateShort item.created |> text From 6c304b4e7a129dec37b08e03ff1cd41389c42d6d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 9 Jul 2020 01:12:09 +0200 Subject: [PATCH 07/27] Manage spaces in web-ui --- modules/webapp/src/main/elm/Api.elm | 72 ++++- modules/webapp/src/main/elm/App/View.elm | 6 +- .../webapp/src/main/elm/Comp/SpaceDetail.elm | 289 +++++++++++++++++- .../webapp/src/main/elm/Comp/SpaceManage.elm | 102 ++++++- .../webapp/src/main/elm/Comp/SpaceTable.elm | 4 +- .../src/main/elm/Page/ManageData/View.elm | 13 +- 6 files changed, 455 insertions(+), 31 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index f09d3a0c..aa2ff687 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -3,12 +3,15 @@ module Api exposing , addConcPerson , addCorrOrg , addCorrPerson + , addMember , addTag , cancelJob , changePassword + , changeSpaceName , checkCalEvent , createImapSettings , createMailSettings + , createNewSpace , createNotifyDueItems , createScanMailbox , deleteAttachment @@ -21,6 +24,7 @@ module Api exposing , deletePerson , deleteScanMailbox , deleteSource + , deleteSpace , deleteTag , deleteUser , getAttachmentMeta @@ -42,6 +46,7 @@ module Api exposing , getScanMailbox , getSentMails , getSources + , getSpaceDetail , getSpaces , getTags , getUsers @@ -62,6 +67,7 @@ module Api exposing , putUser , refreshSession , register + , removeMember , sendMail , setAttachmentName , setCollectiveSettings @@ -103,6 +109,7 @@ import Api.Model.EmailSettingsList exposing (EmailSettingsList) import Api.Model.Equipment exposing (Equipment) import Api.Model.EquipmentList exposing (EquipmentList) 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) @@ -115,6 +122,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.NewSpace exposing (NewSpace) import Api.Model.NotificationSettings exposing (NotificationSettings) import Api.Model.NotificationSettingsList exposing (NotificationSettingsList) import Api.Model.OptionalDate exposing (OptionalDate) @@ -133,6 +141,7 @@ import Api.Model.SentMails exposing (SentMails) import Api.Model.SimpleMail exposing (SimpleMail) import Api.Model.Source exposing (Source) import Api.Model.SourceList exposing (SourceList) +import Api.Model.SpaceDetail exposing (SpaceDetail) import Api.Model.SpaceList exposing (SpaceList) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) @@ -155,11 +164,68 @@ import Util.Http as Http2 --- Spaces -getSpaces : Flags -> (Result Http.Error SpaceList -> msg) -> Cmd msg -getSpaces flags receive = - Http2.authGet +deleteSpace : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteSpace flags id receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/space/" ++ 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/space/" ++ 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/space/" ++ id ++ "/member/" ++ user + , account = getAccount flags + , body = Http.emptyBody + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +changeSpaceName : Flags -> String -> NewSpace -> (Result Http.Error BasicResult -> msg) -> Cmd msg +changeSpaceName flags id ns receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/space/" ++ id + , account = getAccount flags + , body = Http.jsonBody (Api.Model.NewSpace.encode ns) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +createNewSpace : Flags -> NewSpace -> (Result Http.Error IdResult -> msg) -> Cmd msg +createNewSpace flags ns receive = + Http2.authPost { url = flags.config.baseUrl ++ "/api/v1/sec/space" , account = getAccount flags + , body = Http.jsonBody (Api.Model.NewSpace.encode ns) + , expect = Http.expectJson receive Api.Model.IdResult.decoder + } + + +getSpaceDetail : Flags -> String -> (Result Http.Error SpaceDetail -> msg) -> Cmd msg +getSpaceDetail flags id receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/space/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.SpaceDetail.decoder + } + + +getSpaces : Flags -> String -> (Result Http.Error SpaceList -> msg) -> Cmd msg +getSpaces flags query receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/space?q=" ++ Url.percentEncode query + , account = getAccount flags , expect = Http.expectJson receive Api.Model.SpaceList.decoder } 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/SpaceDetail.elm b/modules/webapp/src/main/elm/Comp/SpaceDetail.elm index 3df6c4cf..b6e9c571 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceDetail.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceDetail.elm @@ -2,6 +2,7 @@ module Comp.SpaceDetail exposing ( Model , Msg , init + , initEmpty , update , view ) @@ -9,10 +10,13 @@ module Comp.SpaceDetail exposing import Api import Api.Model.BasicResult exposing (BasicResult) import Api.Model.IdName exposing (IdName) +import Api.Model.IdResult exposing (IdResult) +import Api.Model.NewSpace exposing (NewSpace) import Api.Model.SpaceDetail exposing (SpaceDetail) 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 (..) @@ -24,40 +28,62 @@ import Util.Maybe type alias Model = { result : Maybe BasicResult + , space : SpaceDetail , 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 + | NewSpaceResp (Result Http.Error IdResult) + | ChangeSpaceResp (Result Http.Error BasicResult) + | ChangeNameResp (Result Http.Error BasicResult) + | SpaceDetailResp (Result Http.Error SpaceDetail) + | AddMember + | RemoveMember IdName + | RequestDelete + | DeleteMsg Comp.YesNoDimmer.Msg + | DeleteResp (Result Http.Error BasicResult) + | GoBack init : List User -> SpaceDetail -> Model init users space = { result = Nothing + , space = space , name = Util.Maybe.fromString space.name , members = space.members , users = users , memberDropdown = Comp.FixedDropdown.initMap .name - (makeOptions users space.members) + (makeOptions users space) , selectedMember = Nothing + , loading = False + , deleteDimmer = Comp.YesNoDimmer.emptyModel } -makeOptions : List User -> List IdName -> List IdName -makeOptions users members = +initEmpty : List User -> Model +initEmpty users = + init users Api.Model.SpaceDetail.empty + + +makeOptions : List User -> SpaceDetail -> List IdName +makeOptions users space = let toIdName u = IdName u.id u.login notMember idn = - List.member idn members |> not + List.member idn (space.owner :: space.members) |> not in List.map toIdName users |> List.filter notMember @@ -67,12 +93,16 @@ makeOptions users members = --- Update -update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +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 -> @@ -82,9 +112,160 @@ update flags msg model = in ( { model | memberDropdown = mm - , selectedMember = sel + , selectedMember = + case sel of + Just _ -> + sel + + Nothing -> + model.selectedMember } , Cmd.none + , False + ) + + SaveName -> + case model.name of + Just name -> + let + cmd = + if model.space.id == "" then + Api.createNewSpace flags (NewSpace name) NewSpaceResp + + else + Api.changeSpaceName flags + model.space.id + (NewSpace name) + ChangeNameResp + in + ( { model + | loading = True + , result = Nothing + } + , cmd + , False + ) + + Nothing -> + ( model, Cmd.none, False ) + + NewSpaceResp (Ok ir) -> + if ir.success then + ( model, Api.getSpaceDetail flags ir.id SpaceDetailResp, False ) + + else + ( { model + | loading = False + , result = Just (BasicResult ir.success ir.message) + } + , Cmd.none + , False + ) + + NewSpaceResp (Err err) -> + ( { model + | loading = False + , result = Just (BasicResult False (Util.Http.errorToString err)) + } + , Cmd.none + , False + ) + + ChangeSpaceResp (Ok r) -> + if r.success then + ( model + , Api.getSpaceDetail flags model.space.id SpaceDetailResp + , False + ) + + else + ( { model | loading = False, result = Just r } + , Cmd.none + , False + ) + + ChangeSpaceResp (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 + ) + + SpaceDetailResp (Ok sd) -> + ( init model.users sd, Cmd.none, False ) + + SpaceDetailResp (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.space.id mem.id ChangeSpaceResp + , False + ) + + Nothing -> + ( model, Cmd.none, False ) + + RemoveMember idname -> + ( { model | loading = True } + , Api.removeMember flags model.space.id idname.id ChangeSpaceResp + , 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.deleteSpace flags model.space.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 ) @@ -92,13 +273,54 @@ update flags msg model = --- View -view : Model -> Html Msg -view model = +view : Flags -> Model -> Html Msg +view flags model = + let + isOwner = + Maybe.map .user flags.account + |> Maybe.map ((==) model.space.owner.name) + |> Maybe.withDefault False + in div [] - [ div [ class "ui header" ] + ([ Html.map DeleteMsg (Comp.YesNoDimmer.view model.deleteDimmer) + , if model.space.id == "" then + div [] + [ text "Create a new space. You are automatically set as owner of this new space." + ] + + else + div [] + [ text "Modify this space by changing the name or add/remove members." + ] + , if model.space.id /= "" && not isOwner then + div [ class "ui info message" ] + [ text "You are not the owner of this space 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.space.owner.name + ] + , div [ class "ui header" ] [ text "Name" ] - , div [ class "ui action input" ] + , div [ class "ui action input" ] [ input [ type_ "text" , onInput SetName @@ -108,11 +330,42 @@ view model = [] , button [ class "ui icon button" + , onClick SaveName ] [ i [ class "save icon" ] [] ] ] - , div [ class "ui header" ] + ] + ++ viewMembers model + ++ viewButtons model + ) + + +viewButtons : Model -> List (Html Msg) +viewButtons _ = + [ div [ class "ui divider" ] [] + , button + [ class "ui button" + , onClick GoBack + ] + [ text "Back" + ] + , button + [ class "ui red button" + , onClick RequestDelete + ] + [ text "Delete" + ] + ] + + +viewMembers : Model -> List (Html Msg) +viewMembers model = + if model.space.id == "" then + [] + + else + [ div [ class "ui header" ] [ text "Members" ] , div [ class "ui form" ] @@ -124,6 +377,8 @@ view model = ) , button [ class "ui primary button" + , title "Add a new member" + , onClick AddMember ] [ text "Add" ] @@ -146,9 +401,15 @@ viewMember member = div [ class "item" ] - [ button - [ class "ui primary icon button" + [ a + [ class "link icon" + , href "#" + , title "Remove this member" + , onClick (RemoveMember member) ] - [ i [ class "delete icon" ] [] + [ i [ class "red trash icon" ] [] + ] + , span [] + [ text member.name ] ] diff --git a/modules/webapp/src/main/elm/Comp/SpaceManage.elm b/modules/webapp/src/main/elm/Comp/SpaceManage.elm index a2af0fda..66aa2487 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceManage.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceManage.elm @@ -58,7 +58,7 @@ init flags = ( empty , Cmd.batch [ Api.getUsers flags UserListResp - , Api.getSpaces flags SpaceListResp + , Api.getSpaces flags "" SpaceListResp ] ) @@ -69,15 +69,109 @@ init flags = update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) update flags msg model = - ( model, Cmd.none ) + case msg of + TableMsg lm -> + let + ( tm, action ) = + Comp.SpaceTable.update lm model.tableModel + + cmd = + case action of + Comp.SpaceTable.EditAction item -> + Api.getSpaceDetail flags item.id SpaceDetailResp + + Comp.SpaceTable.NoAction -> + Cmd.none + in + ( { model | tableModel = tm }, cmd ) + + DetailMsg lm -> + case model.detailModel of + Just detail -> + let + ( dm, dc, back ) = + Comp.SpaceDetail.update flags lm detail + + cmd = + if back then + Api.getSpaces flags model.query SpaceListResp + + 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.getSpaces flags str SpaceListResp ) + + UserListResp (Ok ul) -> + ( { model | users = ul.items }, Cmd.none ) + + UserListResp (Err err) -> + ( model, Cmd.none ) + + SpaceListResp (Ok sl) -> + ( { model | spaces = sl.items }, Cmd.none ) + + SpaceListResp (Err err) -> + ( model, Cmd.none ) + + SpaceDetailResp (Ok sd) -> + ( { model | detailModel = Comp.SpaceDetail.init model.users sd |> Just } + , Cmd.none + ) + + SpaceDetailResp (Err err) -> + ( model, Cmd.none ) + + InitNewSpace -> + let + sd = + Comp.SpaceDetail.initEmpty model.users + in + ( { model | detailModel = Just sd } + , Cmd.none + ) --- View -view : Model -> Html Msg -view model = +view : Flags -> Model -> Html Msg +view flags model = + case model.detailModel of + Just dm -> + viewDetail flags dm + + Nothing -> + viewTable model + + +viewDetail : Flags -> Comp.SpaceDetail.Model -> Html Msg +viewDetail flags detailModel = + div [] + [ Html.map DetailMsg (Comp.SpaceDetail.view flags detailModel) + ] + + +viewTable : Model -> Html Msg +viewTable model = div [] [ div [ class "ui secondary menu" ] [ div [ class "horizontally fitted item" ] diff --git a/modules/webapp/src/main/elm/Comp/SpaceTable.elm b/modules/webapp/src/main/elm/Comp/SpaceTable.elm index 10432ea2..acfac4dc 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceTable.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceTable.elm @@ -70,9 +70,7 @@ viewItem item = ] ] , td [] - [ code [] - [ text item.name - ] + [ text item.name ] , td [] [ text item.owner.name diff --git a/modules/webapp/src/main/elm/Page/ManageData/View.elm b/modules/webapp/src/main/elm/Page/ManageData/View.elm index ae3fa852..0829c920 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/View.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/View.elm @@ -5,6 +5,7 @@ import Comp.OrgManage import Comp.PersonManage import Comp.SpaceManage import Comp.TagManage +import Data.Flags exposing (Flags) import Data.Icons as Icons import Data.UiSettings exposing (UiSettings) import Html exposing (..) @@ -14,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" ] @@ -77,7 +78,7 @@ view settings model = viewPerson settings model Just SpaceTab -> - viewSpace settings model + viewSpace flags settings model Nothing -> [] @@ -86,8 +87,8 @@ view settings model = ] -viewSpace : UiSettings -> Model -> List (Html Msg) -viewSpace _ model = +viewSpace : Flags -> UiSettings -> Model -> List (Html Msg) +viewSpace flags _ model = [ h2 [ class "ui header" ] @@ -98,7 +99,7 @@ viewSpace _ model = [ text "Spaces" ] ] - , Html.map SpaceMsg (Comp.SpaceManage.view model.spaceManageModel) + , Html.map SpaceMsg (Comp.SpaceManage.view flags model.spaceManageModel) ] From ea4ab11195df8fd7c8835da766dc744bff008784 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 9 Jul 2020 23:23:02 +0200 Subject: [PATCH 08/27] Allow to only return owning spaces --- .../src/main/scala/docspell/backend/ops/OSpace.scala | 9 +++++++-- modules/restapi/src/main/resources/docspell-openapi.yml | 8 ++++++++ .../scala/docspell/restserver/http4s/QueryParam.scala | 2 ++ .../scala/docspell/restserver/routes/SpaceRoutes.scala | 6 ++++-- .../src/main/scala/docspell/store/queries/QSpace.scala | 6 ++++-- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala index b42522bb..efec5b46 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala @@ -9,7 +9,11 @@ import docspell.store.queries.QSpace trait OSpace[F[_]] { - def findAll(collective: Ident, nameQuery: Option[String]): F[Vector[OSpace.SpaceItem]] + def findAll( + collective: Ident, + ownerLogin: Option[Ident], + nameQuery: Option[String] + ): F[Vector[OSpace.SpaceItem]] def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] @@ -55,9 +59,10 @@ object OSpace { Resource.pure[F, OSpace[F]](new OSpace[F] { def findAll( collective: Ident, + ownerLogin: Option[Ident], nameQuery: Option[String] ): F[Vector[SpaceItem]] = - store.transact(QSpace.findAll(collective, None, nameQuery)) + store.transact(QSpace.findAll(collective, None, ownerLogin, nameQuery)) def findById(id: Ident, collective: Ident): F[Option[SpaceDetail]] = store.transact(QSpace.findById(id, collective)) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index d1e30503..fd352df6 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -811,6 +811,7 @@ paths: - authTokenHeader: [] parameters: - $ref: "#/components/parameters/q" + - $ref: "#/components/parameters/owning" responses: 200: description: Ok @@ -3983,6 +3984,13 @@ components: required: false schema: type: boolean + owning: + name: full + in: query + description: Whether to get owning spaces + required: false + schema: + type: boolean checksum: name: checksum in: path 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/SpaceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala index 480d41d2..b51fefa0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala @@ -25,9 +25,11 @@ object SpaceRoutes { import dsl._ HttpRoutes.of { - case GET -> Root :? QueryParam.QueryOpt(q) => + case GET -> Root :? QueryParam.QueryOpt(q) :? QueryParam.OwningOpt(owning) => + val login = + owning.filter(identity).map(_ => user.account.user) for { - all <- backend.space.findAll(user.account.collective, q.map(_.q)) + all <- backend.space.findAll(user.account.collective, login, q.map(_.q)) resp <- Ok(SpaceList(all.map(mkSpace).toList)) } yield resp diff --git a/modules/store/src/main/scala/docspell/store/queries/QSpace.scala b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala index bd3febe3..696ad9fe 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QSpace.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala @@ -149,7 +149,7 @@ object QSpace { ).query[IdRef].to[Vector] (for { - space <- OptionT(findAll(collective, Some(id), None).map(_.headOption)) + space <- OptionT(findAll(collective, Some(id), None, None).map(_.headOption)) memb <- OptionT.liftF(memberQ) } yield space.withMembers(memb.toList)).value } @@ -157,9 +157,11 @@ object QSpace { def findAll( collective: Ident, idQ: Option[Ident], + ownerLogin: Option[Ident], nameQ: Option[String] ): ConnectionIO[Vector[SpaceItem]] = { val uId = RUser.Columns.uid.prefix("u") + val uLogin = RUser.Columns.login.prefix("u") val sId = RSpace.Columns.id.prefix("s") val sOwner = RSpace.Columns.owner.prefix("s") val sName = RSpace.Columns.name.prefix("s") @@ -178,7 +180,7 @@ object QSpace { val where = sColl.is(collective) :: idQ.toList.map(id => sId.is(id)) ::: nameQ.toList.map(q => sName.lowerLike(s"%${q.toLowerCase}%") - ) + ) ::: ownerLogin.toList.map(login => uLogin.is(login)) selectSimple(cols, from, and(where) ++ orderBy(sName.asc)).query[SpaceItem].to[Vector] } From 60a08fc78630fbdc9241fdc688aff9131c37826c Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Fri, 10 Jul 2020 00:46:14 +0200 Subject: [PATCH 09/27] Return member count and if current user is owner or member --- .../scala/docspell/backend/ops/OSpace.scala | 12 +-- .../src/main/resources/docspell-openapi.yml | 14 +++ .../restserver/conv/Conversions.scala | 2 +- .../restserver/routes/SpaceRoutes.scala | 12 ++- .../scala/docspell/store/queries/QSpace.scala | 85 ++++++++++++++++--- 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala index efec5b46..25ec9e20 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala @@ -10,12 +10,12 @@ import docspell.store.queries.QSpace trait OSpace[F[_]] { def findAll( - collective: Ident, + account: AccountId, ownerLogin: Option[Ident], nameQuery: Option[String] ): F[Vector[OSpace.SpaceItem]] - def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] + def findById(id: Ident, account: AccountId): F[Option[OSpace.SpaceDetail]] /** Adds a new space. If `login` is non-empty, the `space.user` * property is ignored and the user-id is determined by the given @@ -58,14 +58,14 @@ object OSpace { def apply[F[_]: Effect](store: Store[F]): Resource[F, OSpace[F]] = Resource.pure[F, OSpace[F]](new OSpace[F] { def findAll( - collective: Ident, + account: AccountId, ownerLogin: Option[Ident], nameQuery: Option[String] ): F[Vector[SpaceItem]] = - store.transact(QSpace.findAll(collective, None, ownerLogin, nameQuery)) + store.transact(QSpace.findAll(account, None, ownerLogin, nameQuery)) - def findById(id: Ident, collective: Ident): F[Option[SpaceDetail]] = - store.transact(QSpace.findById(id, collective)) + def findById(id: Ident, account: AccountId): F[Option[SpaceDetail]] = + store.transact(QSpace.findById(id, account)) def add(space: RSpace, login: Option[Ident]): F[AddResult] = { val insert = login match { diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index fd352df6..75ed64ad 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -2510,6 +2510,8 @@ components: - name - owner - created + - isMember + - memberCount properties: id: type: string @@ -2521,6 +2523,11 @@ components: created: type: integer format: date-time + isMember: + type: boolean + memberCount: + type: integer + format: int32 NewSpace: description: | Data required to create a new space. @@ -2537,6 +2544,8 @@ components: - name - owner - created + - isMember + - memberCount - members properties: id: @@ -2549,6 +2558,11 @@ components: created: type: integer format: date-time + isMember: + type: boolean + memberCount: + type: integer + format: int32 members: type: array items: 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 19997c74..271fae25 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, UpdateResult} import docspell.store.records._ +import docspell.store.{AddResult, UpdateResult} import bitpeace.FileMeta import org.http4s.headers.`Content-Type` diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala index b51fefa0..71c0a916 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala @@ -8,10 +8,10 @@ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OSpace import docspell.common._ -import docspell.store.records.RSpace import docspell.restapi.model._ import docspell.restserver.conv.Conversions import docspell.restserver.http4s._ +import docspell.store.records.RSpace import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ @@ -29,7 +29,7 @@ object SpaceRoutes { val login = owning.filter(identity).map(_ => user.account.user) for { - all <- backend.space.findAll(user.account.collective, login, q.map(_.q)) + all <- backend.space.findAll(user.account, login, q.map(_.q)) resp <- Ok(SpaceList(all.map(mkSpace).toList)) } yield resp @@ -43,7 +43,7 @@ object SpaceRoutes { case GET -> Root / Ident(id) => (for { - space <- OptionT(backend.space.findById(id, user.account.collective)) + space <- OptionT(backend.space.findById(id, user.account)) resp <- OptionT.liftF(Ok(mkSpaceDetail(space))) } yield resp).getOrElseF(NotFound()) @@ -82,7 +82,9 @@ object SpaceRoutes { item.id, item.name, Conversions.mkIdName(item.owner), - item.created + item.created, + item.member, + item.memberCount ) private def mkSpaceDetail(item: OSpace.SpaceDetail): SpaceDetail = @@ -91,6 +93,8 @@ object SpaceRoutes { item.name, Conversions.mkIdName(item.owner), item.created, + item.member, + item.memberCount, item.members.map(Conversions.mkIdName) ) diff --git a/modules/store/src/main/scala/docspell/store/queries/QSpace.scala b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala index 696ad9fe..8a840506 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QSpace.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala @@ -16,10 +16,12 @@ object QSpace { id: Ident, name: String, owner: IdRef, - created: Timestamp + created: Timestamp, + member: Boolean, + memberCount: Int ) { def withMembers(members: List[IdRef]): SpaceDetail = - SpaceDetail(id, name, owner, created, members) + SpaceDetail(id, name, owner, created, member, memberCount, members) } final case class SpaceDetail( @@ -27,6 +29,8 @@ object QSpace { name: String, owner: IdRef, created: Timestamp, + member: Boolean, + memberCount: Int, members: List[IdRef] ) @@ -130,7 +134,7 @@ object QSpace { } yield res).getOrElse(SpaceChangeResult.notFound) } - def findById(id: Ident, collective: Ident): ConnectionIO[Option[SpaceDetail]] = { + def findById(id: Ident, account: AccountId): ConnectionIO[Option[SpaceDetail]] = { val mUserId = RSpaceMember.Columns.user.prefix("m") val mSpaceId = RSpaceMember.Columns.space.prefix("m") val uId = RUser.Columns.uid.prefix("u") @@ -145,44 +149,99 @@ object QSpace { val memberQ = selectSimple( Seq(uId, uLogin), from, - and(mSpaceId.is(id), sColl.is(collective)) + and(mSpaceId.is(id), sColl.is(account.collective)) ).query[IdRef].to[Vector] (for { - space <- OptionT(findAll(collective, Some(id), None, None).map(_.headOption)) + space <- OptionT(findAll(account, Some(id), None, None).map(_.headOption)) memb <- OptionT.liftF(memberQ) } yield space.withMembers(memb.toList)).value } def findAll( - collective: Ident, + account: AccountId, idQ: Option[Ident], ownerLogin: Option[Ident], nameQ: Option[String] ): ConnectionIO[Vector[SpaceItem]] = { +// with memberlogin as +// (select m.space_id,u.login +// from space_member m +// inner join user_ u on u.uid = m.user_id +// inner join space s on s.id = m.space_id +// where s.cid = 'eike' +// union all +// select s.id,u.login +// from space 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 space_id = s.id and login = 'eike') as member +// ,(select count(*) - 1 from memberlogin where space_id = s.id) as member_count +// from space 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 = RSpace.Columns.id.prefix("s") val sOwner = RSpace.Columns.owner.prefix("s") val sName = RSpace.Columns.name.prefix("s") val sColl = RSpace.Columns.collective.prefix("s") + val mUser = RSpaceMember.Columns.user.prefix("m") + val mSpace = RSpaceMember.Columns.space.prefix("m") + + //CTE + val cte: Fragment = { + val from1 = RSpaceMember.table ++ fr"m INNER JOIN" ++ + RUser.table ++ fr"u ON" ++ uId.is(mUser) ++ fr"INNER JOIN" ++ + RSpace.table ++ fr"s ON" ++ sId.is(mSpace) + + val from2 = RSpace.table ++ fr"s INNER JOIN" ++ + RUser.table ++ fr"u ON" ++ uId.is(sOwner) + + withCTE( + "memberlogin" -> + (selectSimple(Seq(mSpace, 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" ++ mSpace.prefix("").is(sId) ++ + fr"AND" ++ uLogin.prefix("").is(account.user) + + val memberCount = + fr"SELECT COUNT(*) - 1 FROM memberlogin WHERE" ++ mSpace.prefix("").is(sId) + + //Query val cols = Seq( - sId, - sName, - uId, - RUser.Columns.login.prefix("u"), - RSpace.Columns.created.prefix("s") + sId.f, + sName.f, + sOwner.f, + uLogin.f, + RSpace.Columns.created.prefix("s").f, + fr"(" ++ isMember ++ fr") as mem", + fr"(" ++ memberCount ++ fr") as cnt" ) val from = RSpace.table ++ fr"s INNER JOIN" ++ RUser.table ++ fr"u ON" ++ uId.is(sOwner) val where = - sColl.is(collective) :: idQ.toList.map(id => sId.is(id)) ::: nameQ.toList.map(q => + 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)) - selectSimple(cols, from, and(where) ++ orderBy(sName.asc)).query[SpaceItem].to[Vector] + (cte ++ selectSimple(commas(cols), from, and(where) ++ orderBy(sName.asc))) + .query[SpaceItem] + .to[Vector] } private def findUserId(account: AccountId): ConnectionIO[Option[Ident]] = From 0365c1980aa7c2f6e2eff90d769917f03287029f Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Fri, 10 Jul 2020 01:04:59 +0200 Subject: [PATCH 10/27] Show new data about spaces in web-ui --- modules/webapp/src/main/elm/Api.elm | 15 +++++++-- .../webapp/src/main/elm/Comp/SpaceManage.elm | 33 ++++++++++++++++--- .../webapp/src/main/elm/Comp/SpaceTable.elm | 11 ++++++- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index aa2ff687..e2adb8c9 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -221,10 +221,19 @@ getSpaceDetail flags id receive = } -getSpaces : Flags -> String -> (Result Http.Error SpaceList -> msg) -> Cmd msg -getSpaces flags query receive = +getSpaces : Flags -> String -> Bool -> (Result Http.Error SpaceList -> msg) -> Cmd msg +getSpaces flags query owningOnly receive = Http2.authGet - { url = flags.config.baseUrl ++ "/api/v1/sec/space?q=" ++ Url.percentEncode query + { url = + flags.config.baseUrl + ++ "/api/v1/sec/space?q=" + ++ Url.percentEncode query + ++ (if owningOnly then + "&owning=true" + + else + "" + ) , account = getAccount flags , expect = Http.expectJson receive Api.Model.SpaceList.decoder } diff --git a/modules/webapp/src/main/elm/Comp/SpaceManage.elm b/modules/webapp/src/main/elm/Comp/SpaceManage.elm index 66aa2487..d050d6d6 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceManage.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceManage.elm @@ -18,7 +18,7 @@ import Comp.SpaceTable import Data.Flags exposing (Flags) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onClick, onInput) +import Html.Events exposing (onCheck, onClick, onInput) import Http @@ -28,6 +28,7 @@ type alias Model = , spaces : List SpaceItem , users : List User , query : String + , owningOnly : Bool , loading : Bool } @@ -40,6 +41,7 @@ type Msg | SpaceDetailResp (Result Http.Error SpaceDetail) | SetQuery String | InitNewSpace + | ToggleOwningOnly empty : Model @@ -49,6 +51,7 @@ empty = , spaces = [] , users = [] , query = "" + , owningOnly = True , loading = False } @@ -58,7 +61,7 @@ init flags = ( empty , Cmd.batch [ Api.getUsers flags UserListResp - , Api.getSpaces flags "" SpaceListResp + , Api.getSpaces flags empty.query empty.owningOnly SpaceListResp ] ) @@ -94,7 +97,7 @@ update flags msg model = cmd = if back then - Api.getSpaces flags model.query SpaceListResp + Api.getSpaces flags model.query model.owningOnly SpaceListResp else Cmd.none @@ -117,7 +120,18 @@ update flags msg model = ( model, Cmd.none ) SetQuery str -> - ( { model | query = str }, Api.getSpaces flags str SpaceListResp ) + ( { model | query = str } + , Api.getSpaces flags str model.owningOnly SpaceListResp + ) + + ToggleOwningOnly -> + let + newOwning = + not model.owningOnly + in + ( { model | owningOnly = newOwning } + , Api.getSpaces flags model.query newOwning SpaceListResp + ) UserListResp (Ok ul) -> ( { model | users = ul.items }, Cmd.none ) @@ -187,6 +201,17 @@ viewTable model = [] ] ] + , div [ class "item" ] + [ div [ class "ui checkbox" ] + [ input + [ type_ "checkbox" + , onCheck (\_ -> ToggleOwningOnly) + , checked model.owningOnly + ] + [] + , label [] [ text "Show owning spaces only" ] + ] + ] , div [ class "right menu" ] [ div [ class "item" ] [ a diff --git a/modules/webapp/src/main/elm/Comp/SpaceTable.elm b/modules/webapp/src/main/elm/Comp/SpaceTable.elm index acfac4dc..19f7fb3e 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceTable.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceTable.elm @@ -8,10 +8,10 @@ module Comp.SpaceTable exposing ) import Api.Model.SpaceItem exposing (SpaceItem) -import Api.Model.SpaceList exposing (SpaceList) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick) +import Util.Html import Util.Time @@ -48,6 +48,8 @@ view _ items = [ th [ class "collapsing" ] [] , th [] [ text "Name" ] , th [] [ text "Owner" ] + , th [] [ text "Owner or Member" ] + , th [] [ text "#Member" ] , th [] [ text "Created" ] ] , tbody [] @@ -75,6 +77,13 @@ viewItem item = , td [] [ text item.owner.name ] + , td [] + [ Util.Html.checkbox item.isMember + ] + , td [] + [ String.fromInt item.memberCount + |> text + ] , td [] [ Util.Time.formatDateShort item.created |> text From 2ab0b5e222c658f8ef4731a7785358f1bd1ef5ea Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 11:38:57 +0200 Subject: [PATCH 11/27] Rename space -> folder --- .../scala/docspell/backend/BackendApp.scala | 6 +- .../scala/docspell/backend/ops/OFolder.scala | 110 ++++++++ .../scala/docspell/backend/ops/OSpace.scala | 110 -------- .../src/main/resources/docspell-openapi.yml | 84 +++--- .../docspell/restserver/RestServer.scala | 2 +- .../restserver/routes/FolderRoutes.scala | 113 ++++++++ .../restserver/routes/SpaceRoutes.scala | 112 -------- ...V1.8.0__spaces.sql => V1.8.0__folders.sql} | 12 +- .../docspell/store/queries/QFolder.scala | 249 ++++++++++++++++++ .../scala/docspell/store/queries/QSpace.scala | 249 ------------------ .../records/{RSpace.scala => RFolder.scala} | 32 +-- .../store/records/RFolderMember.scala | 61 +++++ .../scala/docspell/store/records/RItem.scala | 12 +- .../docspell/store/records/RSpaceMember.scala | 61 ----- modules/webapp/src/main/elm/Api.elm | 60 ++--- .../{SpaceDetail.elm => FolderDetail.elm} | 82 +++--- .../{SpaceManage.elm => FolderManage.elm} | 78 +++--- .../Comp/{SpaceTable.elm => FolderTable.elm} | 12 +- modules/webapp/src/main/elm/Data/Icons.elm | 14 +- .../src/main/elm/Page/ManageData/Data.elm | 10 +- .../src/main/elm/Page/ManageData/Update.elm | 16 +- .../src/main/elm/Page/ManageData/View.elm | 24 +- 22 files changed, 755 insertions(+), 754 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala delete mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/FolderRoutes.scala delete mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala rename modules/store/src/main/resources/db/migration/postgresql/{V1.8.0__spaces.sql => V1.8.0__folders.sql} (68%) create mode 100644 modules/store/src/main/scala/docspell/store/queries/QFolder.scala delete mode 100644 modules/store/src/main/scala/docspell/store/queries/QSpace.scala rename modules/store/src/main/scala/docspell/store/records/{RSpace.scala => RFolder.scala} (64%) create mode 100644 modules/store/src/main/scala/docspell/store/records/RFolderMember.scala delete mode 100644 modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala rename modules/webapp/src/main/elm/Comp/{SpaceDetail.elm => FolderDetail.elm} (80%) rename modules/webapp/src/main/elm/Comp/{SpaceManage.elm => FolderManage.elm} (68%) rename modules/webapp/src/main/elm/Comp/{SpaceTable.elm => FolderTable.elm} (88%) diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index d62dddd1..72ce0138 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -35,7 +35,7 @@ trait BackendApp[F[_]] { def mail: OMail[F] def joex: OJoex[F] def userTask: OUserTask[F] - def space: OSpace[F] + def folder: OFolder[F] } object BackendApp { @@ -68,7 +68,7 @@ object BackendApp { JavaMailEmil(blocker, Settings.defaultSettings.copy(debug = cfg.mailDebug)) mailImpl <- OMail(store, javaEmil) userTaskImpl <- OUserTask(utStore, queue, joexImpl) - spaceImpl <- OSpace(store) + folderImpl <- OFolder(store) } yield new BackendApp[F] { val login: Login[F] = loginImpl val signup: OSignup[F] = signupImpl @@ -86,7 +86,7 @@ object BackendApp { val mail = mailImpl val joex = joexImpl val userTask = userTaskImpl - val space = spaceImpl + 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..e93b7d5d --- /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.{AddResult, Store} +import docspell.store.records.{RFolder, RUser} +import docspell.store.queries.QFolder + +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/OSpace.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala deleted file mode 100644 index 25ec9e20..00000000 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala +++ /dev/null @@ -1,110 +0,0 @@ -package docspell.backend.ops - -import cats.effect._ - -import docspell.common._ -import docspell.store.{AddResult, Store} -import docspell.store.records.{RSpace, RUser} -import docspell.store.queries.QSpace - -trait OSpace[F[_]] { - - def findAll( - account: AccountId, - ownerLogin: Option[Ident], - nameQuery: Option[String] - ): F[Vector[OSpace.SpaceItem]] - - def findById(id: Ident, account: AccountId): F[Option[OSpace.SpaceDetail]] - - /** Adds a new space. If `login` is non-empty, the `space.user` - * property is ignored and the user-id is determined by the given - * login name. - */ - def add(space: RSpace, login: Option[Ident]): F[AddResult] - - def changeName( - space: Ident, - account: AccountId, - name: String - ): F[OSpace.SpaceChangeResult] - - def addMember( - space: Ident, - account: AccountId, - member: Ident - ): F[OSpace.SpaceChangeResult] - - def removeMember( - space: Ident, - account: AccountId, - member: Ident - ): F[OSpace.SpaceChangeResult] - - def delete(id: Ident, account: AccountId): F[OSpace.SpaceChangeResult] -} - -object OSpace { - - type SpaceChangeResult = QSpace.SpaceChangeResult - val SpaceChangeResult = QSpace.SpaceChangeResult - - type SpaceItem = QSpace.SpaceItem - val SpaceItem = QSpace.SpaceItem - - type SpaceDetail = QSpace.SpaceDetail - val SpaceDetail = QSpace.SpaceDetail - - def apply[F[_]: Effect](store: Store[F]): Resource[F, OSpace[F]] = - Resource.pure[F, OSpace[F]](new OSpace[F] { - def findAll( - account: AccountId, - ownerLogin: Option[Ident], - nameQuery: Option[String] - ): F[Vector[SpaceItem]] = - store.transact(QSpace.findAll(account, None, ownerLogin, nameQuery)) - - def findById(id: Ident, account: AccountId): F[Option[SpaceDetail]] = - store.transact(QSpace.findById(id, account)) - - def add(space: RSpace, login: Option[Ident]): F[AddResult] = { - val insert = login match { - case Some(n) => - for { - user <- RUser.findByAccount(AccountId(space.collectiveId, n)) - s = user.map(u => space.copy(owner = u.uid)).getOrElse(space) - n <- RSpace.insert(s) - } yield n - - case None => - RSpace.insert(space) - } - val exists = RSpace.existsByName(space.collectiveId, space.name) - store.add(insert, exists) - } - - def changeName( - space: Ident, - account: AccountId, - name: String - ): F[SpaceChangeResult] = - store.transact(QSpace.changeName(space, account, name)) - - def addMember( - space: Ident, - account: AccountId, - member: Ident - ): F[SpaceChangeResult] = - store.transact(QSpace.addMember(space, account, member)) - - def removeMember( - space: Ident, - account: AccountId, - member: Ident - ): F[SpaceChangeResult] = - store.transact(QSpace.removeMember(space, account, member)) - - def delete(id: Ident, account: AccountId): F[SpaceChangeResult] = - store.transact(QSpace.delete(id, account)) - }) -} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 75ed64ad..bcdeef5c 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -795,14 +795,14 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" - /sec/space: + /sec/folder: get: - tags: [ Space ] - summary: Get a list of spaces. + tags: [ Folder ] + summary: Get a list of folders. description: | - Return a list of spaces for the current collective. + Return a list of folders for the current collective. - All spaces are returned, including those not owned by the + All folders are returned, including those not owned by the current user. It is possible to restrict the results by a substring match of @@ -818,12 +818,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SpaceList" + $ref: "#/components/schemas/FolderList" post: - tags: [ Space ] - summary: Create a new space + tags: [ Folder ] + summary: Create a new folder description: | - Create a new space owned by the current user. If a space with + Create a new folder owned by the current user. If a folder with the same name already exists, an error is thrown. security: - authTokenHeader: [] @@ -831,7 +831,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/NewSpace" + $ref: "#/components/schemas/NewFolder" responses: 200: description: Ok @@ -839,12 +839,12 @@ paths: application/json: schema: $ref: "#/components/schemas/IdResult" - /sec/space/{id}: + /sec/folder/{id}: get: - tags: [ Space ] - summary: Get space details. + tags: [ Folder ] + summary: Get folder details. description: | - Return details about a space. + Return details about a folder. security: - authTokenHeader: [] parameters: @@ -855,12 +855,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SpaceDetail" + $ref: "#/components/schemas/FolderDetail" put: - tags: [ Space ] - summary: Change the name of a space + tags: [ Folder ] + summary: Change the name of a folder description: | - Changes the name of a space. The new name must not exists. + Changes the name of a folder. The new name must not exists. security: - authTokenHeader: [] parameters: @@ -869,7 +869,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/NewSpace" + $ref: "#/components/schemas/NewFolder" responses: 200: description: Ok @@ -878,10 +878,10 @@ paths: schema: $ref: "#/components/schemas/BasicResult" delete: - tags: [ Space ] - summary: Delete a space by its id. + tags: [ Folder ] + summary: Delete a folder by its id. description: | - Deletes a space. + Deletes a folder. security: - authTokenHeader: [] parameters: @@ -893,12 +893,12 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" - /sec/space/{id}/member/{userId}: + /sec/folder/{id}/member/{userId}: put: - tags: [ Space ] - summary: Add a member to this space + tags: [ Folder ] + summary: Add a member to this folder description: | - Adds a member to this space (identified by `id`). + Adds a member to this folder (identified by `id`). security: - authTokenHeader: [] parameters: @@ -912,10 +912,10 @@ paths: schema: $ref: "#/components/schemas/BasicResult" delete: - tags: [ Space ] - summary: Removes a member from this space. + tags: [ Folder ] + summary: Removes a member from this folder. description: | - Removes a member from this space. + Removes a member from this folder. security: - authTokenHeader: [] parameters: @@ -984,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: @@ -2492,19 +2492,19 @@ paths: components: schemas: - SpaceList: + FolderList: description: | - A list of spaces with their member counts. + A list of folders with their member counts. required: - items properties: items: type: array items: - $ref: "#/components/schemas/SpaceItem" - SpaceItem: + $ref: "#/components/schemas/FolderItem" + FolderItem: description: | - An item in a space list. + An item in a folder list. required: - id - name @@ -2528,17 +2528,17 @@ components: memberCount: type: integer format: int32 - NewSpace: + NewFolder: description: | - Data required to create a new space. + Data required to create a new folder. required: - name properties: name: type: string - SpaceDetail: + FolderDetail: description: | - Details about a space. + Details about a folder. required: - id - name @@ -2567,9 +2567,9 @@ components: type: array items: $ref: "#/components/schemas/IdName" - SpaceMember: + FolderMember: description: | - Information to add or remove a space member. + Information to add or remove a folder member. required: - userId properties: @@ -4001,7 +4001,7 @@ components: owning: name: full in: query - description: Whether to get owning spaces + description: Whether to get owning folders required: false schema: type: boolean diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index d31becf3..501628ea 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -82,7 +82,7 @@ object RestServer { "usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token), "calevent/check" -> CalEventCheckRoutes(), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), - "space" -> SpaceRoutes(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/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/SpaceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala deleted file mode 100644 index 71c0a916..00000000 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala +++ /dev/null @@ -1,112 +0,0 @@ -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.OSpace -import docspell.common._ -import docspell.restapi.model._ -import docspell.restserver.conv.Conversions -import docspell.restserver.http4s._ -import docspell.store.records.RSpace - -import org.http4s.HttpRoutes -import org.http4s.circe.CirceEntityDecoder._ -import org.http4s.circe.CirceEntityEncoder._ -import org.http4s.dsl.Http4sDsl - -object SpaceRoutes { - - 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.space.findAll(user.account, login, q.map(_.q)) - resp <- Ok(SpaceList(all.map(mkSpace).toList)) - } yield resp - - case req @ POST -> Root => - for { - data <- req.as[NewSpace] - nspace <- newSpace(data, user.account) - res <- backend.space.add(nspace, Some(user.account.user)) - resp <- Ok(Conversions.idResult(res, nspace.id, "Space successfully created.")) - } yield resp - - case GET -> Root / Ident(id) => - (for { - space <- OptionT(backend.space.findById(id, user.account)) - resp <- OptionT.liftF(Ok(mkSpaceDetail(space))) - } yield resp).getOrElseF(NotFound()) - - case req @ PUT -> Root / Ident(id) => - for { - data <- req.as[NewSpace] - res <- backend.space.changeName(id, user.account, data.name) - resp <- Ok(mkSpaceChangeResult(res)) - } yield resp - - case DELETE -> Root / Ident(id) => - for { - res <- backend.space.delete(id, user.account) - resp <- Ok(mkSpaceChangeResult(res)) - } yield resp - - case PUT -> Root / Ident(id) / "member" / Ident(userId) => - for { - res <- backend.space.addMember(id, user.account, userId) - resp <- Ok(mkSpaceChangeResult(res)) - } yield resp - - case DELETE -> Root / Ident(id) / "member" / Ident(userId) => - for { - res <- backend.space.removeMember(id, user.account, userId) - resp <- Ok(mkSpaceChangeResult(res)) - } yield resp - } - } - - private def newSpace[F[_]: Sync](ns: NewSpace, account: AccountId): F[RSpace] = - RSpace.newSpace(ns.name, account) - - private def mkSpace(item: OSpace.SpaceItem): SpaceItem = - SpaceItem( - item.id, - item.name, - Conversions.mkIdName(item.owner), - item.created, - item.member, - item.memberCount - ) - - private def mkSpaceDetail(item: OSpace.SpaceDetail): SpaceDetail = - SpaceDetail( - item.id, - item.name, - Conversions.mkIdName(item.owner), - item.created, - item.member, - item.memberCount, - item.members.map(Conversions.mkIdName) - ) - - private def mkSpaceChangeResult(r: OSpace.SpaceChangeResult): BasicResult = - r match { - case OSpace.SpaceChangeResult.Success => - BasicResult(true, "Successfully changed space.") - case OSpace.SpaceChangeResult.NotFound => - BasicResult(false, "Space or user not found.") - case OSpace.SpaceChangeResult.Forbidden => - BasicResult(false, "Not allowed to edit space.") - case OSpace.SpaceChangeResult.Exists => - BasicResult(false, "The member already exists.") - } -} diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__folders.sql similarity index 68% rename from modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql rename to modules/store/src/main/resources/db/migration/postgresql/V1.8.0__folders.sql index 8a43c097..19fdd8a3 100644 --- a/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__spaces.sql +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.8.0__folders.sql @@ -1,4 +1,4 @@ -CREATE TABLE "space" ( +CREATE TABLE "folder" ( "id" varchar(254) not null primary key, "name" varchar(254) not null, "cid" varchar(254) not null, @@ -9,15 +9,15 @@ CREATE TABLE "space" ( foreign key ("owner") references "user_"("uid") ); -CREATE TABLE "space_member" ( +CREATE TABLE "folder_member" ( "id" varchar(254) not null primary key, - "space_id" varchar(254) not null, + "folder_id" varchar(254) not null, "user_id" varchar(254) not null, "created" timestamp not null, - unique ("space_id", "user_id"), - foreign key ("space_id") references "space"("id"), + unique ("folder_id", "user_id"), + foreign key ("folder_id") references "folder"("id"), foreign key ("user_id") references "user_"("uid") ); ALTER TABLE "item" -ADD COLUMN "space_id" varchar(254) NULL; +ADD COLUMN "folder_id" varchar(254) NULL; 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..1495d1b0 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala @@ -0,0 +1,249 @@ +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) + _ <- 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] + } + + 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/QSpace.scala b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala deleted file mode 100644 index 8a840506..00000000 --- a/modules/store/src/main/scala/docspell/store/queries/QSpace.scala +++ /dev/null @@ -1,249 +0,0 @@ -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 QSpace { - - final case class SpaceItem( - id: Ident, - name: String, - owner: IdRef, - created: Timestamp, - member: Boolean, - memberCount: Int - ) { - def withMembers(members: List[IdRef]): SpaceDetail = - SpaceDetail(id, name, owner, created, member, memberCount, members) - } - - final case class SpaceDetail( - id: Ident, - name: String, - owner: IdRef, - created: Timestamp, - member: Boolean, - memberCount: Int, - members: List[IdRef] - ) - - sealed trait SpaceChangeResult - object SpaceChangeResult { - case object Success extends SpaceChangeResult - def success: SpaceChangeResult = Success - case object NotFound extends SpaceChangeResult - def notFound: SpaceChangeResult = NotFound - case object Forbidden extends SpaceChangeResult - def forbidden: SpaceChangeResult = Forbidden - case object Exists extends SpaceChangeResult - def exists: SpaceChangeResult = Exists - } - - def delete(id: Ident, account: AccountId): ConnectionIO[SpaceChangeResult] = { - def tryDelete = - for { - _ <- RItem.removeSpace(id) - _ <- RSpaceMember.deleteAll(id) - _ <- RSpace.delete(id) - } yield SpaceChangeResult.success - - (for { - uid <- OptionT(findUserId(account)) - space <- OptionT(RSpace.findById(id)) - res <- OptionT.liftF( - if (space.owner == uid) tryDelete - else SpaceChangeResult.forbidden.pure[ConnectionIO] - ) - } yield res).getOrElse(SpaceChangeResult.notFound) - } - - def changeName( - space: Ident, - account: AccountId, - name: String - ): ConnectionIO[SpaceChangeResult] = { - def tryUpdate(ns: RSpace): ConnectionIO[SpaceChangeResult] = - for { - n <- RSpace.update(ns) - res = - if (n == 0) SpaceChangeResult.notFound - else SpaceChangeResult.Success - } yield res - - (for { - uid <- OptionT(findUserId(account)) - space <- OptionT(RSpace.findById(space)) - res <- OptionT.liftF( - if (space.owner == uid) tryUpdate(space.copy(name = name)) - else SpaceChangeResult.forbidden.pure[ConnectionIO] - ) - } yield res).getOrElse(SpaceChangeResult.notFound) - } - - def removeMember( - space: Ident, - account: AccountId, - member: Ident - ): ConnectionIO[SpaceChangeResult] = { - def tryRemove: ConnectionIO[SpaceChangeResult] = - for { - n <- RSpaceMember.delete(member, space) - res = - if (n == 0) SpaceChangeResult.notFound - else SpaceChangeResult.Success - } yield res - - (for { - uid <- OptionT(findUserId(account)) - space <- OptionT(RSpace.findById(space)) - res <- OptionT.liftF( - if (space.owner == uid) tryRemove - else SpaceChangeResult.forbidden.pure[ConnectionIO] - ) - } yield res).getOrElse(SpaceChangeResult.notFound) - } - - def addMember( - space: Ident, - account: AccountId, - member: Ident - ): ConnectionIO[SpaceChangeResult] = { - def tryAdd: ConnectionIO[SpaceChangeResult] = - for { - spm <- RSpaceMember.findByUserId(member, space) - mem <- RSpaceMember.newMember[ConnectionIO](space, member) - res <- - if (spm.isDefined) SpaceChangeResult.exists.pure[ConnectionIO] - else RSpaceMember.insert(mem).map(_ => SpaceChangeResult.Success) - } yield res - - (for { - uid <- OptionT(findUserId(account)) - space <- OptionT(RSpace.findById(space)) - res <- OptionT.liftF( - if (space.owner == uid) tryAdd - else SpaceChangeResult.forbidden.pure[ConnectionIO] - ) - } yield res).getOrElse(SpaceChangeResult.notFound) - } - - def findById(id: Ident, account: AccountId): ConnectionIO[Option[SpaceDetail]] = { - val mUserId = RSpaceMember.Columns.user.prefix("m") - val mSpaceId = RSpaceMember.Columns.space.prefix("m") - val uId = RUser.Columns.uid.prefix("u") - val uLogin = RUser.Columns.login.prefix("u") - val sColl = RSpace.Columns.collective.prefix("s") - val sId = RSpace.Columns.id.prefix("s") - - val from = RSpaceMember.table ++ fr"m INNER JOIN" ++ - RUser.table ++ fr"u ON" ++ mUserId.is(uId) ++ fr"INNER JOIN" ++ - RSpace.table ++ fr"s ON" ++ mSpaceId.is(sId) - - val memberQ = selectSimple( - Seq(uId, uLogin), - from, - and(mSpaceId.is(id), sColl.is(account.collective)) - ).query[IdRef].to[Vector] - - (for { - space <- OptionT(findAll(account, Some(id), None, None).map(_.headOption)) - memb <- OptionT.liftF(memberQ) - } yield space.withMembers(memb.toList)).value - } - - def findAll( - account: AccountId, - idQ: Option[Ident], - ownerLogin: Option[Ident], - nameQ: Option[String] - ): ConnectionIO[Vector[SpaceItem]] = { -// with memberlogin as -// (select m.space_id,u.login -// from space_member m -// inner join user_ u on u.uid = m.user_id -// inner join space s on s.id = m.space_id -// where s.cid = 'eike' -// union all -// select s.id,u.login -// from space 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 space_id = s.id and login = 'eike') as member -// ,(select count(*) - 1 from memberlogin where space_id = s.id) as member_count -// from space 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 = RSpace.Columns.id.prefix("s") - val sOwner = RSpace.Columns.owner.prefix("s") - val sName = RSpace.Columns.name.prefix("s") - val sColl = RSpace.Columns.collective.prefix("s") - val mUser = RSpaceMember.Columns.user.prefix("m") - val mSpace = RSpaceMember.Columns.space.prefix("m") - - //CTE - val cte: Fragment = { - val from1 = RSpaceMember.table ++ fr"m INNER JOIN" ++ - RUser.table ++ fr"u ON" ++ uId.is(mUser) ++ fr"INNER JOIN" ++ - RSpace.table ++ fr"s ON" ++ sId.is(mSpace) - - val from2 = RSpace.table ++ fr"s INNER JOIN" ++ - RUser.table ++ fr"u ON" ++ uId.is(sOwner) - - withCTE( - "memberlogin" -> - (selectSimple(Seq(mSpace, 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" ++ mSpace.prefix("").is(sId) ++ - fr"AND" ++ uLogin.prefix("").is(account.user) - - val memberCount = - fr"SELECT COUNT(*) - 1 FROM memberlogin WHERE" ++ mSpace.prefix("").is(sId) - - //Query - val cols = Seq( - sId.f, - sName.f, - sOwner.f, - uLogin.f, - RSpace.Columns.created.prefix("s").f, - fr"(" ++ isMember ++ fr") as mem", - fr"(" ++ memberCount ++ fr") as cnt" - ) - - val from = RSpace.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[SpaceItem] - .to[Vector] - } - - private def findUserId(account: AccountId): ConnectionIO[Option[Ident]] = - RUser.findByAccount(account).map(_.map(_.uid)) -} diff --git a/modules/store/src/main/scala/docspell/store/records/RSpace.scala b/modules/store/src/main/scala/docspell/store/records/RFolder.scala similarity index 64% rename from modules/store/src/main/scala/docspell/store/records/RSpace.scala rename to modules/store/src/main/scala/docspell/store/records/RFolder.scala index 00bc802f..47401984 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSpace.scala +++ b/modules/store/src/main/scala/docspell/store/records/RFolder.scala @@ -9,7 +9,7 @@ import docspell.store.impl.Implicits._ import doobie._ import doobie.implicits._ -case class RSpace( +case class RFolder( id: Ident, name: String, collectiveId: Ident, @@ -17,15 +17,15 @@ case class RSpace( created: Timestamp ) -object RSpace { +object RFolder { - def newSpace[F[_]: Sync](name: String, account: AccountId): F[RSpace] = + def newFolder[F[_]: Sync](name: String, account: AccountId): F[RFolder] = for { nId <- Ident.randomId[F] now <- Timestamp.current[F] - } yield RSpace(nId, name, account.collective, account.user, now) + } yield RFolder(nId, name, account.collective, account.user, now) - val table = fr"space" + val table = fr"folder" object Columns { @@ -40,7 +40,7 @@ object RSpace { import Columns._ - def insert(value: RSpace): ConnectionIO[Int] = { + def insert(value: RFolder): ConnectionIO[Int] = { val sql = insertRow( table, all, @@ -49,37 +49,37 @@ object RSpace { sql.update.run } - def update(v: RSpace): ConnectionIO[Int] = + 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, spaceName: String): ConnectionIO[Boolean] = - selectCount(id, table, and(collective.is(coll), name.is(spaceName))) + 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(spaceId: Ident): ConnectionIO[Option[RSpace]] = { - val sql = selectSimple(all, table, id.is(spaceId)) - sql.query[RSpace].option + 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[RSpace]] = { + ): 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[RSpace].to[Vector] + sql.query[RFolder].to[Vector] } - def delete(spaceId: Ident): ConnectionIO[Int] = - deleteFrom(table, id.is(spaceId)).update.run + 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 a8b33509..b0c197ce 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -28,7 +28,7 @@ case class RItem( created: Timestamp, updated: Timestamp, notes: Option[String], - spaceId: Option[Ident] + folderId: Option[Ident] ) {} object RItem { @@ -82,7 +82,7 @@ object RItem { val created = Column("created") val updated = Column("updated") val notes = Column("notes") - val space = Column("space_id") + val folder = Column("folder_id") val all = List( id, cid, @@ -100,7 +100,7 @@ object RItem { created, updated, notes, - space + folder ) } import Columns._ @@ -111,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},${v.spaceId}" + fr"${v.created},${v.updated},${v.notes},${v.folderId}" ).update.run def getCollective(itemId: Ident): ConnectionIO[Option[Ident]] = @@ -300,8 +300,8 @@ 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 removeSpace(spaceId: Ident): ConnectionIO[Int] = { + def removeFolder(folderId: Ident): ConnectionIO[Int] = { val empty: Option[Ident] = None - updateRow(table, space.is(spaceId), space.setTo(empty)).update.run + updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run } } diff --git a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala deleted file mode 100644 index 839c6267..00000000 --- a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala +++ /dev/null @@ -1,61 +0,0 @@ -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 RSpaceMember( - id: Ident, - spaceId: Ident, - userId: Ident, - created: Timestamp -) - -object RSpaceMember { - - def newMember[F[_]: Sync](space: Ident, user: Ident): F[RSpaceMember] = - for { - nId <- Ident.randomId[F] - now <- Timestamp.current[F] - } yield RSpaceMember(nId, space, user, now) - - val table = fr"space_member" - - object Columns { - - val id = Column("id") - val space = Column("space_id") - val user = Column("user_id") - val created = Column("created") - - val all = List(id, space, user, created) - } - - import Columns._ - - def insert(value: RSpaceMember): ConnectionIO[Int] = { - val sql = insertRow( - table, - all, - fr"${value.id},${value.spaceId},${value.userId},${value.created}" - ) - sql.update.run - } - - def findByUserId(userId: Ident, spaceId: Ident): ConnectionIO[Option[RSpaceMember]] = - selectSimple(all, table, and(space.is(spaceId), user.is(userId))) - .query[RSpaceMember] - .option - - def delete(userId: Ident, spaceId: Ident): ConnectionIO[Int] = - deleteFrom(table, and(space.is(spaceId), user.is(userId))).update.run - - def deleteAll(spaceId: Ident): ConnectionIO[Int] = - deleteFrom(table, space.is(spaceId)).update.run -} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index e2adb8c9..b23fa1dd 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -6,16 +6,17 @@ module Api exposing , addMember , addTag , cancelJob + , changeFolderName , changePassword - , changeSpaceName , checkCalEvent , createImapSettings , createMailSettings - , createNewSpace + , createNewFolder , createNotifyDueItems , createScanMailbox , deleteAttachment , deleteEquip + , deleteFolder , deleteImapSettings , deleteItem , deleteMailSettings @@ -24,7 +25,6 @@ module Api exposing , deletePerson , deleteScanMailbox , deleteSource - , deleteSpace , deleteTag , deleteUser , getAttachmentMeta @@ -32,6 +32,8 @@ module Api exposing , getCollectiveSettings , getContacts , getEquipments + , getFolderDetail + , getFolders , getImapSettings , getInsights , getItemProposals @@ -46,8 +48,6 @@ module Api exposing , getScanMailbox , getSentMails , getSources - , getSpaceDetail - , getSpaces , getTags , getUsers , itemDetail @@ -108,6 +108,8 @@ 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) @@ -122,7 +124,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.NewSpace exposing (NewSpace) +import Api.Model.NewFolder exposing (NewFolder) import Api.Model.NotificationSettings exposing (NotificationSettings) import Api.Model.NotificationSettingsList exposing (NotificationSettingsList) import Api.Model.OptionalDate exposing (OptionalDate) @@ -141,8 +143,6 @@ import Api.Model.SentMails exposing (SentMails) import Api.Model.SimpleMail exposing (SimpleMail) import Api.Model.Source exposing (Source) import Api.Model.SourceList exposing (SourceList) -import Api.Model.SpaceDetail exposing (SpaceDetail) -import Api.Model.SpaceList exposing (SpaceList) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) import Api.Model.User exposing (User) @@ -161,13 +161,13 @@ import Util.Http as Http2 ---- Spaces +--- Folders -deleteSpace : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg -deleteSpace flags id receive = +deleteFolder : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteFolder flags id receive = Http2.authDelete - { url = flags.config.baseUrl ++ "/api/v1/sec/space/" ++ id + { url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id , account = getAccount flags , expect = Http.expectJson receive Api.Model.BasicResult.decoder } @@ -176,7 +176,7 @@ deleteSpace flags id receive = 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/space/" ++ id ++ "/member/" ++ user + { url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id ++ "/member/" ++ user , account = getAccount flags , expect = Http.expectJson receive Api.Model.BasicResult.decoder } @@ -185,48 +185,48 @@ removeMember flags id user receive = 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/space/" ++ id ++ "/member/" ++ user + { 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 } -changeSpaceName : Flags -> String -> NewSpace -> (Result Http.Error BasicResult -> msg) -> Cmd msg -changeSpaceName flags id ns receive = +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/space/" ++ id + { url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id , account = getAccount flags - , body = Http.jsonBody (Api.Model.NewSpace.encode ns) + , body = Http.jsonBody (Api.Model.NewFolder.encode ns) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } -createNewSpace : Flags -> NewSpace -> (Result Http.Error IdResult -> msg) -> Cmd msg -createNewSpace flags ns receive = +createNewFolder : Flags -> NewFolder -> (Result Http.Error IdResult -> msg) -> Cmd msg +createNewFolder flags ns receive = Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/space" + { url = flags.config.baseUrl ++ "/api/v1/sec/folder" , account = getAccount flags - , body = Http.jsonBody (Api.Model.NewSpace.encode ns) + , body = Http.jsonBody (Api.Model.NewFolder.encode ns) , expect = Http.expectJson receive Api.Model.IdResult.decoder } -getSpaceDetail : Flags -> String -> (Result Http.Error SpaceDetail -> msg) -> Cmd msg -getSpaceDetail flags id receive = +getFolderDetail : Flags -> String -> (Result Http.Error FolderDetail -> msg) -> Cmd msg +getFolderDetail flags id receive = Http2.authGet - { url = flags.config.baseUrl ++ "/api/v1/sec/space/" ++ id + { url = flags.config.baseUrl ++ "/api/v1/sec/folder/" ++ id , account = getAccount flags - , expect = Http.expectJson receive Api.Model.SpaceDetail.decoder + , expect = Http.expectJson receive Api.Model.FolderDetail.decoder } -getSpaces : Flags -> String -> Bool -> (Result Http.Error SpaceList -> msg) -> Cmd msg -getSpaces flags query owningOnly receive = +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/space?q=" + ++ "/api/v1/sec/folder?q=" ++ Url.percentEncode query ++ (if owningOnly then "&owning=true" @@ -235,7 +235,7 @@ getSpaces flags query owningOnly receive = "" ) , account = getAccount flags - , expect = Http.expectJson receive Api.Model.SpaceList.decoder + , expect = Http.expectJson receive Api.Model.FolderList.decoder } diff --git a/modules/webapp/src/main/elm/Comp/SpaceDetail.elm b/modules/webapp/src/main/elm/Comp/FolderDetail.elm similarity index 80% rename from modules/webapp/src/main/elm/Comp/SpaceDetail.elm rename to modules/webapp/src/main/elm/Comp/FolderDetail.elm index b6e9c571..f3e44abc 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceDetail.elm +++ b/modules/webapp/src/main/elm/Comp/FolderDetail.elm @@ -1,4 +1,4 @@ -module Comp.SpaceDetail exposing +module Comp.FolderDetail exposing ( Model , Msg , init @@ -9,10 +9,10 @@ module Comp.SpaceDetail exposing 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.NewSpace exposing (NewSpace) -import Api.Model.SpaceDetail exposing (SpaceDetail) +import Api.Model.NewFolder exposing (NewFolder) import Api.Model.User exposing (User) import Api.Model.UserList exposing (UserList) import Comp.FixedDropdown @@ -28,7 +28,7 @@ import Util.Maybe type alias Model = { result : Maybe BasicResult - , space : SpaceDetail + , folder : FolderDetail , name : Maybe String , members : List IdName , users : List User @@ -43,10 +43,10 @@ type Msg = SetName String | MemberDropdownMsg (Comp.FixedDropdown.Msg IdName) | SaveName - | NewSpaceResp (Result Http.Error IdResult) - | ChangeSpaceResp (Result Http.Error BasicResult) + | NewFolderResp (Result Http.Error IdResult) + | ChangeFolderResp (Result Http.Error BasicResult) | ChangeNameResp (Result Http.Error BasicResult) - | SpaceDetailResp (Result Http.Error SpaceDetail) + | FolderDetailResp (Result Http.Error FolderDetail) | AddMember | RemoveMember IdName | RequestDelete @@ -55,16 +55,16 @@ type Msg | GoBack -init : List User -> SpaceDetail -> Model -init users space = +init : List User -> FolderDetail -> Model +init users folder = { result = Nothing - , space = space - , name = Util.Maybe.fromString space.name - , members = space.members + , folder = folder + , name = Util.Maybe.fromString folder.name + , members = folder.members , users = users , memberDropdown = Comp.FixedDropdown.initMap .name - (makeOptions users space) + (makeOptions users folder) , selectedMember = Nothing , loading = False , deleteDimmer = Comp.YesNoDimmer.emptyModel @@ -73,17 +73,17 @@ init users space = initEmpty : List User -> Model initEmpty users = - init users Api.Model.SpaceDetail.empty + init users Api.Model.FolderDetail.empty -makeOptions : List User -> SpaceDetail -> List IdName -makeOptions users space = +makeOptions : List User -> FolderDetail -> List IdName +makeOptions users folder = let toIdName u = IdName u.id u.login notMember idn = - List.member idn (space.owner :: space.members) |> not + List.member idn (folder.owner :: folder.members) |> not in List.map toIdName users |> List.filter notMember @@ -129,13 +129,13 @@ update flags msg model = Just name -> let cmd = - if model.space.id == "" then - Api.createNewSpace flags (NewSpace name) NewSpaceResp + if model.folder.id == "" then + Api.createNewFolder flags (NewFolder name) NewFolderResp else - Api.changeSpaceName flags - model.space.id - (NewSpace name) + Api.changeFolderName flags + model.folder.id + (NewFolder name) ChangeNameResp in ( { model @@ -149,9 +149,9 @@ update flags msg model = Nothing -> ( model, Cmd.none, False ) - NewSpaceResp (Ok ir) -> + NewFolderResp (Ok ir) -> if ir.success then - ( model, Api.getSpaceDetail flags ir.id SpaceDetailResp, False ) + ( model, Api.getFolderDetail flags ir.id FolderDetailResp, False ) else ( { model @@ -162,7 +162,7 @@ update flags msg model = , False ) - NewSpaceResp (Err err) -> + NewFolderResp (Err err) -> ( { model | loading = False , result = Just (BasicResult False (Util.Http.errorToString err)) @@ -171,10 +171,10 @@ update flags msg model = , False ) - ChangeSpaceResp (Ok r) -> + ChangeFolderResp (Ok r) -> if r.success then ( model - , Api.getSpaceDetail flags model.space.id SpaceDetailResp + , Api.getFolderDetail flags model.folder.id FolderDetailResp , False ) @@ -184,7 +184,7 @@ update flags msg model = , False ) - ChangeSpaceResp (Err err) -> + ChangeFolderResp (Err err) -> ( { model | loading = False , result = Just (BasicResult False (Util.Http.errorToString err)) @@ -209,10 +209,10 @@ update flags msg model = , False ) - SpaceDetailResp (Ok sd) -> + FolderDetailResp (Ok sd) -> ( init model.users sd, Cmd.none, False ) - SpaceDetailResp (Err err) -> + FolderDetailResp (Err err) -> ( { model | loading = False , result = Just (BasicResult False (Util.Http.errorToString err)) @@ -225,7 +225,7 @@ update flags msg model = case model.selectedMember of Just mem -> ( { model | loading = True } - , Api.addMember flags model.space.id mem.id ChangeSpaceResp + , Api.addMember flags model.folder.id mem.id ChangeFolderResp , False ) @@ -234,7 +234,7 @@ update flags msg model = RemoveMember idname -> ( { model | loading = True } - , Api.removeMember flags model.space.id idname.id ChangeSpaceResp + , Api.removeMember flags model.folder.id idname.id ChangeFolderResp , False ) @@ -252,7 +252,7 @@ update flags msg model = cmd = if flag then - Api.deleteSpace flags model.space.id DeleteResp + Api.deleteFolder flags model.folder.id DeleteResp else Cmd.none @@ -278,23 +278,23 @@ view flags model = let isOwner = Maybe.map .user flags.account - |> Maybe.map ((==) model.space.owner.name) + |> Maybe.map ((==) model.folder.owner.name) |> Maybe.withDefault False in div [] ([ Html.map DeleteMsg (Comp.YesNoDimmer.view model.deleteDimmer) - , if model.space.id == "" then + , if model.folder.id == "" then div [] - [ text "Create a new space. You are automatically set as owner of this new space." + [ text "Create a new folder. You are automatically set as owner of this new folder." ] else div [] - [ text "Modify this space by changing the name or add/remove members." + [ text "Modify this folder by changing the name or add/remove members." ] - , if model.space.id /= "" && not isOwner then + , if model.folder.id /= "" && not isOwner then div [ class "ui info message" ] - [ text "You are not the owner of this space and therefore are not allowed to edit it." + [ text "You are not the owner of this folder and therefore are not allowed to edit it." ] else @@ -315,7 +315,7 @@ view flags model = [ text "Owner" ] , div [ class "" ] - [ text model.space.owner.name + [ text model.folder.owner.name ] , div [ class "ui header" ] [ text "Name" @@ -361,7 +361,7 @@ viewButtons _ = viewMembers : Model -> List (Html Msg) viewMembers model = - if model.space.id == "" then + if model.folder.id == "" then [] else diff --git a/modules/webapp/src/main/elm/Comp/SpaceManage.elm b/modules/webapp/src/main/elm/Comp/FolderManage.elm similarity index 68% rename from modules/webapp/src/main/elm/Comp/SpaceManage.elm rename to modules/webapp/src/main/elm/Comp/FolderManage.elm index d050d6d6..6ffad0d6 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceManage.elm +++ b/modules/webapp/src/main/elm/Comp/FolderManage.elm @@ -1,4 +1,4 @@ -module Comp.SpaceManage exposing +module Comp.FolderManage exposing ( Model , Msg , empty @@ -8,13 +8,13 @@ module Comp.SpaceManage exposing ) import Api -import Api.Model.SpaceDetail exposing (SpaceDetail) -import Api.Model.SpaceItem exposing (SpaceItem) -import Api.Model.SpaceList exposing (SpaceList) +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.SpaceDetail -import Comp.SpaceTable +import Comp.FolderDetail +import Comp.FolderTable import Data.Flags exposing (Flags) import Html exposing (..) import Html.Attributes exposing (..) @@ -23,9 +23,9 @@ import Http type alias Model = - { tableModel : Comp.SpaceTable.Model - , detailModel : Maybe Comp.SpaceDetail.Model - , spaces : List SpaceItem + { tableModel : Comp.FolderTable.Model + , detailModel : Maybe Comp.FolderDetail.Model + , folders : List FolderItem , users : List User , query : String , owningOnly : Bool @@ -34,21 +34,21 @@ type alias Model = type Msg - = TableMsg Comp.SpaceTable.Msg - | DetailMsg Comp.SpaceDetail.Msg + = TableMsg Comp.FolderTable.Msg + | DetailMsg Comp.FolderDetail.Msg | UserListResp (Result Http.Error UserList) - | SpaceListResp (Result Http.Error SpaceList) - | SpaceDetailResp (Result Http.Error SpaceDetail) + | FolderListResp (Result Http.Error FolderList) + | FolderDetailResp (Result Http.Error FolderDetail) | SetQuery String - | InitNewSpace + | InitNewFolder | ToggleOwningOnly empty : Model empty = - { tableModel = Comp.SpaceTable.init + { tableModel = Comp.FolderTable.init , detailModel = Nothing - , spaces = [] + , folders = [] , users = [] , query = "" , owningOnly = True @@ -61,7 +61,7 @@ init flags = ( empty , Cmd.batch [ Api.getUsers flags UserListResp - , Api.getSpaces flags empty.query empty.owningOnly SpaceListResp + , Api.getFolders flags empty.query empty.owningOnly FolderListResp ] ) @@ -76,14 +76,14 @@ update flags msg model = TableMsg lm -> let ( tm, action ) = - Comp.SpaceTable.update lm model.tableModel + Comp.FolderTable.update lm model.tableModel cmd = case action of - Comp.SpaceTable.EditAction item -> - Api.getSpaceDetail flags item.id SpaceDetailResp + Comp.FolderTable.EditAction item -> + Api.getFolderDetail flags item.id FolderDetailResp - Comp.SpaceTable.NoAction -> + Comp.FolderTable.NoAction -> Cmd.none in ( { model | tableModel = tm }, cmd ) @@ -93,11 +93,11 @@ update flags msg model = Just detail -> let ( dm, dc, back ) = - Comp.SpaceDetail.update flags lm detail + Comp.FolderDetail.update flags lm detail cmd = if back then - Api.getSpaces flags model.query model.owningOnly SpaceListResp + Api.getFolders flags model.query model.owningOnly FolderListResp else Cmd.none @@ -121,7 +121,7 @@ update flags msg model = SetQuery str -> ( { model | query = str } - , Api.getSpaces flags str model.owningOnly SpaceListResp + , Api.getFolders flags str model.owningOnly FolderListResp ) ToggleOwningOnly -> @@ -130,7 +130,7 @@ update flags msg model = not model.owningOnly in ( { model | owningOnly = newOwning } - , Api.getSpaces flags model.query newOwning SpaceListResp + , Api.getFolders flags model.query newOwning FolderListResp ) UserListResp (Ok ul) -> @@ -139,24 +139,24 @@ update flags msg model = UserListResp (Err err) -> ( model, Cmd.none ) - SpaceListResp (Ok sl) -> - ( { model | spaces = sl.items }, Cmd.none ) + FolderListResp (Ok sl) -> + ( { model | folders = sl.items }, Cmd.none ) - SpaceListResp (Err err) -> + FolderListResp (Err err) -> ( model, Cmd.none ) - SpaceDetailResp (Ok sd) -> - ( { model | detailModel = Comp.SpaceDetail.init model.users sd |> Just } + FolderDetailResp (Ok sd) -> + ( { model | detailModel = Comp.FolderDetail.init model.users sd |> Just } , Cmd.none ) - SpaceDetailResp (Err err) -> + FolderDetailResp (Err err) -> ( model, Cmd.none ) - InitNewSpace -> + InitNewFolder -> let sd = - Comp.SpaceDetail.initEmpty model.users + Comp.FolderDetail.initEmpty model.users in ( { model | detailModel = Just sd } , Cmd.none @@ -177,10 +177,10 @@ view flags model = viewTable model -viewDetail : Flags -> Comp.SpaceDetail.Model -> Html Msg +viewDetail : Flags -> Comp.FolderDetail.Model -> Html Msg viewDetail flags detailModel = div [] - [ Html.map DetailMsg (Comp.SpaceDetail.view flags detailModel) + [ Html.map DetailMsg (Comp.FolderDetail.view flags detailModel) ] @@ -209,7 +209,7 @@ viewTable model = , checked model.owningOnly ] [] - , label [] [ text "Show owning spaces only" ] + , label [] [ text "Show owning folders only" ] ] ] , div [ class "right menu" ] @@ -217,15 +217,15 @@ viewTable model = [ a [ class "ui primary button" , href "#" - , onClick InitNewSpace + , onClick InitNewFolder ] [ i [ class "plus icon" ] [] - , text "New Space" + , text "New Folder" ] ] ] ] - , Html.map TableMsg (Comp.SpaceTable.view model.tableModel model.spaces) + , Html.map TableMsg (Comp.FolderTable.view model.tableModel model.folders) , div [ classList [ ( "ui dimmer", True ) diff --git a/modules/webapp/src/main/elm/Comp/SpaceTable.elm b/modules/webapp/src/main/elm/Comp/FolderTable.elm similarity index 88% rename from modules/webapp/src/main/elm/Comp/SpaceTable.elm rename to modules/webapp/src/main/elm/Comp/FolderTable.elm index 19f7fb3e..a44f5e59 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceTable.elm +++ b/modules/webapp/src/main/elm/Comp/FolderTable.elm @@ -1,4 +1,4 @@ -module Comp.SpaceTable exposing +module Comp.FolderTable exposing ( Action(..) , Model , Msg @@ -7,7 +7,7 @@ module Comp.SpaceTable exposing , view ) -import Api.Model.SpaceItem exposing (SpaceItem) +import Api.Model.FolderItem exposing (FolderItem) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick) @@ -20,12 +20,12 @@ type alias Model = type Msg - = EditItem SpaceItem + = EditItem FolderItem type Action = NoAction - | EditAction SpaceItem + | EditAction FolderItem init : Model @@ -40,7 +40,7 @@ update msg model = ( model, EditAction item ) -view : Model -> List SpaceItem -> Html Msg +view : Model -> List FolderItem -> Html Msg view _ items = div [] [ table [ class "ui very basic center aligned table" ] @@ -58,7 +58,7 @@ view _ items = ] -viewItem : SpaceItem -> Html Msg +viewItem : FolderItem -> Html Msg viewItem item = tr [] [ td [ class "collapsing" ] diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index 77badc14..86999931 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -15,12 +15,12 @@ module Data.Icons exposing , editNotesIcon , equipment , equipmentIcon + , folder + , folderIcon , organization , organizationIcon , person , personIcon - , space - , spaceIcon , tag , tagIcon , tags @@ -31,14 +31,14 @@ import Html exposing (Html, i) import Html.Attributes exposing (class) -space : String -space = +folder : String +folder = "folder outline icon" -spaceIcon : String -> Html msg -spaceIcon classes = - i [ class (space ++ " " ++ classes) ] [] +folderIcon : String -> Html msg +folderIcon classes = + i [ class (folder ++ " " ++ classes) ] [] concerned : String diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm index 8ab230a4..69178dac 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Data.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Data.elm @@ -6,9 +6,9 @@ module Page.ManageData.Data exposing ) import Comp.EquipmentManage +import Comp.FolderManage import Comp.OrgManage import Comp.PersonManage -import Comp.SpaceManage import Comp.TagManage import Data.Flags exposing (Flags) @@ -19,7 +19,7 @@ type alias Model = , equipManageModel : Comp.EquipmentManage.Model , orgManageModel : Comp.OrgManage.Model , personManageModel : Comp.PersonManage.Model - , spaceManageModel : Comp.SpaceManage.Model + , folderManageModel : Comp.FolderManage.Model } @@ -30,7 +30,7 @@ init _ = , equipManageModel = Comp.EquipmentManage.emptyModel , orgManageModel = Comp.OrgManage.emptyModel , personManageModel = Comp.PersonManage.emptyModel - , spaceManageModel = Comp.SpaceManage.empty + , folderManageModel = Comp.FolderManage.empty } , Cmd.none ) @@ -41,7 +41,7 @@ type Tab | EquipTab | OrgTab | PersonTab - | SpaceTab + | FolderTab type Msg @@ -50,4 +50,4 @@ type Msg | EquipManageMsg Comp.EquipmentManage.Msg | OrgManageMsg Comp.OrgManage.Msg | PersonManageMsg Comp.PersonManage.Msg - | SpaceMsg Comp.SpaceManage.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 d50f0042..f229e2ad 100644 --- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm +++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm @@ -1,9 +1,9 @@ module Page.ManageData.Update exposing (update) import Comp.EquipmentManage +import Comp.FolderManage import Comp.OrgManage import Comp.PersonManage -import Comp.SpaceManage import Comp.TagManage import Data.Flags exposing (Flags) import Page.ManageData.Data exposing (..) @@ -30,12 +30,12 @@ update flags msg model = PersonTab -> update flags (PersonManageMsg Comp.PersonManage.LoadPersons) m - SpaceTab -> + FolderTab -> let ( sm, sc ) = - Comp.SpaceManage.init flags + Comp.FolderManage.init flags in - ( { m | spaceManageModel = sm }, Cmd.map SpaceMsg sc ) + ( { m | folderManageModel = sm }, Cmd.map FolderMsg sc ) TagManageMsg m -> let @@ -65,11 +65,11 @@ update flags msg model = in ( { model | personManageModel = m2 }, Cmd.map PersonManageMsg c2 ) - SpaceMsg lm -> + FolderMsg lm -> let ( m2, c2 ) = - Comp.SpaceManage.update flags lm model.spaceManageModel + Comp.FolderManage.update flags lm model.folderManageModel in - ( { model | spaceManageModel = m2 } - , Cmd.map SpaceMsg c2 + ( { 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 0829c920..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,9 @@ module Page.ManageData.View exposing (view) import Comp.EquipmentManage +import Comp.FolderManage import Comp.OrgManage import Comp.PersonManage -import Comp.SpaceManage import Comp.TagManage import Data.Flags exposing (Flags) import Data.Icons as Icons @@ -53,11 +53,11 @@ view flags settings model = , text "Person" ] , div - [ classActive (model.currentTab == Just SpaceTab) "link icon item" - , onClick (SetTab SpaceTab) + [ classActive (model.currentTab == Just FolderTab) "link icon item" + , onClick (SetTab FolderTab) ] - [ Icons.spaceIcon "" - , text "Space" + [ Icons.folderIcon "" + , text "Folder" ] ] ] @@ -77,8 +77,8 @@ view flags settings model = Just PersonTab -> viewPerson settings model - Just SpaceTab -> - viewSpace flags settings model + Just FolderTab -> + viewFolder flags settings model Nothing -> [] @@ -87,19 +87,19 @@ view flags settings model = ] -viewSpace : Flags -> UiSettings -> Model -> List (Html Msg) -viewSpace flags _ model = +viewFolder : Flags -> UiSettings -> Model -> List (Html Msg) +viewFolder flags _ model = [ h2 [ class "ui header" ] - [ Icons.spaceIcon "" + [ Icons.folderIcon "" , div [ class "content" ] - [ text "Spaces" + [ text "Folders" ] ] - , Html.map SpaceMsg (Comp.SpaceManage.view flags model.spaceManageModel) + , Html.map FolderMsg (Comp.FolderManage.view flags model.folderManageModel) ] From 5bde78083a3ef25f87218a4890c0d076eae5af73 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 11:43:15 +0200 Subject: [PATCH 12/27] Hide delete button when creating new folder --- modules/webapp/src/main/elm/Comp/FolderDetail.elm | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/FolderDetail.elm b/modules/webapp/src/main/elm/Comp/FolderDetail.elm index f3e44abc..6bf3aac7 100644 --- a/modules/webapp/src/main/elm/Comp/FolderDetail.elm +++ b/modules/webapp/src/main/elm/Comp/FolderDetail.elm @@ -342,7 +342,7 @@ view flags model = viewButtons : Model -> List (Html Msg) -viewButtons _ = +viewButtons model = [ div [ class "ui divider" ] [] , button [ class "ui button" @@ -351,7 +351,10 @@ viewButtons _ = [ text "Back" ] , button - [ class "ui red button" + [ classList + [ ( "ui red button", True ) + , ( "invisible hidden", model.folder.id == "" ) + ] , onClick RequestDelete ] [ text "Delete" From 86443e10a6dbf008dc3d2bb60e7aa9e505a8e492 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 12:00:19 +0200 Subject: [PATCH 13/27] Set the folder of an item --- .../scala/docspell/backend/ops/OItem.scala | 12 +++ .../src/main/resources/docspell-openapi.yml | 27 +++++++ .../restserver/conv/Conversions.scala | 1 + .../restserver/routes/ItemRoutes.scala | 7 ++ .../scala/docspell/store/queries/QItem.scala | 10 ++- .../scala/docspell/store/records/RItem.scala | 14 +++- modules/webapp/src/main/elm/Api.elm | 11 +++ .../webapp/src/main/elm/Comp/ItemDetail.elm | 79 ++++++++++++++++++- 8 files changed, 155 insertions(+), 6 deletions(-) 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..f51e7bd3 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,16 @@ 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) + def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] = store .transact(RItem.updateCorrOrg(item, collective, org)) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index bcdeef5c..4a94d772 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1365,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 ] @@ -3167,6 +3192,8 @@ components: $ref: "#/components/schemas/IdName" inReplyTo: $ref: "#/components/schemas/IdName" + folder: + $ref: "#/components/schemas/IdName" dueDate: type: integer format: date-time 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 271fae25..4f093c40 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -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, 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..d32ba6b8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -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/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 84c81e9a..657f10ec 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) ) } 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 b0c197ce..ea40ec30 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -82,7 +82,7 @@ object RItem { val created = Column("created") val updated = Column("updated") val notes = Column("notes") - val folder = Column("folder_id") + val folder = Column("folder_id") val all = List( id, cid, @@ -243,7 +243,17 @@ 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 diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index b23fa1dd..2934547d 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -77,6 +77,7 @@ module Api exposing , setCorrOrg , setCorrPerson , setDirection + , setFolder , setItemDate , setItemDueDate , setItemName @@ -1262,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/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 718e5d80..67127543 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) @@ -71,6 +73,7 @@ type alias Model = , corrPersonModel : Comp.Dropdown.Model IdName , concPersonModel : Comp.Dropdown.Model IdName , concEquipModel : Comp.Dropdown.Model IdName + , folderModel : Comp.Dropdown.Model IdName , nameModel : String , notesModel : Maybe String , notesField : NotesField @@ -165,6 +168,11 @@ emptyModel = { makeOption = \e -> { value = e.id, text = e.name } , placeholder = "" } + , folderModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name } + , placeholder = "" + } , nameModel = "" , notesModel = Nothing , notesField = ViewNotes @@ -268,6 +276,8 @@ type Msg | EditAttachNameSet String | EditAttachNameSubmit | EditAttachNameResp (Result Http.Error BasicResult) + | GetFolderResp (Result Http.Error FolderList) + | FolderDropdownMsg (Comp.Dropdown.Msg IdName) @@ -281,6 +291,7 @@ getOptions flags = , Api.getOrgLight flags GetOrgResp , Api.getPersonsLight flags GetPersonResp , Api.getEquipments flags "" GetEquipResp + , Api.getFolders flags "" False GetFolderResp ] @@ -310,6 +321,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 +544,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 +565,7 @@ update key flags next msg model = else Cmd.none in - ( { m7 + ( { m8 | item = item , nameModel = item.name , notesModel = item.notes @@ -548,11 +583,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 +611,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 +883,18 @@ update key flags next msg model = SetDueDateSuggestion date -> noSub ( model, setDueDate flags model (Just date) ) + GetFolderResp (Ok fs) -> + let + opts = + fs.items + |> List.map (\e -> IdName e.id e.name) + |> Comp.Dropdown.SetOptions + in + update key flags next (FolderDropdownMsg opts) model + + GetFolderResp (Err _) -> + noSub ( model, Cmd.none ) + GetTagsResp (Ok tags) -> let tagList = @@ -2082,6 +2150,13 @@ renderEditForm settings model = ] ] ] + , div [ class "field" ] + [ label [] + [ Icons.folderIcon "grey" + , text "Folder" + ] + , Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel) + ] , div [ class "field" ] [ label [] [ Icons.directionIcon "grey" From 0df541f30a5a66f749967a6fd768059090a327ce Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 16:52:13 +0200 Subject: [PATCH 14/27] Allow to search by folders --- .../src/main/resources/docspell-openapi.yml | 5 +++ .../restserver/conv/Conversions.scala | 2 + .../scala/docspell/store/queries/QItem.scala | 26 +++++++++--- .../webapp/src/main/elm/Comp/ItemCardList.elm | 13 +++++- .../webapp/src/main/elm/Comp/SearchMenu.elm | 42 +++++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 4a94d772..5102aa97 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3776,6 +3776,9 @@ components: concEquip: type: string format: ident + folder: + type: string + format: ident dateFrom: type: integer format: date-time @@ -3829,6 +3832,8 @@ components: $ref: "#/components/schemas/IdName" concEquip: $ref: "#/components/schemas/IdName" + folder: + $ref: "#/components/schemas/IdName" fileCount: type: integer format: int32 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 4f093c40..d899ea06 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -121,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, @@ -193,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 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 657f10ec..a40f11d2 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -155,7 +155,8 @@ object QItem { corrOrg: Option[IdRef], corrPerson: Option[IdRef], concPerson: Option[IdRef], - concEquip: Option[IdRef] + concEquip: Option[IdRef], + folder: Option[IdRef] ) case class Query( @@ -167,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], @@ -189,6 +191,7 @@ object QItem { None, None, None, + None, Nil, Nil, None, @@ -233,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( @@ -257,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) @@ -270,6 +277,8 @@ object QItem { 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 withFolder = + selectSimple(folderCols, RFolder.table, FC.collective.is(q.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")" @@ -280,7 +289,8 @@ object QItem { "persons" -> withPerson, "orgs" -> withOrgs, "equips" -> withEquips, - "attachs" -> withAttach + "attachs" -> withAttach, + "folders" -> withFolder ) ++ ctes): _* ) ++ selectKW ++ finalCols ++ fr" FROM items i" ++ @@ -288,7 +298,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")) + 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")) query } @@ -346,6 +359,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 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/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 5e1b93d9..f7539d61 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 @@ -103,6 +105,14 @@ init = , labelColor = \_ -> \_ -> "" , placeholder = "Choose an equipment" } + , folderModel = + Comp.Dropdown.makeModel + { multiple = False + , searchable = \n -> n > 5 + , makeOption = \e -> { value = e.id, text = e.name } + , labelColor = \_ -> \_ -> "" + , placeholder = "Only items in folder" + } , inboxCheckbox = False , fromDateModel = Comp.DatePicker.emptyModel , fromDate = Nothing @@ -144,6 +154,8 @@ type Msg | ResetForm | KeyUpMsg (Maybe KeyCode) | ToggleNameHelp + | FolderMsg (Comp.Dropdown.Msg IdName) + | GetFolderResp (Result Http.Error FolderList) getDirection : Model -> Maybe Direction @@ -184,6 +196,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 +263,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 +527,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 +666,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)" ] From e66c5010563dbd511b7bc4cef95800081e259c4d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 17:45:45 +0200 Subject: [PATCH 15/27] Extend dropdown to display additional option info Use this to display folder information when setting the folder on an item. --- .../webapp/src/main/elm/Comp/AddressForm.elm | 2 +- .../main/elm/Comp/CollectiveSettingsForm.elm | 1 + .../webapp/src/main/elm/Comp/ContactField.elm | 1 + modules/webapp/src/main/elm/Comp/Dropdown.elm | 16 +++ .../src/main/elm/Comp/EmailSettingsForm.elm | 14 +- .../src/main/elm/Comp/ImapSettingsForm.elm | 14 +- .../webapp/src/main/elm/Comp/ItemDetail.elm | 136 ++++++++++++++---- modules/webapp/src/main/elm/Comp/ItemMail.elm | 4 +- .../src/main/elm/Comp/NotificationForm.elm | 4 +- .../src/main/elm/Comp/ScanMailboxForm.elm | 4 +- .../webapp/src/main/elm/Comp/SearchMenu.elm | 11 +- modules/webapp/src/main/elm/Comp/UserForm.elm | 8 +- modules/webapp/src/main/elm/Util/Tag.elm | 2 +- 13 files changed, 171 insertions(+), 46 deletions(-) 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/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/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 67127543..f36ca77c 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -74,6 +74,7 @@ type alias Model = , 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 @@ -130,6 +131,36 @@ isEditNotes field = False +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 } + + emptyModel : Model emptyModel = { item = Api.Model.ItemDetail.empty @@ -143,6 +174,7 @@ emptyModel = \entry -> { value = Data.Direction.toString entry , text = Data.Direction.toString entry + , additional = "" } , options = Data.Direction.all , placeholder = "Choose a direction…" @@ -150,29 +182,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 } + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } , placeholder = "" } + , allFolders = [] , nameModel = "" , notesModel = Nothing , notesField = ViewNotes @@ -885,12 +918,24 @@ update key flags next msg model = 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 (\e -> IdName e.id e.name) + |> List.map mkIdName |> Comp.Dropdown.SetOptions in - update key flags next (FolderDropdownMsg opts) model + update key flags next (FolderDropdownMsg opts) model_ GetFolderResp (Err _) -> noSub ( model, Cmd.none ) @@ -1450,29 +1495,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 ) @@ -2129,7 +2178,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" @@ -2156,6 +2205,18 @@ renderEditForm settings model = , 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 [] @@ -2482,3 +2543,22 @@ 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 + + findFolder id = + List.filter (\e -> e.id == id) model.allFolders + |> List.head + + folder = + Maybe.andThen findFolder selected + in + Maybe.map .isMember folder + |> Maybe.withDefault True 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..8b2f2c84 100644 --- a/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm +++ b/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm @@ -129,7 +129,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 @@ -260,7 +260,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/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index f7539d61..b3411b28 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -74,6 +74,7 @@ init = \entry -> { value = Data.Direction.toString entry , text = Data.Direction.toString entry + , additional = "" } , options = Data.Direction.all , placeholder = "Choose a direction…" @@ -83,25 +84,25 @@ 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" } @@ -109,7 +110,7 @@ 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 = "Only items in folder" } 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/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 -> From 5b95fddf3d47d525a47c0f44b3e3e1283ff664cf Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 21:54:51 +0200 Subject: [PATCH 16/27] Make item queries depend on the account-id Now the user is required, too, to list items. --- .../docspell/backend/ops/OFulltext.scala | 12 +++++------ .../docspell/backend/ops/OItemSearch.scala | 2 +- .../restserver/conv/Conversions.scala | 4 ++-- .../restserver/routes/ItemRoutes.scala | 6 +++--- .../scala/docspell/store/queries/QItem.scala | 20 +++++++++---------- 5 files changed, 22 insertions(+), 22 deletions(-) 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..5e32ad5d 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala @@ -30,7 +30,7 @@ trait OFulltext[F[_]] { def findIndexOnly( fts: OFulltext.FtsInput, - collective: Ident, + account: AccountId, batch: Batch ): F[Vector[OFulltext.FtsItemWithTags]] @@ -94,12 +94,12 @@ 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, batch.limit, batch.offset, @@ -113,8 +113,8 @@ object OFulltext { 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 +182,7 @@ object OFulltext { val sqlResult = search(q, batch) val fq = FtsQuery( ftsQ.query, - q.collective, + q.account.collective, Set.empty, 0, 0, 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/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index d899ea06..fa94c30b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -110,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, 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 d32ba6b8..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)) 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 a40f11d2..e4618772 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -160,7 +160,7 @@ object QItem { ) case class Query( - collective: Ident, + account: AccountId, name: Option[String], states: Seq[ItemState], direction: Option[Direction], @@ -181,9 +181,9 @@ object QItem { ) object Query { - def empty(collective: Ident): Query = + def empty(account: AccountId): Query = Query( - collective, + account, None, Seq.empty, None, @@ -273,12 +273,12 @@ 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.collective)) + 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")" @@ -337,7 +337,7 @@ object QItem { 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 @@ -476,7 +476,7 @@ 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") From e387b5513f85f26c291365482d51ead21ff1c8af Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jul 2020 22:16:09 +0200 Subject: [PATCH 17/27] Remove items in non-member folders from sql search results --- .../joex/notify/NotifyDueItemsTask.scala | 2 +- .../docspell/store/queries/QFolder.scala | 26 +++++++++++++++++++ .../scala/docspell/store/queries/QItem.scala | 23 ++++++++++------ 3 files changed, 42 insertions(+), 9 deletions(-) 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/store/src/main/scala/docspell/store/queries/QFolder.scala b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala index 1495d1b0..8f8b50a8 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QFolder.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala @@ -244,6 +244,32 @@ object QFolder { .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)) + ) + } + 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 e4618772..99415125 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -273,17 +273,20 @@ object QItem { ) ++ moreCols ) - 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 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, @@ -302,7 +305,6 @@ object QItem { .prefix("i") .is(EC.eid.prefix("e1")) ++ fr"LEFT JOIN folders f1 ON" ++ IC.folder.prefix("i").is(FC.id.prefix("f1")) - query } def findItems(q: Query, batch: Batch): Stream[ConnectionIO, ListItem] = { @@ -334,6 +336,7 @@ 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( @@ -385,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 { @@ -476,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]): Fragment = { + 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") From aeba4ba9137af6e0357f33a35d0b5ba375626b2f Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 12 Jul 2020 00:30:37 +0200 Subject: [PATCH 18/27] Refactor full-text migrations and add folder to solr schema --- .../scala/docspell/ftsclient/FtsClient.scala | 9 ++--- .../docspell/ftsclient/FtsMigration.scala | 24 +++++++++++++ .../main/scala/docspell/ftssolr/Field.scala | 1 + .../docspell/ftssolr/SolrFtsClient.scala | 2 +- .../scala/docspell/ftssolr/SolrSetup.scala | 32 +++++++++++++++-- .../scala/docspell/joex/fts/FtsWork.scala | 35 +++++++++++++++---- .../scala/docspell/joex/fts/Migration.scala | 5 ++- .../docspell/joex/fts/MigrationTask.scala | 10 ++---- .../scala/docspell/joex/fts/ReIndexTask.scala | 14 +++----- 9 files changed, 102 insertions(+), 30 deletions(-) create mode 100644 modules/fts-client/src/main/scala/docspell/ftsclient/FtsMigration.scala 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..b3bdcf9a 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] @@ -107,8 +108,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] 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-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/SolrFtsClient.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala index 635c0d97..c0994328 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] = 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/joex/src/main/scala/docspell/joex/fts/FtsWork.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala index a4952271..fc3c77b3 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.data.{Kleisli, NonEmptyList} -import cats.effect._ import cats.implicits._ -import cats.{ApplicativeError, FlatMap, Semigroup} +import cats.{Applicative, ApplicativeError, FlatMap, Monad, 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 => 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..40c5bf4a 100644 --- a/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala +++ b/modules/joex/src/main/scala/docspell/joex/fts/Migration.scala @@ -1,6 +1,6 @@ package docspell.joex.fts -import cats.Traverse +import cats.{Applicative, FlatMap, Traverse} import cats.data.{Kleisli, OptionT} import cats.effect._ import cats.implicits._ @@ -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] }) } From 22fa1dba13ddeed8f466489a3e4d48425d184f37 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 12 Jul 2020 13:44:11 +0200 Subject: [PATCH 19/27] Apply folder restriction to fulltext only search And update index when folder changes. --- .../docspell/backend/ops/OFulltext.scala | 7 +++- .../scala/docspell/backend/ops/OItem.scala | 3 ++ .../scala/docspell/ftsclient/FtsClient.scala | 20 +++++++++- .../scala/docspell/ftsclient/FtsQuery.scala | 8 ++++ .../scala/docspell/ftsclient/TextData.scala | 10 ++++- .../scala/docspell/ftssolr/DocIdResult.scala | 9 +++++ .../scala/docspell/ftssolr/JsonCodec.scala | 39 ++++++++++++++++++- .../scala/docspell/ftssolr/QueryData.scala | 30 +++++++++----- .../scala/docspell/ftssolr/SetFolder.scala | 5 +++ .../docspell/ftssolr/SolrFtsClient.scala | 11 ++++++ .../scala/docspell/ftssolr/SolrUpdate.scala | 28 +++++++++++++ .../scala/docspell/joex/fts/FtsWork.scala | 5 ++- .../joex/process/TextExtraction.scala | 10 ++++- .../docspell/store/queries/QAttachment.scala | 4 +- .../docspell/store/queries/QFolder.scala | 3 ++ .../scala/docspell/store/queries/QItem.scala | 12 +++--- .../scala/docspell/store/records/RItem.scala | 6 ++- 17 files changed, 183 insertions(+), 27 deletions(-) create mode 100644 modules/fts-solr/src/main/scala/docspell/ftssolr/DocIdResult.scala create mode 100644 modules/fts-solr/src/main/scala/docspell/ftssolr/SetFolder.scala 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 5e32ad5d..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 @@ -101,12 +101,14 @@ object OFulltext { ftsQ.query, 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 <- @@ -184,6 +186,7 @@ object OFulltext { ftsQ.query, q.account.collective, Set.empty, + Set.empty, 0, 0, FtsQuery.HighlightSetting(ftsQ.highlightPre, ftsQ.highlightPost) 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 f51e7bd3..d17b453b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -142,6 +142,9 @@ object OItem { .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 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 b3bdcf9a..dcf2d88f 100644 --- a/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala +++ b/modules/fts-client/src/main/scala/docspell/ftsclient/FtsClient.scala @@ -58,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], @@ -68,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( @@ -84,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] @@ -117,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/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/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 c0994328..f8f7fd3b 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrFtsClient.scala @@ -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/SolrUpdate.scala b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala index 88089d51..616f7b16 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala @@ -1,7 +1,9 @@ package docspell.ftssolr import cats.effect._ +import cats.implicits._ +import docspell.common._ import docspell.ftsclient._ import docspell.ftssolr.JsonCodec._ @@ -11,6 +13,7 @@ import org.http4s._ import org.http4s.circe._ import org.http4s.client.Client import org.http4s.client.dsl.Http4sClientDsl +import org.http4s.circe.CirceEntityDecoder._ trait SolrUpdate[F[_]] { @@ -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/fts/FtsWork.scala b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala index fc3c77b3..43d34ae4 100644 --- a/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala +++ b/modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala @@ -80,6 +80,7 @@ object FtsWork { caa.item, caa.id, caa.collective, + caa.folder, caa.lang, caa.name, caa.content @@ -92,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/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/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 index 8f8b50a8..e613f6e9 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QFolder.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala @@ -270,6 +270,9 @@ object QFolder { ) } + 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 99415125..bc6dc7ce 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -585,6 +585,7 @@ object QItem { final case class NameAndNotes( id: Ident, collective: Ident, + folder: Option[Ident], name: String, notes: Option[String] ) @@ -592,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/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index ea40ec30..97b87d84 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -247,7 +247,11 @@ object RItem { ).update.run } yield n - def updateFolder(itemId: Ident, coll: Ident, folderId: Option[Ident]): ConnectionIO[Int] = + def updateFolder( + itemId: Ident, + coll: Ident, + folderId: Option[Ident] + ): ConnectionIO[Int] = for { t <- currentTime n <- updateRow( From 259526a088556c6a2b0ba363086132888a5e681b Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 12 Jul 2020 13:51:52 +0200 Subject: [PATCH 20/27] Organize imports --- .../backend/src/main/scala/docspell/backend/ops/OFolder.scala | 4 ++-- .../fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala | 2 +- modules/joex/src/main/scala/docspell/joex/fts/FtsWork.scala | 2 +- modules/joex/src/main/scala/docspell/joex/fts/Migration.scala | 2 +- .../store/src/main/scala/docspell/store/UpdateResult.scala | 2 +- .../store/src/main/scala/docspell/store/records/RFolder.scala | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala index e93b7d5d..41576378 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OFolder.scala @@ -3,9 +3,9 @@ package docspell.backend.ops import cats.effect._ import docspell.common._ -import docspell.store.{AddResult, Store} -import docspell.store.records.{RFolder, RUser} import docspell.store.queries.QFolder +import docspell.store.records.{RFolder, RUser} +import docspell.store.{AddResult, Store} trait OFolder[F[_]] { 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 616f7b16..b5b5e642 100644 --- a/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala +++ b/modules/fts-solr/src/main/scala/docspell/ftssolr/SolrUpdate.scala @@ -10,10 +10,10 @@ 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 -import org.http4s.circe.CirceEntityDecoder._ trait SolrUpdate[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 43d34ae4..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,8 +1,8 @@ package docspell.joex.fts +import cats._ import cats.data.{Kleisli, NonEmptyList} import cats.implicits._ -import cats.{Applicative, ApplicativeError, FlatMap, Monad, Semigroup} import docspell.common._ import docspell.ftsclient._ 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 40c5bf4a..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.{Applicative, FlatMap, Traverse} import cats.data.{Kleisli, OptionT} import cats.effect._ import cats.implicits._ +import cats.{Applicative, FlatMap, Traverse} import docspell.common._ import docspell.ftsclient._ diff --git a/modules/store/src/main/scala/docspell/store/UpdateResult.scala b/modules/store/src/main/scala/docspell/store/UpdateResult.scala index 6ea55842..09ae064a 100644 --- a/modules/store/src/main/scala/docspell/store/UpdateResult.scala +++ b/modules/store/src/main/scala/docspell/store/UpdateResult.scala @@ -1,7 +1,7 @@ package docspell.store -import cats.implicits._ import cats.ApplicativeError +import cats.implicits._ sealed trait UpdateResult diff --git a/modules/store/src/main/scala/docspell/store/records/RFolder.scala b/modules/store/src/main/scala/docspell/store/records/RFolder.scala index 47401984..0b3b0ebb 100644 --- a/modules/store/src/main/scala/docspell/store/records/RFolder.scala +++ b/modules/store/src/main/scala/docspell/store/records/RFolder.scala @@ -2,6 +2,7 @@ package docspell.store.records import cats.effect._ import cats.implicits._ + import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ From ec7f027b4e134f0e5584358ff0f695abb40c7873 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 12 Jul 2020 16:15:02 +0200 Subject: [PATCH 21/27] Fix postgres changeset for folders --- .../db/migration/postgresql/V1.8.0__folders.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 19fdd8a3..0eec9067 100644 --- 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 @@ -21,3 +21,14 @@ CREATE TABLE "folder_member" ( 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"); From 5b01c93711c7e15087bffa4ce0fb3fe3cc6b860c Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 14 Jul 2020 21:25:44 +0200 Subject: [PATCH 22/27] Add a folder-id to item processing This allows to define a folder when uploading files. All generated items are associated to this folder on creation. --- .../scala/docspell/backend/ops/OUpload.scala | 7 +++- .../docspell/common/ProcessItemArgs.scala | 1 + .../docspell/common/ScanMailboxArgs.scala | 4 ++- .../scala/docspell/joex/JoexAppImpl.scala | 3 +- .../docspell/joex/process/ItemHandler.scala | 9 +++-- .../docspell/joex/process/ProcessItem.scala | 3 ++ .../docspell/joex/process/SetGivenData.scala | 35 +++++++++++++++++++ .../joex/scanmailbox/ScanMailboxTask.scala | 11 +++--- modules/microsite/docs/doc/uploading.md | 6 ++++ .../src/main/resources/docspell-openapi.yml | 25 +++++++++++-- .../restserver/conv/Conversions.scala | 22 +++++++++--- .../restserver/routes/ScanMailboxRoutes.scala | 6 ++-- .../docspell/store/queries/QFolder.scala | 1 + .../docspell/store/records/RSource.scala | 17 ++++++--- 14 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala 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/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/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/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 5102aa97..02a673cc 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -2694,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. @@ -3437,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: @@ -3449,6 +3462,9 @@ components: direction: type: string format: direction + folder: + type: string + format: ident Collective: description: | Information about a collective. @@ -3519,6 +3535,9 @@ components: priority: type: string format: priority + folder: + type: string + format: ident created: description: DateTime type: integer 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 fa94c30b..7c57b5e3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -287,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"))) @@ -491,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 = @@ -508,7 +519,8 @@ trait Conversions { s.counter, s.enabled, s.priority, - s.created + s.created, + s.folder ) // equipment 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/scala/docspell/store/queries/QFolder.scala b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala index e613f6e9..9c922d48 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QFolder.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QFolder.scala @@ -50,6 +50,7 @@ object QFolder { def tryDelete = for { _ <- RItem.removeFolder(id) + _ <- RSource.removeFolder(id) _ <- RFolderMember.deleteAll(id) _ <- RFolder.delete(id) } yield FolderChangeResult.success 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 + } } From ca5b7b999ff250c8b285485564a6ee898169d1b6 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 14 Jul 2020 22:38:11 +0200 Subject: [PATCH 23/27] Update source form to specify folder --- modules/webapp/src/main/elm/App/Data.elm | 6 +- .../webapp/src/main/elm/Comp/ItemDetail.elm | 41 +---- .../webapp/src/main/elm/Comp/SourceForm.elm | 157 ++++++++++++++++-- .../webapp/src/main/elm/Comp/SourceManage.elm | 39 +++-- .../main/elm/Page/CollectiveSettings/Data.elm | 27 +-- .../main/elm/Page/CollectiveSettings/View.elm | 8 +- modules/webapp/src/main/elm/Util/Folder.elm | 53 ++++++ 7 files changed, 245 insertions(+), 86 deletions(-) create mode 100644 modules/webapp/src/main/elm/Util/Folder.elm diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index 58660587..ba9fe730 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -60,6 +60,9 @@ init key url flags settings = ( mdm, mdc ) = Page.ManageData.Data.init flags + + ( csm, csc ) = + Page.CollectiveSettings.Data.init flags in ( { flags = flags , key = key @@ -68,7 +71,7 @@ init key url flags settings = , homeModel = Page.Home.Data.init flags , loginModel = Page.Login.Data.emptyModel , manageDataModel = mdm - , collSettingsModel = Page.CollectiveSettings.Data.emptyModel + , collSettingsModel = csm , userSettingsModel = um , queueModel = Page.Queue.Data.emptyModel , registerModel = Page.Register.Data.emptyModel @@ -82,6 +85,7 @@ init key url flags settings = , Cmd.batch [ Cmd.map UserSettingsMsg uc , Cmd.map ManageDataMsg mdc + , Cmd.map CollSettingsMsg csc ] ) diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index f36ca77c..78a65c44 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -54,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 @@ -131,36 +132,6 @@ isEditNotes field = False -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 } - - emptyModel : Model emptyModel = { item = Api.Model.ItemDetail.empty @@ -2552,13 +2523,5 @@ isFolderMember model = Comp.Dropdown.getSelected model.folderModel |> List.head |> Maybe.map .id - - findFolder id = - List.filter (\e -> e.id == id) model.allFolders - |> List.head - - folder = - Maybe.andThen findFolder selected in - Maybe.map .isMember folder - |> Maybe.withDefault True + Util.Folder.isFolderMember model.allFolders selected 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/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/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 From 225877a40cc02329f715c35670eb156a2d8c884c Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 14 Jul 2020 23:10:58 +0200 Subject: [PATCH 24/27] Show folder in item detail view --- modules/webapp/src/main/elm/Comp/ItemDetail.elm | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 78a65c44..4b3351ea 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -2027,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" @@ -2060,6 +2071,7 @@ renderItemInfo settings model = [ date , corr , conc + , itemfolder , src ] (if Util.Maybe.isEmpty model.item.dueDate then From 25538d6a594b75d82b3f7e955576360b443bbb9d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 14 Jul 2020 23:11:13 +0200 Subject: [PATCH 25/27] Allow to set a folder when importing mailboxes --- .../src/main/elm/Comp/ScanMailboxForm.elm | 139 +++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm b/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm index 8b2f2c84..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 ] ) @@ -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 @@ -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 From d277f99feef1601beb63f2e1304e8bf21890c203 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 14 Jul 2020 23:18:07 +0200 Subject: [PATCH 26/27] Add sbt alias for reformatting --- build.sbt | 1 + 1 file changed, 1 insertion(+) 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") From c6975015719a795471e2665555382a8e2994049b Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 14 Jul 2020 23:22:52 +0200 Subject: [PATCH 27/27] Add folders sql changeset for mariadb --- .../db/migration/mariadb/V1.8.0__folders.sql | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.8.0__folders.sql 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`);