feat(time-tracking): filter time entries with the task DSL
This commit is contained in:
parent
42795518e9
commit
4bd6a6c4f7
|
|
@ -0,0 +1,204 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
|
||||
"github.com/ganigeorgiev/fexpr"
|
||||
"github.com/jszwedko/go-datemath"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// entriesForProjectCond matches time entries belonging to a project given a
|
||||
// predicate over a project_id column: standalone entries whose own project_id
|
||||
// matches, plus task-attached entries whose task currently lives in a matching
|
||||
// project. Tasks move between projects, so the project is resolved via the task
|
||||
// at query time rather than denormalized. Used for both permission scoping and
|
||||
// the project_id filter.
|
||||
func entriesForProjectCond(projectIDCond builder.Cond) builder.Cond {
|
||||
return builder.Or(
|
||||
projectIDCond,
|
||||
builder.In("task_id",
|
||||
builder.Select("id").From("tasks").Where(projectIDCond),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// timeEntryFilterCond parses a task-style filter string into a condition over
|
||||
// the time_entries table, or nil for an empty filter. Filterable fields:
|
||||
// user_id, task_id, project_id (ints / in-lists), start_time, end_time (dates,
|
||||
// datemath, or the literal null for running timers). comment is deliberately
|
||||
// not filterable — text matching belongs to search.
|
||||
func timeEntryFilterCond(filter, filterTimezone string) (builder.Cond, error) {
|
||||
if filter == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := fexpr.Parse(preprocessFilterString(filter))
|
||||
if err != nil {
|
||||
return nil, &ErrInvalidFilterExpression{Expression: filter, ExpressionError: err}
|
||||
}
|
||||
|
||||
loc := config.GetTimeZone()
|
||||
if filterTimezone != "" {
|
||||
loc, err = time.LoadLocation(filterTimezone)
|
||||
if err != nil {
|
||||
return nil, &ErrInvalidTimezone{Name: filterTimezone, LoadError: err}
|
||||
}
|
||||
}
|
||||
|
||||
return buildTimeEntryFilterCond(parsed, loc)
|
||||
}
|
||||
|
||||
func buildTimeEntryFilterCond(groups []fexpr.ExprGroup, loc *time.Location) (builder.Cond, error) {
|
||||
conds := make([]builder.Cond, 0, len(groups))
|
||||
joins := make([]taskFilterConcatinator, 0, len(groups))
|
||||
|
||||
for _, g := range groups {
|
||||
join := filterConcatAnd
|
||||
if g.Join == fexpr.JoinOr {
|
||||
join = filterConcatOr
|
||||
}
|
||||
|
||||
var (
|
||||
cond builder.Cond
|
||||
err error
|
||||
)
|
||||
switch item := g.Item.(type) {
|
||||
case []fexpr.ExprGroup: // a parenthesized sub-expression
|
||||
cond, err = buildTimeEntryFilterCond(item, loc)
|
||||
case fexpr.Expr:
|
||||
var comparator taskFilterComparator
|
||||
comparator, err = getFilterComparatorFromOp(item.Op)
|
||||
if err == nil {
|
||||
cond, err = resolveTimeEntryFilter(item.Left.Literal, comparator, item.Right.Literal, loc)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conds = append(conds, cond)
|
||||
joins = append(joins, join)
|
||||
}
|
||||
|
||||
if len(conds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := conds[0]
|
||||
for i := 1; i < len(conds); i++ {
|
||||
if joins[i] == filterConcatOr {
|
||||
result = builder.Or(result, conds[i])
|
||||
continue
|
||||
}
|
||||
result = builder.And(result, conds[i])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveTimeEntryFilter(field string, comparator taskFilterComparator, raw string, loc *time.Location) (builder.Cond, error) {
|
||||
switch field {
|
||||
case "user_id", "task_id":
|
||||
value, err := timeEntryIntFilterValue(raw, comparator)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw}
|
||||
}
|
||||
return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator, isNumeric: true}, false)
|
||||
|
||||
case "project", "project_id":
|
||||
value, err := timeEntryIntFilterValue(raw, comparator)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: "project_id", Value: raw}
|
||||
}
|
||||
// Build membership positively (standalone-in-project OR task-in-project)
|
||||
// and negate the whole set for != / not in. Negating project_id alone would
|
||||
// wrongly match task-attached entries, whose own project_id is 0.
|
||||
positive, negate := comparator, false
|
||||
if comparator == taskFilterComparatorNotEquals {
|
||||
positive, negate = taskFilterComparatorEquals, true
|
||||
}
|
||||
if comparator == taskFilterComparatorNotIn {
|
||||
positive, negate = taskFilterComparatorIn, true
|
||||
}
|
||||
inner, err := getFilterCond(&taskFilter{field: "project_id", value: value, comparator: positive, isNumeric: true}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := entriesForProjectCond(inner)
|
||||
if negate {
|
||||
cond = builder.Not{cond}
|
||||
}
|
||||
return cond, nil
|
||||
|
||||
case "start_time", "end_time":
|
||||
if raw == "null" {
|
||||
return nullTimeFilterCond(field, comparator)
|
||||
}
|
||||
value, err := timeEntryTimeFilterValue(raw, loc)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw}
|
||||
}
|
||||
return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator}, false)
|
||||
|
||||
default:
|
||||
return nil, ErrInvalidTimeEntryFilterField{Field: field}
|
||||
}
|
||||
}
|
||||
|
||||
// nullTimeFilterCond handles `end_time = null` (running timers) and its negation.
|
||||
func nullTimeFilterCond(field string, comparator taskFilterComparator) (builder.Cond, error) {
|
||||
if comparator == taskFilterComparatorEquals {
|
||||
return &builder.IsNull{field}, nil
|
||||
}
|
||||
if comparator == taskFilterComparatorNotEquals {
|
||||
return &builder.NotNull{field}, nil
|
||||
}
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: "null"}
|
||||
}
|
||||
|
||||
func timeEntryIntFilterValue(raw string, comparator taskFilterComparator) (any, error) {
|
||||
if comparator == taskFilterComparatorIn || comparator == taskFilterComparatorNotIn {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
v, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
}
|
||||
|
||||
// timeEntryTimeFilterValue mirrors the task filter's date handling: datemath
|
||||
// (now, now-7d) first, then explicit date formats.
|
||||
func timeEntryTimeFilterValue(raw string, loc *time.Location) (time.Time, error) {
|
||||
if loc == nil {
|
||||
loc = config.GetTimeZone()
|
||||
}
|
||||
if expr, err := safeDatemathParse(raw); err == nil {
|
||||
t := expr.Time(datemath.WithLocation(loc)).In(config.GetTimeZone())
|
||||
return adjustDateForMysql(t), nil
|
||||
}
|
||||
return parseTimeFromUserInput(raw, loc)
|
||||
}
|
||||
Loading…
Reference in New Issue