Merge pull request #727 from eikek/next-fixes

Next fixes
This commit is contained in:
mergify[bot] 2021-03-27 21:30:30 +00:00 committed by GitHub
commit 121051f234
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 318 additions and 583 deletions

View File

@ -150,6 +150,7 @@ object OCollective {
LearnClassifierArgs.taskName,
on,
timer,
None,
LearnClassifierArgs(coll)
)
_ <- uts.updateOneTask(AccountId(coll, LearnClassifierArgs.taskName), ut)
@ -164,6 +165,7 @@ object OCollective {
LearnClassifierArgs.taskName,
true,
CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All),
None,
LearnClassifierArgs(collective)
).encode.toPeriodicTask(AccountId(collective, LearnClassifierArgs.taskName))
job <- ut.toJob

View File

@ -1,5 +1,6 @@
package docspell.backend.ops
import cats.data.NonEmptyList
import cats.effect._
import cats.implicits._
import fs2.Stream
@ -9,6 +10,8 @@ import docspell.backend.ops.OItemSearch._
import docspell.common._
import docspell.common.syntax.all._
import docspell.ftsclient._
import docspell.query.ItemQuery._
import docspell.query.ItemQueryDsl._
import docspell.store.queries.{QFolder, QItem, SelectedItem}
import docspell.store.queue.JobQueue
import docspell.store.records.RJob
@ -125,12 +128,18 @@ object OFulltext {
.map(_.minBy(-_.score))
.map(r => SelectedItem(r.itemId, r.score))
.toSet
now <- Timestamp.current[F]
itemsWithTags <-
store
.transact(
QItem.findItemsWithTags(
account.collective,
QItem.findSelectedItems(Query.empty(account), maxNoteLen, select)
QItem.findSelectedItems(
Query.all(account),
now.toUtcDate,
maxNoteLen,
select
)
)
)
.take(batch.limit.toLong)
@ -165,7 +174,13 @@ object OFulltext {
.flatMap(r => Stream.emits(r.results.map(_.itemId)))
.compile
.to(Set)
q = Query.empty(account).withFix(_.copy(itemIds = itemIds.some))
itemIdsQuery = NonEmptyList
.fromList(itemIds.toList)
.map(ids => Attr.ItemId.in(ids.map(_.id)))
.getOrElse(Attr.ItemId.notExists)
q = Query
.all(account)
.withFix(_.copy(query = itemIdsQuery.some))
res <- store.transact(QItem.searchStats(now.toUtcDate)(q))
} yield res
}
@ -221,7 +236,11 @@ object OFulltext {
.flatMap(r => Stream.emits(r.results.map(_.itemId)))
.compile
.to(Set)
qnext = q.withFix(_.copy(itemIds = items.some))
itemIdsQuery = NonEmptyList
.fromList(items.toList)
.map(ids => Attr.ItemId.in(ids.map(_.id)))
.getOrElse(Attr.ItemId.notExists)
qnext = q.withFix(_.copy(query = itemIdsQuery.some))
now <- Timestamp.current[F]
res <- store.transact(QItem.searchStats(now.toUtcDate)(qnext))
} yield res

View File

