mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-05 10:59:33 +00:00
Add and change custom fields
This commit is contained in:
parent
248ad04dd0
commit
62313ab03a
@ -1,25 +1,80 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.effect.{Effect, Resource}
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
|
||||
import docspell.backend.ops.OCustomFields.CustomFieldData
|
||||
import docspell.backend.ops.OCustomFields.NewCustomField
|
||||
import docspell.common._
|
||||
import docspell.store.AddResult
|
||||
import docspell.store.Store
|
||||
import docspell.store.UpdateResult
|
||||
import docspell.store.queries.QCustomField
|
||||
import docspell.store.records.RCustomField
|
||||
import docspell.store.records.RCustomFieldValue
|
||||
|
||||
import doobie._
|
||||
|
||||
trait OCustomFields[F[_]] {
|
||||
|
||||
def findAll(coll: Ident): F[Vector[RCustomField]]
|
||||
def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]]
|
||||
|
||||
def findById(coll: Ident, fieldId: Ident): F[Option[CustomFieldData]]
|
||||
|
||||
def create(field: NewCustomField): F[AddResult]
|
||||
|
||||
def change(field: RCustomField): F[UpdateResult]
|
||||
|
||||
def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult]
|
||||
}
|
||||
|
||||
object OCustomFields {
|
||||
|
||||
type CustomFieldData = QCustomField.CustomFieldData
|
||||
val CustomFieldData = QCustomField.CustomFieldData
|
||||
|
||||
case class NewCustomField(
|
||||
name: Ident,
|
||||
label: Option[String],
|
||||
ftype: CustomFieldType,
|
||||
cid: Ident
|
||||
)
|
||||
|
||||
def apply[F[_]: Effect](
|
||||
store: Store[F]
|
||||
): Resource[F, OCustomFields[F]] =
|
||||
Resource.pure[F, OCustomFields[F]](new OCustomFields[F] {
|
||||
|
||||
def findAll(coll: Ident): F[Vector[RCustomField]] =
|
||||
store.transact(RCustomField.findAll(coll))
|
||||
def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] =
|
||||
store.transact(QCustomField.findAllLike(coll, nameQuery))
|
||||
|
||||
def findById(coll: Ident, field: Ident): F[Option[CustomFieldData]] =
|
||||
store.transact(QCustomField.findById(field, coll))
|
||||
|
||||
def create(field: NewCustomField): F[AddResult] = {
|
||||
val exists = RCustomField.exists(field.name, field.cid)
|
||||
val insert = for {
|
||||
id <- Ident.randomId[ConnectionIO]
|
||||
now <- Timestamp.current[ConnectionIO]
|
||||
rec = RCustomField(id, field.name, field.label, field.cid, field.ftype, now)
|
||||
n <- RCustomField.insert(rec)
|
||||
} yield n
|
||||
|
||||
store.add(insert, exists)
|
||||
}
|
||||
|
||||
def change(field: RCustomField): F[UpdateResult] =
|
||||
UpdateResult.fromUpdate(store.transact(RCustomField.update(field)))
|
||||
|
||||
def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult] = {
|
||||
val update =
|
||||
for {
|
||||
field <- OptionT(RCustomField.findByIdOrName(fieldIdOrName, coll))
|
||||
n <- OptionT.liftF(RCustomFieldValue.deleteByField(field.id))
|
||||
k <- OptionT.liftF(RCustomField.deleteById(field.id, coll))
|
||||
} yield n + k
|
||||
|
||||
UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -42,7 +42,6 @@ object CustomFieldType {
|
||||
def unsafe(str: String): CustomFieldType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
|
||||
implicit val jsonDecoder: Decoder[CustomFieldType] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
|
||||
|
@ -3210,6 +3210,8 @@ paths:
|
||||
Get all custom fields defined for the current collective.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/q"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
@ -3217,6 +3219,79 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CustomFieldList"
|
||||
post:
|
||||
tags: [ Custom Fields ]
|
||||
summary: Create a new custom field
|
||||
description: |
|
||||
Creates a new custom field.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NewCustomField"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/customfields/{id}:
|
||||
get:
|
||||
tags: [ Custom Fields ]
|
||||
summary: Get details about a custom field.
|
||||
description: |
|
||||
Returns the details about a custom field.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CustomField"
|
||||
put:
|
||||
tags: [ Custom Fields ]
|
||||
summary: Change a custom field
|
||||
description: |
|
||||
Change properties of a custom field.
|
||||
|
||||
Changing the label has no further impliciations, since it is
|
||||
only used for displaying. The name and type on the other hand
|
||||
have consequences: name must be unique and the type determines
|
||||
how the value is stored internally.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NewCustomField"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
delete:
|
||||
tags: [ Custom Fields ]
|
||||
summary: Deletes a custom field.
|
||||
description: |
|
||||
Deletes the custom field and all its relations.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
|
||||
components:
|
||||
@ -3310,6 +3385,22 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/CustomField"
|
||||
|
||||
NewCustomField:
|
||||
description: |
|
||||
Data for creating a custom field.
|
||||
required:
|
||||
- name
|
||||
- ftype
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
format: ident
|
||||
label:
|
||||
type: string
|
||||
ftype:
|
||||
type: string
|
||||
format: customfieldtype
|
||||
|
||||
CustomField:
|
||||
description: |
|
||||
A custom field definition.
|
||||
@ -3317,6 +3408,7 @@ components:
|
||||
- id
|
||||
- name
|
||||
- ftype
|
||||
- usages
|
||||
- created
|
||||
properties:
|
||||
id:
|
||||
@ -3324,9 +3416,15 @@ components:
|
||||
format: ident
|
||||
name:
|
||||
type: string
|
||||
format: ident
|
||||
label:
|
||||
type: string
|
||||
ftype:
|
||||
type: string
|
||||
format: customfieldtype
|
||||
usages:
|
||||
type: integer
|
||||
format: int32
|
||||
created:
|
||||
type: integer
|
||||
format: date-time
|
||||
|
@ -1,18 +1,25 @@
|
||||
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.OCustomFields
|
||||
import docspell.backend.ops.OCustomFields.CustomFieldData
|
||||
import docspell.common._
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s._
|
||||
import docspell.store.AddResult
|
||||
import docspell.store.UpdateResult
|
||||
import docspell.store.records.RCustomField
|
||||
|
||||
import org.http4s.HttpRoutes
|
||||
//import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import docspell.store.records.RCustomField
|
||||
|
||||
object CustomFieldRoutes {
|
||||
|
||||
@ -21,15 +28,78 @@ object CustomFieldRoutes {
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case GET -> Root =>
|
||||
case GET -> Root :? QueryParam.QueryOpt(param) =>
|
||||
for {
|
||||
fs <- backend.customFields.findAll(user.account.collective)
|
||||
fs <- backend.customFields.findAll(user.account.collective, param.map(_.q))
|
||||
res <- Ok(CustomFieldList(fs.map(convertField).toList))
|
||||
} yield res
|
||||
|
||||
case req @ POST -> Root =>
|
||||
for {
|
||||
data <- req.as[NewCustomField]
|
||||
res <- backend.customFields.create(convertNewField(user, data))
|
||||
resp <- Ok(convertResult(res))
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / Ident(id) =>
|
||||
(for {
|
||||
field <- OptionT(backend.customFields.findById(user.account.collective, id))
|
||||
res <- OptionT.liftF(Ok(convertField(field)))
|
||||
} yield res).getOrElseF(NotFound(BasicResult(false, "Not found")))
|
||||
|
||||
case req @ PUT -> Root / Ident(id) =>
|
||||
for {
|
||||
data <- req.as[NewCustomField]
|
||||
res <- backend.customFields.change(convertChangeField(id, user, data))
|
||||
resp <- Ok(convertResult(res))
|
||||
} yield resp
|
||||
|
||||
case DELETE -> Root / Ident(id) =>
|
||||
for {
|
||||
res <- backend.customFields.delete(id, user.account.collective)
|
||||
resp <- Ok(convertResult(res))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
private def convertResult(r: AddResult): BasicResult =
|
||||
Conversions.basicResult(r, "New field created.")
|
||||
|
||||
private def convertField(f: RCustomField): CustomField =
|
||||
CustomField(f.id, f.name, f.ftype, f.created)
|
||||
private def convertResult(r: UpdateResult): BasicResult =
|
||||
Conversions.basicResult(r, "Field updated.")
|
||||
|
||||
private def convertChangeField(
|
||||
id: Ident,
|
||||
user: AuthToken,
|
||||
in: NewCustomField
|
||||
): RCustomField =
|
||||
RCustomField(
|
||||
id,
|
||||
in.name,
|
||||
in.label,
|
||||
user.account.collective,
|
||||
in.ftype,
|
||||
Timestamp.Epoch
|
||||
)
|
||||
|
||||
private def convertNewField(
|
||||
user: AuthToken,
|
||||
in: NewCustomField
|
||||
): OCustomFields.NewCustomField =
|
||||
OCustomFields.NewCustomField(
|
||||
in.name,
|
||||
in.label,
|
||||
in.ftype,
|
||||
user.account.collective
|
||||
)
|
||||
|
||||
private def convertField(f: CustomFieldData): CustomField =
|
||||
CustomField(
|
||||
f.field.id,
|
||||
f.field.name,
|
||||
f.field.label,
|
||||
f.field.ftype,
|
||||
f.usageCount,
|
||||
f.field.created
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
CREATE TABLE "custom_field" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"name" varchar(254) not null,
|
||||
"label" varchar(254),
|
||||
"cid" varchar(254) not null,
|
||||
"ftype" varchar(100) not null,
|
||||
"created" timestamp not null,
|
||||
|
@ -1,5 +1,7 @@
|
||||
package docspell.store.impl
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import docspell.common.Timestamp
|
||||
|
||||
import doobie._
|
||||
@ -7,6 +9,12 @@ import doobie.implicits._
|
||||
|
||||
trait DoobieSyntax {
|
||||
|
||||
def groupBy(c0: Column, cs: Column*): Fragment =
|
||||
groupBy(NonEmptyList.of(c0, cs: _*))
|
||||
|
||||
def groupBy(cs: NonEmptyList[Column]): Fragment =
|
||||
fr" GROUP BY (" ++ commas(cs.toList.map(_.f)) ++ fr")"
|
||||
|
||||
def coalesce(f0: Fragment, fs: Fragment*): Fragment =
|
||||
sql" coalesce(" ++ commas(f0 :: fs.toList) ++ sql") "
|
||||
|
||||
|
@ -0,0 +1,64 @@
|
||||
package docspell.store.queries
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.store.impl.Column
|
||||
import docspell.store.impl.Implicits._
|
||||
import docspell.store.records._
|
||||
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
|
||||
object QCustomField {
|
||||
|
||||
case class CustomFieldData(field: RCustomField, usageCount: Int)
|
||||
|
||||
def findAllLike(
|
||||
coll: Ident,
|
||||
nameQuery: Option[String]
|
||||
): ConnectionIO[Vector[CustomFieldData]] =
|
||||
findFragment(coll, nameQuery, None).query[CustomFieldData].to[Vector]
|
||||
|
||||
def findById(field: Ident, collective: Ident): ConnectionIO[Option[CustomFieldData]] =
|
||||
findFragment(collective, None, field.some).query[CustomFieldData].option
|
||||
|
||||
private def findFragment(
|
||||
coll: Ident,
|
||||
nameQuery: Option[String],
|
||||
fieldId: Option[Ident]
|
||||
): Fragment = {
|
||||
val fId = RCustomField.Columns.id.prefix("f")
|
||||
val fColl = RCustomField.Columns.cid.prefix("f")
|
||||
val fName = RCustomField.Columns.name.prefix("f")
|
||||
val fLabel = RCustomField.Columns.label.prefix("f")
|
||||
val vField = RCustomFieldValue.Columns.field.prefix("v")
|
||||
|
||||
val join = RCustomField.table ++ fr"f LEFT OUTER JOIN" ++
|
||||
RCustomFieldValue.table ++ fr"v ON" ++ fId.is(vField)
|
||||
|
||||
val cols = RCustomField.Columns.all.map(_.prefix("f")) :+ Column("COUNT(v.id)")
|
||||
|
||||
val nameCond = nameQuery.map(QueryWildcard.apply) match {
|
||||
case Some(q) =>
|
||||
or(fName.lowerLike(q), fLabel.lowerLike(q))
|
||||
case None =>
|
||||
Fragment.empty
|
||||
}
|
||||
val fieldCond = fieldId match {
|
||||
case Some(id) =>
|
||||
fId.is(id)
|
||||
case None =>
|
||||
Fragment.empty
|
||||
}
|
||||
val cond = and(fColl.is(coll), nameCond, fieldCond)
|
||||
|
||||
val group = NonEmptyList.fromList(RCustomField.Columns.all) match {
|
||||
case Some(nel) => groupBy(nel.map(_.prefix("f")))
|
||||
case None => Fragment.empty
|
||||
}
|
||||
|
||||
selectSimple(cols, join, cond) ++ group
|
||||
}
|
||||
}
|
@ -342,8 +342,8 @@ object QItem {
|
||||
TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl)
|
||||
|
||||
val iFolder = IC.folder.prefix("i")
|
||||
val name = q.name.map(_.toLowerCase).map(queryWildcard)
|
||||
val allNames = q.allNames.map(_.toLowerCase).map(queryWildcard)
|
||||
val name = q.name.map(_.toLowerCase).map(QueryWildcard.apply)
|
||||
val allNames = q.allNames.map(_.toLowerCase).map(QueryWildcard.apply)
|
||||
val cond = and(
|
||||
IC.cid.prefix("i").is(q.account.collective),
|
||||
IC.state.prefix("i").isOneOf(q.states),
|
||||
@ -516,8 +516,9 @@ object QItem {
|
||||
rn <- QAttachment.deleteItemAttachments(store)(itemId, collective)
|
||||
tn <- store.transact(RTagItem.deleteItemTags(itemId))
|
||||
mn <- store.transact(RSentMail.deleteByItem(itemId))
|
||||
cf <- store.transact(RCustomFieldValue.deleteByItem(itemId))
|
||||
n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective))
|
||||
} yield tn + rn + n + mn
|
||||
} yield tn + rn + n + mn + cf
|
||||
|
||||
private def findByFileIdsQuery(
|
||||
fileMetaIds: NonEmptyList[Ident],
|
||||
@ -618,18 +619,6 @@ object QItem {
|
||||
.to[Vector]
|
||||
}
|
||||
|
||||
private def queryWildcard(value: String): String = {
|
||||
def prefix(n: String) =
|
||||
if (n.startsWith("*")) s"%${n.substring(1)}"
|
||||
else n
|
||||
|
||||
def suffix(n: String) =
|
||||
if (n.endsWith("*")) s"${n.dropRight(1)}%"
|
||||
else n
|
||||
|
||||
prefix(suffix(value))
|
||||
}
|
||||
|
||||
final case class NameAndNotes(
|
||||
id: Ident,
|
||||
collective: Ident,
|
||||
|
@ -0,0 +1,17 @@
|
||||
package docspell.store.queries
|
||||
|
||||
object QueryWildcard {
|
||||
|
||||
def apply(value: String): String = {
|
||||
def prefix(n: String) =
|
||||
if (n.startsWith("*")) s"%${n.substring(1)}"
|
||||
else n
|
||||
|
||||
def suffix(n: String) =
|
||||
if (n.endsWith("*")) s"${n.dropRight(1)}%"
|
||||
else n
|
||||
|
||||
prefix(suffix(value))
|
||||
}
|
||||
|
||||
}
|
@ -9,7 +9,8 @@ import doobie.implicits._
|
||||
|
||||
case class RCustomField(
|
||||
id: Ident,
|
||||
name: String,
|
||||
name: Ident,
|
||||
label: Option[String],
|
||||
cid: Ident,
|
||||
ftype: CustomFieldType,
|
||||
created: Timestamp
|
||||
@ -23,22 +24,49 @@ object RCustomField {
|
||||
|
||||
val id = Column("id")
|
||||
val name = Column("name")
|
||||
val label = Column("label")
|
||||
val cid = Column("cid")
|
||||
val ftype = Column("ftype")
|
||||
val created = Column("created")
|
||||
|
||||
val all = List(id, name, cid, ftype, created)
|
||||
val all = List(id, name, label, cid, ftype, created)
|
||||
}
|
||||
import Columns._
|
||||
|
||||
def insert(value: RCustomField): ConnectionIO[Int] = {
|
||||
val sql = insertRow(
|
||||
table,
|
||||
Columns.all,
|
||||
fr"${value.id},${value.name},${value.cid},${value.ftype},${value.created}"
|
||||
fr"${value.id},${value.name},${value.label},${value.cid},${value.ftype},${value.created}"
|
||||
)
|
||||
sql.update.run
|
||||
}
|
||||
|
||||
def exists(fname: Ident, coll: Ident): ConnectionIO[Boolean] =
|
||||
???
|
||||
|
||||
def findById(fid: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] =
|
||||
selectSimple(all, table, and(id.is(fid), cid.is(coll))).query[RCustomField].option
|
||||
|
||||
def findByIdOrName(idOrName: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] =
|
||||
selectSimple(all, table, and(cid.is(coll), or(id.is(idOrName), name.is(idOrName))))
|
||||
.query[RCustomField]
|
||||
.option
|
||||
|
||||
def deleteById(fid: Ident, coll: Ident): ConnectionIO[Int] =
|
||||
deleteFrom(table, and(id.is(fid), cid.is(coll))).update.run
|
||||
|
||||
def findAll(coll: Ident): ConnectionIO[Vector[RCustomField]] =
|
||||
selectSimple(Columns.all, table, Columns.cid.is(coll)).query[RCustomField].to[Vector]
|
||||
selectSimple(all, table, cid.is(coll)).query[RCustomField].to[Vector]
|
||||
|
||||
def update(value: RCustomField): ConnectionIO[Int] =
|
||||
updateRow(
|
||||
table,
|
||||
and(id.is(value.id), cid.is(value.cid)),
|
||||
commas(
|
||||
name.setTo(value.name),
|
||||
label.setTo(value.label),
|
||||
ftype.setTo(value.ftype)
|
||||
)
|
||||
).update.run
|
||||
}
|
||||
|
@ -39,4 +39,12 @@ object RCustomFieldValue {
|
||||
sql.update.run
|
||||
}
|
||||
|
||||
def countField(fieldId: Ident): ConnectionIO[Int] =
|
||||
selectCount(Columns.id, table, Columns.field.is(fieldId)).query[Int].unique
|
||||
|
||||
def deleteByField(fieldId: Ident): ConnectionIO[Int] =
|
||||
deleteFrom(table, Columns.field.is(fieldId)).update.run
|
||||
|
||||
def deleteByItem(item: Ident): ConnectionIO[Int] =
|
||||
deleteFrom(table, Columns.itemId.is(item)).update.run
|
||||
}
|
||||
|
@ -0,0 +1,21 @@
|
||||
package docspell.store.queries
|
||||
|
||||
import minitest._
|
||||
|
||||
object QueryWildcardTest extends SimpleTestSuite {
|
||||
|
||||
test("replace prefix") {
|
||||
assertEquals("%name", QueryWildcard("*name"))
|
||||
assertEquals("%some more", QueryWildcard("*some more"))
|
||||
}
|
||||
|
||||
test("replace suffix") {
|
||||
assertEquals("name%", QueryWildcard("name*"))
|
||||
assertEquals("some other name%", QueryWildcard("some other name*"))
|
||||
}
|
||||
|
||||
test("replace both sides") {
|
||||
assertEquals("%name%", QueryWildcard("*name*"))
|
||||
assertEquals("%some other name%", QueryWildcard("*some other name*"))
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user