@ -129,7 +129,7 @@ object OSimpleSearch {
def makeQuery(iq: ItemQuery): F[StringSearchResult[Items]] =
iq.findFulltext match {
case FulltextExtract.Result.Success(expr, ftq) =>
search(settings)(Query(fix, Query.QueryExpr(iq.copy(expr = expr))), ftq)
search(settings)(Query(fix, Query.QueryExpr(expr.some)), ftq)
.map(StringSearchResult.Success.apply)
case other: FulltextExtract.FailureResult =>
StringSearchResult.fulltextMismatch[Items](other).pure[F]
@ -152,7 +152,7 @@ object OSimpleSearch {
def makeQuery(iq: ItemQuery): F[StringSearchResult[SearchSummary]] =
iq.findFulltext match {
case FulltextExtract.Result.Success(expr, ftq) =>
searchSummary(useFTS)(Query(fix, Query.QueryExpr(iq.copy(expr = expr))), ftq)
searchSummary(useFTS)(Query(fix, Query.QueryExpr(expr.some)), ftq)
.map(StringSearchResult.Success.apply)
case other: FulltextExtract.FailureResult =>
StringSearchResult.fulltextMismatch[SearchSummary](other).pure[F]

View File

@ -36,7 +36,8 @@ object HouseKeepingTask {
"Docspell house-keeping",
DocspellSystem.taskGroup,
Priority.Low,
ce
ce,
None
)
.map(_.copy(id = periodicId))
}

View File

@ -1,6 +1,6 @@
package docspell.joex.notify
import cats.data.OptionT
import cats.data.{NonEmptyList => Nel, OptionT}
import cats.effect._
import cats.implicits._
@ -8,6 +8,9 @@ import docspell.backend.ops.OItemSearch.{Batch, ListItem, Query}
import docspell.common._
import docspell.joex.mail.EmilHeader
import docspell.joex.scheduler.{Context, Task}
import docspell.query.Date
import docspell.query.ItemQuery._
import docspell.query.ItemQueryDsl._
import docspell.store.queries.QItem
import docspell.store.records._
@ -69,18 +72,24 @@ object NotifyDueItemsTask {
def findItems[F[_]: Sync](ctx: Context[F, Args]): F[Vector[ListItem]] =
for {
now <- Timestamp.current[F]
rightDate = Date((now + Duration.days(ctx.args.remindDays.toLong)).toMillis)
q =
Query
.empty(ctx.args.account)
.all(ctx.args.account)
.withOrder(orderAsc = _.dueDate)
.withFix(_.copy(query = Expr.ValidItemStates.some))
.withCond(_ =>
Query.QueryForm.empty.copy(
states = ItemState.validStates.toList,
tagsInclude = ctx.args.tagsInclude,
tagsExclude = ctx.args.tagsExclude,
dueDateFrom =
ctx.args.daysBack.map(back => now - Duration.days(back.toLong)),
dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong))
Query.QueryExpr(
Attr.DueDate <= rightDate &&?
ctx.args.daysBack.map(back =>
Attr.DueDate >= Date((now - Duration.days(back.toLong)).toMillis)
) &&?
Nel
.fromList(ctx.args.tagsInclude)
.map(ids => Q.tagIdsEq(ids.map(_.id))) &&?
Nel
.fromList(ctx.args.tagsExclude)
.map(ids => Q.tagIdsIn(ids.map(_.id)).negate)
)
)
res <-

View File

@ -119,6 +119,8 @@ object ItemQuery {
final case class ChecksumMatch(checksum: String) extends Expr
final case class AttachId(id: String) extends Expr
case object ValidItemStates extends Expr
// things that can be expressed with terms above
sealed trait MacroExpr extends Expr {
def body: Expr

View File

@ -0,0 +1,76 @@
package docspell.query
import cats.data.NonEmptyList
import docspell.query.ItemQuery._
import docspell.query.internal.ExprUtil
object ItemQueryDsl {
implicit final class StringAttrDsl(val attr: Attr.StringAttr) extends AnyVal {
def ===(value: String): Expr =
Expr.SimpleExpr(Operator.Eq, Property(attr, value))
def <=(value: String): Expr =
Expr.SimpleExpr(Operator.Lte, Property(attr, value))
def >=(value: String): Expr =
Expr.SimpleExpr(Operator.Gte, Property(attr, value))
def <(value: String): Expr =
Expr.SimpleExpr(Operator.Lt, Property(attr, value))
def >(value: String): Expr =
Expr.SimpleExpr(Operator.Gt, Property(attr, value))
def in(values: NonEmptyList[String]): Expr =
Expr.InExpr(attr, values)
def exists: Expr =
Expr.Exists(attr)
def notExists: Expr =
Expr.NotExpr(exists)
}
implicit final class DateAttrDsl(val attr: Attr.DateAttr) extends AnyVal {
def <=(value: Date): Expr =
Expr.SimpleExpr(Operator.Lte, Property(attr, value))
def >=(value: Date): Expr =
Expr.SimpleExpr(Operator.Gte, Property(attr, value))
}
implicit final class ExprDsl(val expr: Expr) extends AnyVal {
def &&(other: Expr): Expr =
ExprUtil.reduce(Expr.and(expr, other))
def ||(other: Expr): Expr =
ExprUtil.reduce(Expr.or(expr, other))
def &&?(other: Option[Expr]): Expr =
other.map(e => &&(e)).getOrElse(expr)
def ||?(other: Option[Expr]): Expr =
other.map(e => ||(e)).getOrElse(expr)
def negate: Expr =
ExprUtil.reduce(Expr.NotExpr(expr))
def unary_! : Expr =
negate
}
object Q {
def tagIdsIn(values: NonEmptyList[String]): Expr =
Expr.TagIdsMatch(TagOperator.AnyMatch, values)
def tagIdsEq(values: NonEmptyList[String]): Expr =
Expr.TagIdsMatch(TagOperator.AllMatch, values)
def tagsIn(values: NonEmptyList[String]): Expr =
Expr.TagsMatch(TagOperator.AnyMatch, values)
def tagsEq(values: NonEmptyList[String]): Expr =
Expr.TagsMatch(TagOperator.AllMatch, values)
}
}

View File

@ -69,6 +69,9 @@ object ExprUtil {
expr
case AttachId(_) =>
expr
case ValidItemStates =>
expr
}
private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] =

View File

@ -1309,73 +1309,6 @@ paths:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/searchForm:
post:
tags: [ Item Search ]
summary: Search for items.
deprecated: true
description: |
Search for items given a search form. The results are grouped
by month and are sorted by item date (newest first). Tags and
attachments are *not* resolved. The results will always
contain an empty list for item tags and attachments. Use
`/searchFormWithTags` to also retrieve all tags and a list of
attachments of an item.
The `fulltext` field can be used to restrict the results by
using full-text search in the documents contents.
The customfields used in the search query are allowed to be
specified by either field id or field name. The values may
contain the wildcard `*` at beginning or end.
**NOTE** This is deprecated in favor for using a search query.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemSearch"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLightList"
/sec/item/searchFormWithTags:
post:
tags: [ Item Search ]
summary: Search for items.
deprecated: true
description: |
Search for items given a search form. The results are grouped
by month by default. For each item, its tags and attachments
are also returned. This uses more queries and is therefore
slower, but returns all tags to an item as well as their
attachments with some minor details.
The `fulltext` field can be used to restrict the results by
using full-text search in the documents contents.
**NOTE** This is deprecated in favor for using search query.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemSearch"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLightList"
/sec/item/search:
get:
tags: [ Item Search ]
@ -1457,29 +1390,6 @@ paths:
schema:
$ref: "#/components/schemas/ItemLightList"
/sec/item/searchFormStats:
post:
tags: [ Item Search ]
summary: Get basic statistics about the data of a search.
deprecated: true
description: |
Takes a search query and returns a summary about the results.
**NOTE** This is deprecated in favor of using a search query.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemSearch"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/SearchStats"
/sec/item/searchStats:
post:
tags: [ Item Search ]
@ -3943,6 +3853,8 @@ components:
format: ident
enabled:
type: boolean
summary:
type: string
imapConnection:
type: string
format: ident
@ -4102,6 +4014,8 @@ components:
format: ident
enabled:
type: boolean
summary:
type: string
smtpConnection:
type: string
format: ident
@ -5268,104 +5182,6 @@ components:
type: array
items:
$ref: "#/components/schemas/ItemLight"
ItemSearch:
description: |
A structure for a search form.
required:
- tagsInclude
- tagsExclude
- tagCategoriesInclude
- tagCategoriesExclude
- inbox
- offset
- limit
- customValues
properties:
tagsInclude:
type: array
items:
type: string
format: ident
tagsExclude:
type: array
items:
type: string
format: ident
tagCategoriesInclude:
type: array
items:
type: string
tagCategoriesExclude:
type: array
items:
type: string
inbox:
type: boolean
offset:
type: integer
format: int32
limit:
type: integer
format: int32
description: |
The maximum number of results to return. Note that this
limit is a soft limit, there is some hard limit on the
server, too.
direction:
type: string
format: direction
enum:
- incoming
- outgoing
name:
type: string
description: |
Search in item names.
allNames:
type: string
description: |
Search in item names, correspondents, concerned entities
and notes.
fullText:
type: string
description: |
A query searching the contents of documents. If only this
field is set, then a fulltext-only search is done.
corrOrg:
type: string
format: ident
corrPerson:
type: string
format: ident
concPerson:
type: string
format: ident
concEquip:
type: string
format: ident
folder:
type: string
format: ident
dateFrom:
type: integer
format: date-time
dateUntil:
type: integer
format: date-time
dueDateFrom:
type: integer
format: date-time
dueDateUntil:
type: integer
format: date-time
itemSubset:
$ref: "#/components/schemas/IdList"
customValues:
type: array
items:
$ref: "#/components/schemas/CustomFieldValue"
source:
type: string
ItemLight:
description: |
An item with only a few important properties.

View File

@ -143,36 +143,6 @@ trait Conversions {
// item list
def mkQuery(m: ItemSearch, account: AccountId): OItemSearch.Query =
OItemSearch.Query(
OItemSearch.Query.Fix(account, None, None),
OItemSearch.Query.QueryForm(
m.name,
if (m.inbox) Seq(ItemState.Created)
else ItemState.validStates.toList,
m.direction,
m.corrPerson,
m.corrOrg,
m.concPerson,
m.concEquip,
m.folder,
m.tagsInclude.map(Ident.unsafe),
m.tagsExclude.map(Ident.unsafe),
m.tagCategoriesInclude,
m.tagCategoriesExclude,
m.dateFrom,
m.dateUntil,
m.dueDateFrom,
m.dueDateUntil,
m.allNames,
m.itemSubset
.map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet)
.filter(_.nonEmpty),
m.customValues.map(mkCustomValue),
m.source
)
)
def mkCustomValue(v: CustomFieldValue): OItemSearch.CustomValue =
OItemSearch.CustomValue(v.field, v.value)

View File

@ -1,6 +1,5 @@
package docspell.restserver.routes
import cats.Monoid
import cats.data.NonEmptyList
import cats.effect._
import cats.implicits._
@ -16,6 +15,7 @@ import docspell.common._
import docspell.common.syntax.all._
import docspell.query.FulltextExtract.Result.TooMany
import docspell.query.FulltextExtract.Result.UnsupportedPosition
import docspell.query.ItemQuery.Expr
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions
@ -62,12 +62,12 @@ object ItemRoutes {
detailFlag.getOrElse(false),
cfg.maxNoteLength
)
val fixQuery = Query.Fix(user.account, None, None)
val fixQuery = Query.Fix(user.account, Some(Expr.ValidItemStates), None)
searchItems(backend, dsl)(settings, fixQuery, itemQuery)
case GET -> Root / "searchStats" :? QP.Query(q) =>
val itemQuery = ItemQueryString(q)
val fixQuery = Query.Fix(user.account, None, None)
val fixQuery = Query.Fix(user.account, Some(Expr.ValidItemStates), None)
searchItemStats(backend, dsl)(cfg.fullTextSearch.enabled, fixQuery, itemQuery)
case req @ POST -> Root / "search" =>
@ -86,7 +86,7 @@ object ItemRoutes {
userQuery.withDetails.getOrElse(false),
cfg.maxNoteLength
)
fixQuery = Query.Fix(user.account, None, None)
fixQuery = Query.Fix(user.account, Some(Expr.ValidItemStates), None)
resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery)
} yield resp
@ -94,7 +94,7 @@ object ItemRoutes {
for {
userQuery <- req.as[ItemQuery]
itemQuery = ItemQueryString(userQuery.query)
fixQuery = Query.Fix(user.account, None, None)
fixQuery = Query.Fix(user.account, Some(Expr.ValidItemStates), None)
resp <- searchItemStats(backend, dsl)(
cfg.fullTextSearch.enabled,
fixQuery,
@ -102,85 +102,6 @@ object ItemRoutes {
)
} yield resp
//DEPRECATED
case req @ POST -> Root / "searchForm" =>
for {
mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask")
query = Conversions.mkQuery(mask, user.account)
_ <- logger.ftrace(s"Running query: $query")
resp <- mask match {
case SearchFulltextOnly(ftq) if cfg.fullTextSearch.enabled =>
val ftsIn = OFulltext.FtsInput(ftq.query)
for {
items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)(
ftsIn,
user.account,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items))
} yield ok
case SearchWithFulltext(fq) if cfg.fullTextSearch.enabled =>
for {
items <- backend.fulltext.findItems(cfg.maxNoteLength)(
query,
OFulltext.FtsInput(fq),
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemListFts(items))
} yield ok
case _ =>
for {
items <- backend.itemSearch.findItems(cfg.maxNoteLength)(
query,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemList(items))
} yield ok
}
} yield resp
//DEPRECATED
case req @ POST -> Root / "searchFormWithTags" =>
for {
mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask")
query = Conversions.mkQuery(mask, user.account)
_ <- logger.ftrace(s"Running query: $query")
resp <- mask match {
case SearchFulltextOnly(ftq) if cfg.fullTextSearch.enabled =>
val ftsIn = OFulltext.FtsInput(ftq.query)
for {
items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)(
ftsIn,
user.account,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items))
} yield ok
case SearchWithFulltext(fq) if cfg.fullTextSearch.enabled =>
for {
items <- backend.fulltext.findItemsWithTags(cfg.maxNoteLength)(
query,
OFulltext.FtsInput(fq),
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemListWithTagsFts(items))
} yield ok
case _ =>
for {
items <- backend.itemSearch.findItemsWithTags(cfg.maxNoteLength)(
query,
Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
)
ok <- Ok(Conversions.mkItemListWithTags(items))
} yield ok
}
} yield resp
case req @ POST -> Root / "searchIndex" =>
for {
mask <- req.as[ItemQuery]
@ -204,26 +125,6 @@ object ItemRoutes {
}
} yield resp
//DEPRECATED
case req @ POST -> Root / "searchFormStats" =>
for {
mask <- req.as[ItemSearch]
query = Conversions.mkQuery(mask, user.account)
stats <- mask match {
case SearchFulltextOnly(ftq) if cfg.fullTextSearch.enabled =>
logger.finfo(s"Make index only summary: $ftq") *>
backend.fulltext.findIndexOnlySummary(
user.account,
OFulltext.FtsInput(ftq.query)
)
case SearchWithFulltext(fq) if cfg.fullTextSearch.enabled =>
backend.fulltext.findItemsSummary(query, OFulltext.FtsInput(fq))
case _ =>
backend.itemSearch.findItemsSummary(query)
}
resp <- Ok(Conversions.mkSearchStats(stats))
} yield resp
case GET -> Root / Ident(id) =>
for {
item <- backend.itemSearch.findItem(id, user.account.collective)
@ -560,40 +461,4 @@ object ItemRoutes {
def notEmpty: Option[String] =
opt.map(_.trim).filter(_.nonEmpty)
}
object SearchFulltextOnly {
implicit private val identMonoid: Monoid[Ident] =
Monoid.instance(Ident.unsafe(""), _ / _)
implicit private val timestampMonoid: Monoid[Timestamp] =
Monoid.instance(Timestamp.Epoch, (a, _) => a)
implicit private val directionMonoid: Monoid[Direction] =
Monoid.instance(Direction.Incoming, (a, _) => a)
implicit private val idListMonoid: Monoid[IdList] =
Monoid.instance(IdList(Nil), (a, b) => IdList(a.ids ++ b.ids))
implicit private val boolMonoid: Monoid[Boolean] =
Monoid.instance(false, _ || _)
private val itemSearchMonoid: Monoid[ItemSearch] =
cats.derived.semiauto.monoid
def unapply(m: ItemSearch): Option[ItemQuery] =
m.fullText match {
case Some(fq) =>
val me = m.copy(fullText = None, offset = 0, limit = 0)
if (itemSearchMonoid.empty == me)
Some(ItemQuery(m.offset.some, m.limit.some, Some(false), fq))
else None
case _ =>
None
}
}
object SearchWithFulltext {
def unapply(m: ItemSearch): Option[String] =
m.fullText
}
}

View File

@ -112,6 +112,7 @@ object NotifyDueItemsRoutes {
NotifyDueItemsArgs.taskName,
settings.enabled,
settings.schedule,
settings.summary,
NotifyDueItemsArgs(
user,
settings.smtpConnection,
@ -144,6 +145,7 @@ object NotifyDueItemsRoutes {
} yield NotificationSettings(
task.id,
task.enabled,
task.summary,
conn.getOrElse(Ident.unsafe("")),
task.args.recipients,
task.timer,

View File

@ -105,6 +105,7 @@ object ScanMailboxRoutes {
ScanMailboxArgs.taskName,
settings.enabled,
settings.schedule,
settings.summary,
ScanMailboxArgs(
user,
settings.imapConnection,
@ -139,6 +140,7 @@ object ScanMailboxRoutes {
} yield ScanMailboxSettings(
task.id,
task.enabled,
task.summary,
conn.getOrElse(Ident.unsafe("")),
task.args.folders,
task.timer,

View File

@ -0,0 +1,2 @@
ALTER TABLE "periodic_task"
ADD COLUMN "summary" varchar(254);

View File

@ -0,0 +1,2 @@
ALTER TABLE `periodic_task`
ADD COLUMN `summary` varchar(254);

View File

@ -0,0 +1,2 @@
ALTER TABLE "periodic_task"
ADD COLUMN "summary" varchar(254);

View File

@ -117,6 +117,9 @@ object ItemQueryGenerator {
if (flag) tables.item.state === ItemState.created
else tables.item.state === ItemState.confirmed
case Expr.ValidItemStates =>
tables.item.state.in(ItemState.validStates)
case Expr.TagIdsMatch(op, tags) =>
val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
Nel

View File

@ -98,30 +98,7 @@ object QItem {
cv.itemId === itemId
).build.query[ItemFieldValue].to[Vector]
private def findCustomFieldValuesForColl(
coll: Ident,
values: Seq[CustomValue]
): Option[Select] = {
val cf = RCustomField.as("cf")
val cv = RCustomFieldValue.as("cv")
def singleSelect(v: CustomValue) =
Select(
cv.itemId.s,
from(cv).innerJoin(cf, cv.field === cf.id),
where(
cf.cid === coll &&
(cf.name === v.field || cf.id === v.field) &&
cv.value.like(QueryWildcard(v.value.toLowerCase))
)
)
Nel
.fromList(values.toList)
.map(nel => intersect(nel.map(singleSelect)))
}
private def findItemsBase(q: Query.Fix, noteMaxLen: Int): Select = {
private def findItemsBase(q: Query.Fix, today: LocalDate, noteMaxLen: Int): Select = {
val attachs = AttachCountTable("cta")
val coll = q.account.collective
@ -169,9 +146,7 @@ object QItem {
.leftJoin(pers1, pers1.pid === i.concPerson && pers1.cid === coll)
.leftJoin(equip, equip.eid === i.concEquipment && equip.cid === coll),
where(
i.cid === coll &&? q.itemIds.map(s =>
Nel.fromList(s.toList).map(nel => i.id.in(nel)).getOrElse(i.id.isNull)
)
i.cid === coll &&? q.query.map(qs => queryCondFromExpr(today, coll, qs))
&& or(
i.folder.isNull,
i.folder.in(QFolder.findMemberFolderIds(q.account))
@ -184,54 +159,17 @@ object QItem {
)
}
def queryCondFromForm(coll: Ident, q: Query.QueryForm): Condition =
Condition.unit &&?
q.direction.map(d => i.incoming === d) &&?
q.name.map(n => i.name.like(QueryWildcard.lower(n))) &&?
Nel.fromList(q.states.toList).map(nel => i.state.in(nel)) &&?
q.allNames
.map(QueryWildcard.lower)
.map(n =>
org.name.like(n) ||
pers0.name.like(n) ||
pers1.name.like(n) ||
equip.name.like(n) ||
i.name.like(n) ||
i.notes.like(n)
) &&?
q.corrPerson.map(p => pers0.pid === p) &&?
q.corrOrg.map(o => org.oid === o) &&?
q.concPerson.map(p => pers1.pid === p) &&?
q.concEquip.map(e => equip.eid === e) &&?
q.folder.map(fid => f.id === fid) &&?
q.dateFrom.map(d => coalesce(i.itemDate.s, i.created.s) >= d) &&?
q.dateTo.map(d => coalesce(i.itemDate.s, i.created.s) <= d) &&?
q.dueDateFrom.map(d => i.dueDate > d) &&?
q.dueDateTo.map(d => i.dueDate < d) &&?
q.source.map(n => i.source.like(QueryWildcard.lower(n))) &&?
q.itemIds.map(s =>
Nel.fromList(s.toList).map(nel => i.id.in(nel)).getOrElse(i.id.isNull)
) &&?
TagItemName
.itemsWithAllTagAndCategory(q.tagsInclude, q.tagCategoryIncl)
.map(subsel => i.id.in(subsel)) &&?
TagItemName
.itemsWithEitherTagOrCategory(q.tagsExclude, q.tagCategoryExcl)
.map(subsel => i.id.notIn(subsel)) &&?
findCustomFieldValuesForColl(coll, q.customValues)
.map(itemIds => i.id.in(itemIds))
def queryCondFromExpr(today: LocalDate, coll: Ident, q: ItemQuery): Condition = {
def queryCondFromExpr(today: LocalDate, coll: Ident, q: ItemQuery.Expr): Condition = {
val tables = Tables(i, org, pers0, pers1, equip, f, a, m, AttachCountTable("cta"))
ItemQueryGenerator.fromExpr(today, tables, coll)(q.expr)
ItemQueryGenerator.fromExpr(today, tables, coll)(q)
}
def queryCondition(today: LocalDate, coll: Ident, cond: Query.QueryCond): Condition =
cond match {
case fm: Query.QueryForm =>
queryCondFromForm(coll, fm)
case expr: Query.QueryExpr =>
queryCondFromExpr(today, coll, expr.q)
case Query.QueryExpr(Some(expr)) =>
queryCondFromExpr(today, coll, expr)
case Query.QueryExpr(None) =>
Condition.unit
}
def findItems(
@ -240,7 +178,7 @@ object QItem {
maxNoteLen: Int,
batch: Batch
): Stream[ConnectionIO, ListItem] = {
val sql = findItemsBase(q.fix, maxNoteLen)
val sql = findItemsBase(q.fix, today, maxNoteLen)
.changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
.limit(batch)
.build
@ -263,7 +201,7 @@ object QItem {
.innerJoin(i, i.id === ti.itemId)
val tagCloud =
findItemsBase(q.fix, 0).unwrap
findItemsBase(q.fix, today, 0).unwrap
.withSelect(select(tag.all).append(count(i.id).as("num")))
.changeFrom(_.prepend(tagFrom))
.changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
@ -281,7 +219,7 @@ object QItem {
}
def searchCountSummary(today: LocalDate)(q: Query): ConnectionIO[Int] =
findItemsBase(q.fix, 0).unwrap
findItemsBase(q.fix, today, 0).unwrap
.withSelect(Nel.of(count(i.id).as("num")))
.changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
.build
@ -290,7 +228,7 @@ object QItem {
def searchFolderSummary(today: LocalDate)(q: Query): ConnectionIO[List[FolderCount]] = {
val fu = RUser.as("fu")
findItemsBase(q.fix, 0).unwrap
findItemsBase(q.fix, today, 0).unwrap
.withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num")))
.changeFrom(_.innerJoin(fu, fu.uid === f.owner))
.changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
@ -307,7 +245,7 @@ object QItem {
.innerJoin(i, i.id === cv.itemId)
val base =
findItemsBase(q.fix, 0).unwrap
findItemsBase(q.fix, today, 0).unwrap
.changeFrom(_.prepend(fieldJoin))
.changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
.groupBy(GroupBy(cf.all))
@ -359,6 +297,7 @@ object QItem {
def findSelectedItems(
q: Query,
today: LocalDate,
maxNoteLen: Int,
items: Set[SelectedItem]
): Stream[ConnectionIO, ListItem] =
@ -386,7 +325,7 @@ object QItem {
)
)
val from = findItemsBase(q.fix, maxNoteLen)
val from = findItemsBase(q.fix, today, maxNoteLen)
.appendCte(cte)
.appendSelect(Tids.weight.s)
.changeFrom(_.innerJoin(Tids, Tids.itemId === i.id))

View File

@ -85,6 +85,6 @@ object QUserTask {
)
def makeUserTask(r: RPeriodicTask): UserTask[String] =
UserTask(r.id, r.task, r.enabled, r.timer, r.args)
UserTask(r.id, r.task, r.enabled, r.timer, r.summary, r.args)
}

View File

@ -26,12 +26,12 @@ object Query {
case class Fix(
account: AccountId,
itemIds: Option[Set[Ident]],
query: Option[ItemQuery.Expr],
orderAsc: Option[RItem.Table => Column[_]]
) {
def isEmpty: Boolean =
itemIds.isEmpty
query.isEmpty
}
sealed trait QueryCond {
@ -41,64 +41,17 @@ object Query {
!isEmpty
}
case class QueryForm(
name: Option[String],
states: Seq[ItemState],
direction: Option[Direction],
corrPerson: Option[Ident],
corrOrg: Option[Ident],
concPerson: Option[Ident],
concEquip: Option[Ident],
folder: Option[Ident],
tagsInclude: List[Ident],
tagsExclude: List[Ident],
tagCategoryIncl: List[String],
tagCategoryExcl: List[String],
dateFrom: Option[Timestamp],
dateTo: Option[Timestamp],
dueDateFrom: Option[Timestamp],
dueDateTo: Option[Timestamp],
allNames: Option[String],
itemIds: Option[Set[Ident]],
customValues: Seq[CustomValue],
source: Option[String]
) extends QueryCond {
case class QueryExpr(q: Option[ItemQuery.Expr]) extends QueryCond {
def isEmpty: Boolean =
this == QueryForm.empty
}
object QueryForm {
val empty =
QueryForm(
None,
Seq.empty,
None,
None,
None,
None,
None,
None,
Nil,
Nil,
Nil,
Nil,
None,
None,
None,
None,
None,
None,
Seq.empty,
None
)
q.isEmpty
}
case class QueryExpr(q: ItemQuery) extends QueryCond {
def isEmpty: Boolean =
q.expr == ItemQuery.all.expr
object QueryExpr {
def apply(q: ItemQuery.Expr): QueryExpr =
QueryExpr(Some(q))
}
def empty(account: AccountId): Query =
Query(Fix(account, None, None), QueryForm.empty)
def all(account: AccountId): Query =
Query(Fix(account, None, None), QueryExpr(None))
}

View File

@ -30,7 +30,8 @@ case class RPeriodicTask(
marked: Option[Timestamp],
timer: CalEvent,
nextrun: Timestamp,
created: Timestamp
created: Timestamp,
summary: Option[String]
) {
def toJob[F[_]: Sync]: F[RJob] =
@ -66,7 +67,8 @@ object RPeriodicTask {
subject: String,
submitter: Ident,
priority: Priority,
timer: CalEvent
timer: CalEvent,
summary: Option[String]
): F[RPeriodicTask] =
Ident
.randomId[F]
@ -91,7 +93,8 @@ object RPeriodicTask {
.map(_.toInstant)
.map(Timestamp.apply)
.getOrElse(Timestamp.Epoch),
now
now,
summary
)
}
)
@ -104,9 +107,20 @@ object RPeriodicTask {
subject: String,
submitter: Ident,
priority: Priority,
timer: CalEvent
timer: CalEvent,
summary: Option[String]
)(implicit E: Encoder[A]): F[RPeriodicTask] =
create[F](enabled, task, group, E(args).noSpaces, subject, submitter, priority, timer)
create[F](
enabled,
task,
group,
E(args).noSpaces,
subject,
submitter,
priority,
timer,
summary
)
final case class Table(alias: Option[String]) extends TableDef {
val tableName = "periodic_task"
@ -124,6 +138,7 @@ object RPeriodicTask {
val timer = Column[CalEvent]("timer", this)
val nextrun = Column[Timestamp]("nextrun", this)
val created = Column[Timestamp]("created", this)
val summary = Column[String]("summary", this)
val all = NonEmptyList.of[Column[_]](
id,
enabled,
@ -137,7 +152,8 @@ object RPeriodicTask {
marked,
timer,
nextrun,
created
created,
summary
)
}
@ -151,7 +167,7 @@ object RPeriodicTask {
T.all,
fr"${v.id},${v.enabled},${v.task},${v.group},${v.args}," ++
fr"${v.subject},${v.submitter},${v.priority},${v.worker}," ++
fr"${v.marked},${v.timer},${v.nextrun},${v.created}"
fr"${v.marked},${v.timer},${v.nextrun},${v.created},${v.summary}"
)
def update(v: RPeriodicTask): ConnectionIO[Int] =
@ -168,7 +184,8 @@ object RPeriodicTask {
T.worker.setTo(v.worker),
T.marked.setTo(v.marked),
T.timer.setTo(v.timer),
T.nextrun.setTo(v.nextrun)
T.nextrun.setTo(v.nextrun),
T.summary.setTo(v.summary)
)
)

View File

@ -16,6 +16,7 @@ case class UserTask[A](
name: Ident,
enabled: Boolean,
timer: CalEvent,
summary: Option[String],
args: A
) {
@ -47,7 +48,8 @@ object UserTask {
s"${account.user.id}: ${ut.name.id}",
account.user,
Priority.Low,
ut.timer
ut.timer,
ut.summary
)
.map(r => r.copy(id = ut.id))
}

View File

@ -162,7 +162,6 @@ import Api.Model.ItemInsights exposing (ItemInsights)
import Api.Model.ItemLightList exposing (ItemLightList)
import Api.Model.ItemProposals exposing (ItemProposals)
import Api.Model.ItemQuery exposing (ItemQuery)
import Api.Model.ItemSearch exposing (ItemSearch)
import Api.Model.ItemUploadMeta exposing (ItemUploadMeta)
import Api.Model.ItemsAndDate exposing (ItemsAndDate)
import Api.Model.ItemsAndDirection exposing (ItemsAndDirection)

View File

@ -28,6 +28,7 @@ import Data.UiSettings exposing (UiSettings)
import Data.Validated exposing (Validated(..))
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import Http
import Styles as S
import Util.Http
@ -52,6 +53,7 @@ type alias Model =
, formMsg : Maybe BasicResult
, loading : Int
, yesNoDelete : Comp.YesNoDimmer.Model
, summary : Maybe String
}
@ -79,6 +81,7 @@ type Msg
| Cancel
| RequestDelete
| YesNoDeleteMsg Comp.YesNoDimmer.Msg
| SetSummary String
initWith : Flags -> NotificationSettings -> ( Model, Cmd Msg )
@ -121,6 +124,7 @@ initWith flags s =
, formMsg = Nothing
, loading = im.loading
, yesNoDelete = Comp.YesNoDimmer.emptyModel
, summary = s.summary
}
, Cmd.batch
[ nc
@ -158,6 +162,7 @@ init flags =
, formMsg = Nothing
, loading = 2
, yesNoDelete = Comp.YesNoDimmer.emptyModel
, summary = Nothing
}
, Cmd.batch
[ Api.getMailSettings flags "" ConnResp
@ -203,6 +208,7 @@ makeSettings model =
, capOverdue = model.capOverdue
, enabled = model.enabled
, schedule = Data.CalEvent.makeEvent timer
, summary = model.summary
}
in
Data.Validated.map4 make
@ -450,6 +456,12 @@ update flags msg model =
, Cmd.none
)
SetSummary str ->
( { model | summary = Util.Maybe.fromString str }
, NoAction
, Cmd.none
)
--- View2
@ -473,6 +485,14 @@ view2 extraClasses settings model =
let
dimmerSettings =
Comp.YesNoDimmer.defaultSettings2 "Really delete this notification task?"
startOnceBtn =
MB.SecondaryButton
{ tagger = StartOnce
, label = "Start Once"
, title = "Start this task now"
, icon = Just "fa fa-play"
}
in
div
[ class "flex flex-col md:relative"
@ -501,7 +521,8 @@ view2 extraClasses settings model =
]
, end =
if model.settings.id /= "" then
[ MB.DeleteButton
[ startOnceBtn
, MB.DeleteButton
{ tagger = RequestDelete
, label = "Delete"
, title = "Delete this task"
@ -510,7 +531,8 @@ view2 extraClasses settings model =
]
else
[]
[ startOnceBtn
]
, rootClasses = "mb-4"
}
, div
@ -534,6 +556,22 @@ view2 extraClasses settings model =
, id = "notify-enabled"
}
]
, div [ class "mb-4" ]
[ label [ class S.inputLabel ]
[ text "Summary"
]
, input
[ type_ "text"
, onInput SetSummary
, class S.textInput
, Maybe.withDefault "" model.summary
|> value
]
[]
, span [ class "opacity-50 text-sm" ]
[ text "Some human readable name, only for displaying"
]
]
, div [ class "mb-4" ]
[ label [ class S.inputLabel ]
[ text "Send via"

View File

@ -54,13 +54,13 @@ view2 _ items =
, th [ class "text-center mr-2" ]
[ i [ class "fa fa-check" ] []
]
, th [ class "text-left " ] [ text "Summary" ]
, th [ class "text-left hidden sm:table-cell mr-2" ]
[ text "Schedule" ]
, th [ class "text-left mr-2" ]
[ text "Connection" ]
, th [ class "text-left hidden sm:table-cell mr-2" ]
[ text "Recipients" ]
, th [ class "text-center " ] [ text "Remind Days" ]
]
]
, tbody []
@ -76,6 +76,10 @@ viewItem2 item =
, td [ class "w-px whitespace-nowrap px-2 text-center" ]
[ Util.Html.checkbox2 item.enabled
]
, td [ class "text-left" ]
[ Maybe.withDefault "" item.summary
|> text
]
, td [ class "text-left hidden sm:table-cell mr-2" ]
[ code [ class "font-mono text-sm" ]
[ text item.schedule
@ -87,8 +91,4 @@ viewItem2 item =
, td [ class "text-left hidden sm:table-cell mr-2" ]
[ String.join ", " item.recipients |> text
]
, td [ class "text-center" ]
[ String.fromInt item.remindDays
|> text
]
]

View File

@ -221,6 +221,7 @@ view2 settings model =
, ( S.successMessage, Maybe.map .success model.result == Just True )
, ( "hidden", model.result == Nothing )
]
, class "mb-2"
]
[ Maybe.map .message model.result
|> Maybe.withDefault ""

View File

@ -75,6 +75,7 @@ type alias Model =
, languageModel : Comp.FixedDropdown.Model Language
, language : Maybe Language
, postHandleAll : Bool
, summary : Maybe String
, openTabs : Set String
}
@ -121,6 +122,7 @@ type Msg
| RemoveLanguage
| TogglePostHandleAll
| ToggleAkkordionTab String
| SetSummary String
initWith : Flags -> ScanMailboxSettings -> ( Model, Cmd Msg )
@ -167,6 +169,7 @@ initWith flags s =
Comp.FixedDropdown.init (List.map mkLanguageItem Data.Language.all)
, language = Maybe.andThen Data.Language.fromString s.language
, postHandleAll = Maybe.withDefault False s.postHandleAll
, summary = s.summary
}
, Cmd.batch
[ Api.getImapSettings flags "" ConnResp
@ -221,6 +224,7 @@ init flags =
Comp.FixedDropdown.init (List.map mkLanguageItem Data.Language.all)
, language = Nothing
, postHandleAll = False
, summary = Nothing
, openTabs = Set.insert (tabTitle TabGeneral) Set.empty
}
, Cmd.batch
@ -283,6 +287,7 @@ makeSettings model =
|> Just
, language = Maybe.map Data.Language.toIso3 model.language
, postHandleAll = Just model.postHandleAll
, summary = model.summary
}
in
Data.Validated.map3 make
@ -689,6 +694,12 @@ update flags msg model =
, Cmd.none
)
SetSummary str ->
( { model | summary = Util.Maybe.fromString str }
, NoAction
, Cmd.none
)
--- View2
@ -870,6 +881,22 @@ viewGeneral2 settings model =
[ text "Mailbox"
, B.inputRequired
]
, div [ class "mb-4" ]
[ label [ class S.inputLabel ]
[ text "Summary"
]
, input
[ type_ "text"
, onInput SetSummary
, class S.textInput
, Maybe.withDefault "" model.summary
|> value
]
[]
, span [ class "opacity-50 text-sm" ]
[ text "Some human readable name, only for displaying"
]
]
, Html.map ConnMsg
(Comp.Dropdown.view2
DS.mainStyle

View File

@ -54,12 +54,11 @@ view2 _ items =
, th [ class "" ]
[ i [ class "fa fa-check" ] []
]
, th [ class "text-left mr-2 hidden md:table-cell" ] [ text "Schedule" ]
, th [ class "text-left mr-2" ] [ text "Connection" ]
, th [ class "text-left mr-2" ] [ text "Folders" ]
, th [ class "text-left mr-2 hidden md:table-cell" ] [ text "Received Since" ]
, th [ class "text-left mr-2 hidden md:table-cell" ] [ text "Target" ]
, th [ class "hidden md:table-cell" ] [ text "Delete" ]
, th [ class "text-left" ] [ text "Summary" ]
, th [ class "text-left mr-2" ] [ text "Schedule" ]
, th [ class "text-left mr-2 hidden md:table-cell" ] [ text "Connection" ]
, th [ class "text-left mr-2 hidden md:table-cell" ] [ text "Folders" ]
, th [ class "text-left mr-2 hidden lg:table-cell" ] [ text "Received Since" ]
]
]
, tbody []
@ -75,28 +74,24 @@ viewItem2 item =
, td [ class "w-px px-2" ]
[ Util.Html.checkbox2 item.enabled
]
, td [ class "mr-2 hidden md:table-cell" ]
, td [ class "text-left" ]
[ Maybe.withDefault "" item.summary |> text
]
, td [ class "mr-2" ]
[ code [ class "font-mono text-sm" ]
[ text item.schedule
]
]
, td [ class "text-left mr-2" ]
, td [ class "text-left mr-2 hidden md:table-cell" ]
[ text item.imapConnection
]
, td [ class "text-left mr-2" ]
, td [ class "text-left mr-2 hidden md:table-cell" ]
[ String.join ", " item.folders |> text
]
, td [ class "text-left mr-2 hidden md:table-cell" ]
, td [ class "text-left mr-2 hidden lg:table-cell" ]
[ Maybe.map String.fromInt item.receivedSinceHours
|> Maybe.withDefault "-"
|> text
, text " h"
]
, td [ class "text-left mr-2 hidden md:table-cell" ]
[ Maybe.withDefault "-" item.targetFolder
|> text
]
, td [ class "w-px px-2 hidden md:table-cell" ]
[ Util.Html.checkbox2 item.deleteMail
]
]

View File

@ -7,7 +7,6 @@ import Comp.MenuBar as MB
import Comp.PowerSearchInput
import Comp.SearchMenu
import Comp.SearchStatsView
import Comp.YesNoDimmer
import Data.Flags exposing (Flags)
import Data.ItemSelection
import Data.UiSettings exposing (UiSettings)
@ -53,19 +52,6 @@ viewContent flags settings model =
deleteSelectedDimmer : Model -> List (Html Msg)
deleteSelectedDimmer model =
let
selectAction =
case model.viewMode of
SelectView svm ->
svm.action
_ ->
NoneAction
deleteAllDimmer : Comp.YesNoDimmer.Settings
deleteAllDimmer =
Comp.YesNoDimmer.defaultSettings2 "Really delete all selected items?"
in
case model.viewMode of
SelectView svm ->
case svm.confirmModal of

View File

@ -34,35 +34,37 @@ viewContent mid _ _ model =
[ id "content"
, class S.content
]
[ div [ class "px-0 flex flex-col" ]
[ div [ class "py-4" ]
[ renderForm model
]
, div [ class "py-0" ]
[ Html.map DropzoneMsg
(Comp.Dropzone.view2 model.dropzone)
]
, div [ class "py-4" ]
[ a
[ class S.primaryButton
, href "#"
, onClick SubmitUpload
[ div [ class "container mx-auto" ]
[ div [ class "px-0 flex flex-col" ]
[ div [ class "py-4" ]
[ renderForm model
]
[ text "Submit"
, div [ class "py-0" ]
[ Html.map DropzoneMsg
(Comp.Dropzone.view2 model.dropzone)
]
, a
[ class S.secondaryButton
, class "ml-2"
, href "#"
, onClick Clear
]
[ text "Reset"
, div [ class "py-4" ]
[ a
[ class S.primaryButton
, href "#"
, onClick SubmitUpload
]
[ text "Submit"
]
, a
[ class S.secondaryButton
, class "ml-2"
, href "#"
, onClick Clear
]
[ text "Reset"
]
]
]
, renderErrorMsg model
, renderSuccessMsg (Util.Maybe.nonEmpty mid) model
, renderUploads model
]
, renderErrorMsg model
, renderSuccessMsg (Util.Maybe.nonEmpty mid) model
, renderUploads model
]

View File

@ -18,7 +18,7 @@ sidebarMenuItemActive =
content : String
content =
"container mx-auto px-2 h-screen-12 overflow-y-auto scrollbar-main scrollbar-thin"
"w-full mx-auto px-2 h-screen-12 overflow-y-auto scrollbar-main scrollbar-thin"
sidebarLink : String

View File

@ -70,7 +70,7 @@ fi
set -o errexit -o pipefail -o noclobber -o nounset
LOGIN_URL="$BASE_URL/api/v1/open/auth/login"
SEARCH_URL="$BASE_URL/api/v1/sec/item/searchWithTags"
SEARCH_URL="$BASE_URL/api/v1/sec/item/search"
INSIGHT_URL="$BASE_URL/api/v1/sec/collective/insights"
DETAIL_URL="$BASE_URL/api/v1/sec/item"
ATTACH_URL="$BASE_URL/api/v1/sec/attachment"
@ -108,11 +108,11 @@ mcurl() {
errout "Login to Docspell."
errout "Using url: $BASE_URL"
if [ -z "$DS_USER" ]; then
if [ -z "${DS_USER:-}" ]; then
errout -n "Account: "
read DS_USER
fi
if [ -z "$DS_PASS" ]; then
if [ -z "${DS_PASS:-}" ]; then
errout -n "Password: "
read -s DS_PASS
fi
@ -152,7 +152,7 @@ listItems() {
OFFSET="${1:-0}"
LIMIT="${2:-50}"
errout "Get next items with offset=$OFFSET, limit=$LIMIT"
REQ="{\"offset\":$OFFSET, \"limit\":$LIMIT, \"tagsInclude\":[],\"tagsExclude\":[],\"tagCategoriesInclude\":[], \"tagCategoriesExclude\":[],\"customValues\":[],\"inbox\":false}"
REQ="{\"offset\":$OFFSET, \"limit\":$LIMIT, \"withDetails\":true, \"query\":\"\"}"
mcurl -XPOST -H 'ContentType: application/json' -d "$REQ" "$SEARCH_URL" | "$JQ_CMD" -r '.groups[].items[]|.id'
}