From fc5103e45b6fac771f2445bda24ee7b94706257b Mon Sep 17 00:00:00 2001 From: Jan Heuermann Date: Sat, 21 Feb 2026 12:34:21 +0100 Subject: [PATCH 1/5] Handle empty records when filtering --- klog/app/cli/args/filter.go | 7 +- klog/app/cli/args/misc.go | 7 + klog/app/cli/args/now.go | 6 +- klog/app/cli/args/warn.go | 10 +- klog/app/cli/print.go | 5 +- klog/app/cli/report.go | 2 +- klog/app/cli/tags.go | 2 +- klog/app/cli/today.go | 2 +- klog/app/cli/total.go | 2 +- klog/app/config.go | 9 +- klog/service/filter/filter.go | 30 ++-- klog/service/filter/filter_test.go | 227 ++++++++++++++++++----------- klog/service/filter/predicate.go | 41 +++++- klog/service/warning.go | 21 ++- 14 files changed, 252 insertions(+), 119 deletions(-) diff --git a/klog/app/cli/args/filter.go b/klog/app/cli/args/filter.go index 4a7aff0..9a1ff05 100644 --- a/klog/app/cli/args/filter.go +++ b/klog/app/cli/args/filter.go @@ -36,6 +36,8 @@ type FilterArgs struct { // General filters: Tags []klog.Tag `name:"tag" placeholder:"TAG" group:"Filter" help:"Entries that match these tags (either in the record summary or the entry summary). You can omit the leading '#'."` Filter string `name:"filter" placeholder:"EXPR" group:"Filter" help:"Entries that match this filter expression. Run 'klog info --filtering' to learn how expressions works."` + + hasPartialRecordsWithShouldTotal bool // Field only for internal use } func (args *FilterArgs) ApplyFilter(now gotime.Time, rs []klog.Record) ([]klog.Record, app.Error) { @@ -125,7 +127,10 @@ func (args *FilterArgs) ApplyFilter(now gotime.Time, rs []klog.Record) ([]klog.R // Apply filters, if applicable: if len(predicates) > 0 { - rs = filter.Filter(filter.And{Predicates: predicates}, rs) + hasPartialRecordsWithShouldTotal := false + rs, hasPartialRecordsWithShouldTotal = filter.Filter(filter.And{Predicates: predicates}, rs) + args.hasPartialRecordsWithShouldTotal = hasPartialRecordsWithShouldTotal + } return rs, nil } diff --git a/klog/app/cli/args/misc.go b/klog/app/cli/args/misc.go index f7930cb..cad71a5 100644 --- a/klog/app/cli/args/misc.go +++ b/klog/app/cli/args/misc.go @@ -13,6 +13,13 @@ type DiffArgs struct { Diff bool `name:"diff" short:"d" help:"Show difference between actual and should-total time."` } +func (args *DiffArgs) GetWarning(filterArgs FilterArgs) service.UsageWarning { + if args.Diff && filterArgs.hasPartialRecordsWithShouldTotal { + return service.DiffEntryFilteringWarning + } + return service.UsageWarning{} +} + type NoStyleArgs struct { NoStyle bool `name:"no-style" help:"Do not style or colour the values."` } diff --git a/klog/app/cli/args/now.go b/klog/app/cli/args/now.go index 7c71646..80bb6ee 100644 --- a/klog/app/cli/args/now.go +++ b/klog/app/cli/args/now.go @@ -34,9 +34,9 @@ func (args *NowArgs) HadOpenRange() bool { return args.hadOpenRange } -func (args *NowArgs) GetNowWarnings() []string { +func (args *NowArgs) GetWarning() service.UsageWarning { if args.Now && !args.hadOpenRange { - return []string{"You specified --now, but there was no open-ended time range."} + return service.PointlessNowWarning } - return nil + return service.UsageWarning{} } diff --git a/klog/app/cli/args/warn.go b/klog/app/cli/args/warn.go index 7111c72..dcdb1c5 100644 --- a/klog/app/cli/args/warn.go +++ b/klog/app/cli/args/warn.go @@ -11,15 +11,17 @@ type WarnArgs struct { NoWarn bool `name:"no-warn" help:"Suppress warnings about potential mistakes or logical errors."` } -func (args *WarnArgs) PrintWarnings(ctx app.Context, records []klog.Record, additionalWarnings []string) { +func (args *WarnArgs) PrintWarnings(ctx app.Context, records []klog.Record, additionalWarnings []service.UsageWarning) { styler, _ := ctx.Serialise() if args.NoWarn { return } - for _, msg := range additionalWarnings { - ctx.Print(prettify.PrettifyGeneralWarning(msg, styler)) - } disabledCheckers := ctx.Config().NoWarnings.UnwrapOr(service.NewDisabledCheckers()) + for _, warn := range additionalWarnings { + if warn != (service.UsageWarning{}) && !disabledCheckers[warn.Name] { + ctx.Print(prettify.PrettifyGeneralWarning(warn.Message, styler)) + } + } service.CheckForWarnings(func(w service.Warning) { ctx.Print(prettify.PrettifyWarning(w, styler)) }, ctx.Now(), records, disabledCheckers) diff --git a/klog/app/cli/print.go b/klog/app/cli/print.go index 7bf8972..e8adf16 100644 --- a/klog/app/cli/print.go +++ b/klog/app/cli/print.go @@ -23,9 +23,10 @@ type Print struct { func (opt *Print) Help() string { return ` Outputs data on the terminal, by default with syntax-highlighting turned on. -Note that the output doesn’t resemble the file byte by byte, but the command may apply some minor clean-ups of the formatting. +Note that the output doesn’t resemble the file verbatim, but it may apply some minor formatting. + +If run with filter flags, it only outputs those entries that match the filter clauses (i.e., you may see partial records). -If run with filter flags, it only outputs those entries that match the filter clauses. You can optionally also sort the records, or print out the total times for each record and entry. ` } diff --git a/klog/app/cli/report.go b/klog/app/cli/report.go index 4c426bd..68edc51 100644 --- a/klog/app/cli/report.go +++ b/klog/app/cli/report.go @@ -145,7 +145,7 @@ func (opt *Report) Run(ctx app.Context) app.Error { } table.Collect(ctx.Print) - opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) + opt.WarnArgs.PrintWarnings(ctx, records, []service.UsageWarning{opt.NowArgs.GetWarning(), opt.DiffArgs.GetWarning(opt.FilterArgs)}) return nil } diff --git a/klog/app/cli/tags.go b/klog/app/cli/tags.go index 37c46b7..7dbe88c 100644 --- a/klog/app/cli/tags.go +++ b/klog/app/cli/tags.go @@ -94,6 +94,6 @@ func (opt *Tags) Run(ctx app.Context) app.Error { } } table.Collect(ctx.Print) - opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) + opt.WarnArgs.PrintWarnings(ctx, records, []service.UsageWarning{opt.NowArgs.GetWarning()}) return nil } diff --git a/klog/app/cli/today.go b/klog/app/cli/today.go index 0aae8fd..57862b5 100644 --- a/klog/app/cli/today.go +++ b/klog/app/cli/today.go @@ -191,7 +191,7 @@ func handle(opt *Today, ctx app.Context) app.Error { } } table.Collect(ctx.Print) - opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) + opt.WarnArgs.PrintWarnings(ctx, records, []service.UsageWarning{opt.NowArgs.GetWarning()}) return nil } diff --git a/klog/app/cli/total.go b/klog/app/cli/total.go index e5e685d..0be2c77 100644 --- a/klog/app/cli/total.go +++ b/klog/app/cli/total.go @@ -59,6 +59,6 @@ func (opt *Total) Run(ctx app.Context) app.Error { return "s" }())) - opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) + opt.WarnArgs.PrintWarnings(ctx, records, []service.UsageWarning{opt.NowArgs.GetWarning(), opt.DiffArgs.GetWarning(opt.FilterArgs)}) return nil } diff --git a/klog/app/config.go b/klog/app/config.go index 0e61889..bfd921b 100644 --- a/klog/app/config.go +++ b/klog/app/config.go @@ -239,7 +239,14 @@ var CONFIG_FILE_ENTRIES = []ConfigFileEntries[any]{ Name: "no_warnings", Help: Help{ Summary: "Whether klog should suppress certain warnings when processing files.", - Value: "The config property must be one (or several comma-separated) of: `UNCLOSED_OPEN_RANGE` (for unclosed open ranges in past records), `FUTURE_ENTRIES` (for records/entries in the future), `OVERLAPPING_RANGES` (for time ranges that overlap), `MORE_THAN_24H` (if there is a record with more than 24h total). Multiple values must be separated by a comma, e.g.: `UNCLOSED_OPEN_RANGE, MORE_THAN_24H`.", + Value: "The config property must be one or several (comma-separated) of: " + + "`UNCLOSED_OPEN_RANGE` (for unclosed open ranges in past records), " + + "`FUTURE_ENTRIES` (for records/entries in the future), " + + "`OVERLAPPING_RANGES` (for time ranges that overlap), " + + "`MORE_THAN_24H` (if there is a record with more than 24h total), " + + "`POINTLESS_NOW` (when using --now without any open ranges), " + + "`DIFF_ENTRY_FILTERING` (for combining --diff and entry-level filtering). " + + "Multiple values must be separated by a comma, e.g.: `UNCLOSED_OPEN_RANGE, MORE_THAN_24H`.", Default: "If absent/empty, klog prints all available warnings.", }, read: func(value string, config *Config) error { diff --git a/klog/service/filter/filter.go b/klog/service/filter/filter.go index a3ed286..034a9b5 100644 --- a/klog/service/filter/filter.go +++ b/klog/service/filter/filter.go @@ -4,20 +4,28 @@ import ( "github.com/jotaen/klog/klog" ) -func Filter(p Predicate, rs []klog.Record) []klog.Record { +func Filter(p Predicate, rs []klog.Record) ([]klog.Record, bool) { var res []klog.Record + hasPartialRecordsWithShouldTotal := false for _, r := range rs { - var es []klog.Entry - for i, e := range r.Entries() { - if p.Matches(queriedEntry{r, r.Entries()[i]}) { - es = append(es, e) + if len(r.Entries()) == 0 && p.MatchesEmptyRecord(r) { + res = append(res, r) + } else { + var es []klog.Entry + for i, e := range r.Entries() { + if p.Matches(queriedEntry{r, r.Entries()[i]}) { + es = append(es, e) + } } + if len(es) == 0 { + continue + } + if len(es) != len(r.Entries()) && r.ShouldTotal() != nil { + hasPartialRecordsWithShouldTotal = true + } + r.SetEntries(es) + res = append(res, r) } - if len(es) == 0 { - continue - } - r.SetEntries(es) - res = append(res, r) } - return res + return res, hasPartialRecordsWithShouldTotal } diff --git a/klog/service/filter/filter_test.go b/klog/service/filter/filter_test.go index 06f37d3..9e9228a 100644 --- a/klog/service/filter/filter_test.go +++ b/klog/service/filter/filter_test.go @@ -4,151 +4,200 @@ import ( "testing" "github.com/jotaen/klog/klog" - "github.com/jotaen/klog/klog/service" + "github.com/jotaen/klog/klog/parser" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func sampleRecordsForQuerying() []klog.Record { - return []klog.Record{ - func() klog.Record { - // Note that records without entries never match any query. - r := klog.NewRecord(klog.Ɀ_Date_(1999, 12, 30)) - r.SetSummary(klog.Ɀ_RecordSummary_("Hello World", "#foo")) - return r - }(), func() klog.Record { - r := klog.NewRecord(klog.Ɀ_Date_(1999, 12, 31)) - r.AddDuration(klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("#bar")) - return r - }(), func() klog.Record { - r := klog.NewRecord(klog.Ɀ_Date_(2000, 1, 1)) - r.SetSummary(klog.Ɀ_RecordSummary_("#foo")) - r.AddDuration(klog.NewDuration(0, 15), nil) - r.AddDuration(klog.NewDuration(6, 0), klog.Ɀ_EntrySummary_("#bar")) - r.AddDuration(klog.NewDuration(0, -30), nil) - return r - }(), func() klog.Record { - r := klog.NewRecord(klog.Ɀ_Date_(2000, 1, 2)) - r.SetSummary(klog.Ɀ_RecordSummary_("#foo")) - r.AddDuration(klog.NewDuration(7, 0), nil) - return r - }(), func() klog.Record { - r := klog.NewRecord(klog.Ɀ_Date_(2000, 1, 3)) - r.SetSummary(klog.Ɀ_RecordSummary_("#foo=a")) - r.AddDuration(klog.NewDuration(4, 0), klog.Ɀ_EntrySummary_("test", "foo #bar=1")) - r.AddDuration(klog.NewDuration(4, 0), klog.Ɀ_EntrySummary_("#bar=2")) - r.Start(klog.NewOpenRange(klog.Ɀ_Time_(12, 00)), nil) - return r - }(), + rs, _, err := parser.NewSerialParser().Parse(` +1999-12-30 +Hello World #foo + +1999-12-31 + 5h #bar [300] + +2000-01-01 +#foo + 1:30-1:45 [14] + 6h #bar [360] + -30m [-30] + +2000-01-02 +#foo + 7h [420] + +2000-01-03 +#foo=a + 4h #bar=1 [240] + 3h #bar=2 [180] + 12:00-? [0] +`) + if err != nil { + panic(err) + } + return rs +} + +type expect struct { + date klog.Date + durations []int +} + +func assertResult(t *testing.T, es []expect, rs []klog.Record) { + require.Equal(t, len(es), len(rs), "unexpected number of records") + for i, expct := range es { + assert.Equal(t, expct.date, rs[i].Date(), "unexpected date") + require.Equal(t, len(expct.durations), len(rs[i].Entries()), "unexpected number of entries") + actualDurations := make([]int, len(rs[i].Entries())) + for j, e := range rs[i].Entries() { + actualDurations[j] = e.Duration().InMinutes() + } + assert.Equal(t, expct.durations, actualDurations, "unexpected duration") } } func TestQueryWithNoClauses(t *testing.T) { - rs := Filter(And{}, sampleRecordsForQuerying()) - require.Len(t, rs, 4) - assert.Equal(t, klog.NewDuration(5+6+7+8, -30+15), service.Total(rs...)) + rs, hprws := Filter(And{}, sampleRecordsForQuerying()) + assert.False(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 30), []int{}}, + {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{15, 360, -30}}, + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180, 0}}, + }, rs) } func TestQueryWithAtDate(t *testing.T) { - rs := Filter(IsInDateRange{ + rs, hprws := Filter(IsInDateRange{ From: klog.Ɀ_Date_(2000, 1, 2), To: klog.Ɀ_Date_(2000, 1, 2), }, sampleRecordsForQuerying()) - require.Len(t, rs, 1) - assert.Equal(t, klog.NewDuration(7, 0), service.Total(rs...)) + assert.False(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + }, rs) } func TestQueryWithAfter(t *testing.T) { - rs := Filter(IsInDateRange{ + rs, hprws := Filter(IsInDateRange{ From: klog.Ɀ_Date_(2000, 1, 1), To: nil, }, sampleRecordsForQuerying()) - require.Len(t, rs, 3) - assert.Equal(t, 1, rs[0].Date().Day()) - assert.Equal(t, 2, rs[1].Date().Day()) - assert.Equal(t, 3, rs[2].Date().Day()) + assert.False(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 1), []int{15, 360, -30}}, + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180, 0}}, + }, rs) } func TestQueryWithBefore(t *testing.T) { - rs := Filter(IsInDateRange{ + rs, hprws := Filter(IsInDateRange{ From: nil, To: klog.Ɀ_Date_(2000, 1, 1), }, sampleRecordsForQuerying()) - require.Len(t, rs, 2) - assert.Equal(t, 31, rs[0].Date().Day()) - assert.Equal(t, 1, rs[1].Date().Day()) + assert.False(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 30), []int{}}, + {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{15, 360, -30}}, + }, rs) } func TestQueryWithTagOnEntries(t *testing.T) { - rs := Filter(HasTag{klog.NewTagOrPanic("bar", "")}, sampleRecordsForQuerying()) - require.Len(t, rs, 3) - assert.Equal(t, 31, rs[0].Date().Day()) - assert.Equal(t, 1, rs[1].Date().Day()) - assert.Equal(t, 3, rs[2].Date().Day()) - assert.Equal(t, klog.NewDuration(5+8+6, 0), service.Total(rs...)) + rs, hprws := Filter(HasTag{klog.NewTagOrPanic("bar", "")}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{360}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + }, rs) } func TestQueryWithTagOnOverallSummary(t *testing.T) { - rs := Filter(HasTag{klog.NewTagOrPanic("foo", "")}, sampleRecordsForQuerying()) - require.Len(t, rs, 3) - assert.Equal(t, 1, rs[0].Date().Day()) - assert.Equal(t, 2, rs[1].Date().Day()) - assert.Equal(t, 3, rs[2].Date().Day()) - assert.Equal(t, klog.NewDuration(6+7+8, -30+15), service.Total(rs...)) + rs, hprws := Filter(HasTag{klog.NewTagOrPanic("foo", "")}, sampleRecordsForQuerying()) + assert.False(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 30), []int{}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{15, 360, -30}}, + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180, 0}}, + }, rs) } func TestQueryWithTagOnEntriesAndInSummary(t *testing.T) { - rs := Filter(And{[]Predicate{HasTag{klog.NewTagOrPanic("foo", "")}, HasTag{klog.NewTagOrPanic("bar", "")}}}, sampleRecordsForQuerying()) - require.Len(t, rs, 2) - assert.Equal(t, 1, rs[0].Date().Day()) - assert.Equal(t, 3, rs[1].Date().Day()) - assert.Equal(t, klog.NewDuration(8+6, 0), service.Total(rs...)) + rs, hprws := Filter(And{[]Predicate{HasTag{klog.NewTagOrPanic("foo", "")}, HasTag{klog.NewTagOrPanic("bar", "")}}}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 1), []int{360}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + }, rs) } func TestQueryWithTagValues(t *testing.T) { - rs := Filter(HasTag{klog.NewTagOrPanic("foo", "a")}, sampleRecordsForQuerying()) - require.Len(t, rs, 1) - assert.Equal(t, 3, rs[0].Date().Day()) - assert.Equal(t, klog.NewDuration(8, 0), service.Total(rs...)) + rs, hprws := Filter(HasTag{klog.NewTagOrPanic("foo", "a")}, sampleRecordsForQuerying()) + assert.False(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180, 0}}, + }, rs) } func TestQueryWithTagValuesInEntries(t *testing.T) { - rs := Filter(HasTag{klog.NewTagOrPanic("bar", "1")}, sampleRecordsForQuerying()) - require.Len(t, rs, 1) - assert.Equal(t, 3, rs[0].Date().Day()) - assert.Equal(t, klog.NewDuration(4, 0), service.Total(rs...)) + rs, hprws := Filter(HasTag{klog.NewTagOrPanic("bar", "1")}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 3), []int{240}}, + }, rs) } func TestQueryWithTagNonMatchingValues(t *testing.T) { - rs := Filter(HasTag{klog.NewTagOrPanic("bar", "3")}, sampleRecordsForQuerying()) - require.Len(t, rs, 0) + rs, hprws := Filter(HasTag{klog.NewTagOrPanic("bar", "3")}, sampleRecordsForQuerying()) + assert.False(t, hprws) + assertResult(t, []expect{}, rs) } func TestQueryWithEntryTypes(t *testing.T) { { - rs := Filter(IsEntryType{ENTRY_TYPE_DURATION}, sampleRecordsForQuerying()) - require.Len(t, rs, 4) - assert.Equal(t, klog.NewDuration(0, 1545), service.Total(rs...)) + rs, hprws := Filter(IsEntryType{ENTRY_TYPE_DURATION}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{360, -30}}, + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + }, rs) } { - rs := Filter(IsEntryType{ENTRY_TYPE_DURATION_NEGATIVE}, sampleRecordsForQuerying()) - require.Len(t, rs, 1) - assert.Equal(t, 1, rs[0].Date().Day()) - assert.Equal(t, klog.NewDuration(0, -30), service.Total(rs...)) + rs, hprws := Filter(IsEntryType{ENTRY_TYPE_DURATION_NEGATIVE}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 1), []int{-30}}, + }, rs) } { - rs := Filter(IsEntryType{ENTRY_TYPE_DURATION_POSITIVE}, sampleRecordsForQuerying()) - require.Len(t, rs, 4) - assert.Equal(t, klog.NewDuration(0, 1575), service.Total(rs...)) + rs, hprws := Filter(IsEntryType{ENTRY_TYPE_DURATION_POSITIVE}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{360}}, + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + }, rs) } { - rs := Filter(IsEntryType{ENTRY_TYPE_RANGE}, sampleRecordsForQuerying()) - require.Len(t, rs, 0) - assert.Equal(t, klog.NewDuration(0, 0), service.Total(rs...)) + rs, hprws := Filter(IsEntryType{ENTRY_TYPE_RANGE}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 1), []int{15}}, + }, rs) } { - rs := Filter(IsEntryType{ENTRY_TYPE_OPEN_RANGE}, sampleRecordsForQuerying()) - require.Len(t, rs, 1) - assert.Equal(t, klog.NewDuration(0, 0), service.Total(rs...)) + rs, hprws := Filter(IsEntryType{ENTRY_TYPE_OPEN_RANGE}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 3), []int{0}}, + }, rs) } } diff --git a/klog/service/filter/predicate.go b/klog/service/filter/predicate.go index 6c1778a..7494956 100644 --- a/klog/service/filter/predicate.go +++ b/klog/service/filter/predicate.go @@ -14,6 +14,7 @@ type queriedEntry struct { type Predicate interface { Matches(queriedEntry) bool + MatchesEmptyRecord(klog.Record) bool } type IsInDateRange struct { @@ -22,17 +23,21 @@ type IsInDateRange struct { } func (i IsInDateRange) Matches(e queriedEntry) bool { + return i.MatchesEmptyRecord(e.parent) +} + +func (i IsInDateRange) MatchesEmptyRecord(r klog.Record) bool { isAfter := func() bool { if i.From == nil { return true } - return e.parent.Date().IsAfterOrEqual(i.From) + return r.Date().IsAfterOrEqual(i.From) }() isBefore := func() bool { if i.To == nil { return true } - return i.To.IsAfterOrEqual(e.parent.Date()) + return i.To.IsAfterOrEqual(r.Date()) }() return isAfter && isBefore } @@ -42,7 +47,11 @@ type HasTag struct { } func (h HasTag) Matches(e queriedEntry) bool { - return e.parent.Summary().Tags().Contains(h.Tag) || e.entry.Summary().Tags().Contains(h.Tag) + return h.MatchesEmptyRecord(e.parent) || e.entry.Summary().Tags().Contains(h.Tag) +} + +func (h HasTag) MatchesEmptyRecord(r klog.Record) bool { + return r.Summary().Tags().Contains(h.Tag) } type And struct { @@ -58,6 +67,15 @@ func (a And) Matches(e queriedEntry) bool { return true } +func (a And) MatchesEmptyRecord(r klog.Record) bool { + for _, p := range a.Predicates { + if !p.MatchesEmptyRecord(r) { + return false + } + } + return true +} + type Or struct { Predicates []Predicate } @@ -71,6 +89,15 @@ func (o Or) Matches(e queriedEntry) bool { return false } +func (o Or) MatchesEmptyRecord(r klog.Record) bool { + for _, p := range o.Predicates { + if p.MatchesEmptyRecord(r) { + return true + } + } + return false +} + type Not struct { Predicate Predicate } @@ -79,6 +106,10 @@ func (n Not) Matches(e queriedEntry) bool { return !n.Predicate.Matches(e) } +func (n Not) MatchesEmptyRecord(r klog.Record) bool { + return !n.Predicate.MatchesEmptyRecord(r) +} + type EntryType string const ( @@ -126,3 +157,7 @@ func (t IsEntryType) Matches(e queriedEntry) bool { return t.Type == ENTRY_TYPE_OPEN_RANGE }) } + +func (t IsEntryType) MatchesEmptyRecord(r klog.Record) bool { + return false +} diff --git a/klog/service/warning.go b/klog/service/warning.go index 425a1d3..3bb3128 100644 --- a/klog/service/warning.go +++ b/klog/service/warning.go @@ -7,12 +7,29 @@ import ( "github.com/jotaen/klog/klog" ) -// Warning contains information for helping locate an issue. +// Warning contains information for helping locate an issue within the data. type Warning struct { date klog.Date origin checker } +// UsageWarning contains information for avoiding potential usage issues. +type UsageWarning struct { + Name string + Message string +} + +var ( + PointlessNowWarning = UsageWarning{ + Name: "POINTLESS_NOW", + Message: "You specified --now, but there was no open-ended time range", + } + DiffEntryFilteringWarning = UsageWarning{ + Name: "DIFF_ENTRY_FILTERING", + Message: "Combining --diff and filtering at entry-level may not yield sensible results", + } +) + // Date is the date of the record that the warning refers to. func (w Warning) Date() klog.Date { return w.date @@ -39,6 +56,8 @@ func NewDisabledCheckers() DisabledCheckers { (&futureEntriesChecker{}).Name(): false, (&overlappingTimeRangesChecker{}).Name(): false, (&moreThan24HoursChecker{}).Name(): false, + PointlessNowWarning.Name: false, + DiffEntryFilteringWarning.Name: false, } } From d4c73228ca3282d8361a610421e93095b25feba8 Mon Sep 17 00:00:00 2001 From: Jan Heuermann Date: Sat, 21 Feb 2026 15:42:42 +0100 Subject: [PATCH 2/5] Phrasing --- klog/service/warning.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/klog/service/warning.go b/klog/service/warning.go index 99c6413..0104675 100644 --- a/klog/service/warning.go +++ b/klog/service/warning.go @@ -20,7 +20,7 @@ var ( } DiffEntryFilteringWarning = UsageWarning{ Name: "DIFF_ENTRY_FILTERING", - Message: "Combining --diff and filtering at entry-level may not yield sensible results", + Message: "Combining --diff and filtering at entry-level may yield nonsensical results", } ) From dcd02860c87fbe211d1644f165bae6cf385ed919 Mon Sep 17 00:00:00 2001 From: Jan Heuermann Date: Sat, 21 Feb 2026 16:03:14 +0100 Subject: [PATCH 3/5] Add complex filter tests --- klog/service/filter/filter_test.go | 62 +++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/klog/service/filter/filter_test.go b/klog/service/filter/filter_test.go index 9e9228a..c1128b0 100644 --- a/klog/service/filter/filter_test.go +++ b/klog/service/filter/filter_test.go @@ -12,26 +12,27 @@ import ( func sampleRecordsForQuerying() []klog.Record { rs, _, err := parser.NewSerialParser().Parse(` 1999-12-30 -Hello World #foo +Hello World #foo #first 1999-12-31 - 5h #bar [300] + 5h #bar [300] 2000-01-01 -#foo - 1:30-1:45 [14] - 6h #bar [360] - -30m [-30] +#foo #third + 1:30-1:45 [15] + 6h #bar [360] + -30m [-30] 2000-01-02 -#foo - 7h [420] +#foo #fourth + 7h #xyz [420] 2000-01-03 -#foo=a - 4h #bar=1 [240] - 3h #bar=2 [180] - 12:00-? [0] +#foo=a #fifth + 12:00-16:00 [240] + #bar=1 + 3h #bar=2 [180] + 12:00-? [0] `) if err != nil { panic(err) @@ -166,7 +167,7 @@ func TestQueryWithEntryTypes(t *testing.T) { {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, {klog.Ɀ_Date_(2000, 1, 1), []int{360, -30}}, {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, - {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{180}}, }, rs) } { @@ -183,7 +184,7 @@ func TestQueryWithEntryTypes(t *testing.T) { {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, {klog.Ɀ_Date_(2000, 1, 1), []int{360}}, {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, - {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{180}}, }, rs) } { @@ -191,6 +192,7 @@ func TestQueryWithEntryTypes(t *testing.T) { assert.True(t, hprws) assertResult(t, []expect{ {klog.Ɀ_Date_(2000, 1, 1), []int{15}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240}}, }, rs) } { @@ -201,3 +203,35 @@ func TestQueryWithEntryTypes(t *testing.T) { }, rs) } } + +func TestComplexFilterQueries(t *testing.T) { + { + rs, hprws := Filter(Or{[]Predicate{ + IsInDateRange{From: klog.Ɀ_Date_(2000, 1, 2), To: nil}, + HasTag{klog.NewTagOrPanic("first", "")}, + And{[]Predicate{ + Not{HasTag{klog.NewTagOrPanic("something", "1")}}, + IsEntryType{ENTRY_TYPE_RANGE}, + }}, + }}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 30), []int{}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{15}}, + {klog.Ɀ_Date_(2000, 1, 2), []int{420}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180, 0}}, + }, rs) + } + { + rs, hprws := Filter(And{[]Predicate{ + IsInDateRange{From: klog.Ɀ_Date_(2000, 1, 1), To: klog.Ɀ_Date_(2000, 1, 3)}, + HasTag{klog.NewTagOrPanic("bar", "")}, + Not{HasTag{klog.NewTagOrPanic("third", "")}}, + IsEntryType{ENTRY_TYPE_RANGE}, + }}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(2000, 1, 3), []int{240}}, + }, rs) + } +} From 03514ca13cd0c25b6172896538a16f3db9556835 Mon Sep 17 00:00:00 2001 From: Jan Heuermann Date: Sat, 21 Feb 2026 16:11:08 +0100 Subject: [PATCH 4/5] Docs, refactor --- klog/app/cli/args/filter.go | 6 ++--- klog/service/filter/filter.go | 7 +++++- klog/service/filter/predicate.go | 43 ++++++++++++++++---------------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/klog/app/cli/args/filter.go b/klog/app/cli/args/filter.go index 4686f37..c0d855c 100644 --- a/klog/app/cli/args/filter.go +++ b/klog/app/cli/args/filter.go @@ -127,9 +127,9 @@ func (args *FilterArgs) ApplyFilter(now gotime.Time, rs []klog.Record) ([]klog.R // Apply filters, if applicable: if len(predicates) > 0 { - hasPartialRecordsWithShouldTotal := false - rs, hasPartialRecordsWithShouldTotal = filter.Filter(filter.And{Predicates: predicates}, rs) - args.hasPartialRecordsWithShouldTotal = hasPartialRecordsWithShouldTotal + hprws := false + rs, hprws = filter.Filter(filter.And{Predicates: predicates}, rs) + args.hasPartialRecordsWithShouldTotal = hprws } return rs, nil diff --git a/klog/service/filter/filter.go b/klog/service/filter/filter.go index 034a9b5..afbcbfc 100644 --- a/klog/service/filter/filter.go +++ b/klog/service/filter/filter.go @@ -4,6 +4,11 @@ import ( "github.com/jotaen/klog/klog" ) +// Filter goes through a list of records and only keeps those that match the +// given predicate. The records may be returned partially, keeping only those +// entries that match the predicate. The second return value indicates whether +// there are partial records with ShouldTotal set, as this may yield nonsensical +// results in a subsequent evaluation. func Filter(p Predicate, rs []klog.Record) ([]klog.Record, bool) { var res []klog.Record hasPartialRecordsWithShouldTotal := false @@ -13,7 +18,7 @@ func Filter(p Predicate, rs []klog.Record) ([]klog.Record, bool) { } else { var es []klog.Entry for i, e := range r.Entries() { - if p.Matches(queriedEntry{r, r.Entries()[i]}) { + if p.Matches(r, r.Entries()[i]) { es = append(es, e) } } diff --git a/klog/service/filter/predicate.go b/klog/service/filter/predicate.go index 7494956..7b30b8f 100644 --- a/klog/service/filter/predicate.go +++ b/klog/service/filter/predicate.go @@ -7,23 +7,22 @@ import ( "github.com/jotaen/klog/klog" ) -type queriedEntry struct { - parent klog.Record - entry klog.Entry -} - +// Predicate is the generic base type for all predicates. type Predicate interface { - Matches(queriedEntry) bool + // Matches returns true if the record’s entry satisfies the predicate. + Matches(klog.Record, klog.Entry) bool + // MatchesEmptyRecord returns true if an empty record (without any entries) + // satisfies the predicate. MatchesEmptyRecord(klog.Record) bool } type IsInDateRange struct { - From klog.Date - To klog.Date + From klog.Date // May be nil to denote open range. + To klog.Date // May be nil to denote open range. } -func (i IsInDateRange) Matches(e queriedEntry) bool { - return i.MatchesEmptyRecord(e.parent) +func (i IsInDateRange) Matches(r klog.Record, e klog.Entry) bool { + return i.MatchesEmptyRecord(r) } func (i IsInDateRange) MatchesEmptyRecord(r klog.Record) bool { @@ -46,8 +45,8 @@ type HasTag struct { Tag klog.Tag } -func (h HasTag) Matches(e queriedEntry) bool { - return h.MatchesEmptyRecord(e.parent) || e.entry.Summary().Tags().Contains(h.Tag) +func (h HasTag) Matches(r klog.Record, e klog.Entry) bool { + return h.MatchesEmptyRecord(r) || e.Summary().Tags().Contains(h.Tag) } func (h HasTag) MatchesEmptyRecord(r klog.Record) bool { @@ -58,9 +57,9 @@ type And struct { Predicates []Predicate } -func (a And) Matches(e queriedEntry) bool { +func (a And) Matches(r klog.Record, e klog.Entry) bool { for _, p := range a.Predicates { - if !p.Matches(e) { + if !p.Matches(r, e) { return false } } @@ -80,9 +79,9 @@ type Or struct { Predicates []Predicate } -func (o Or) Matches(e queriedEntry) bool { +func (o Or) Matches(r klog.Record, e klog.Entry) bool { for _, p := range o.Predicates { - if p.Matches(e) { + if p.Matches(r, e) { return true } } @@ -102,8 +101,8 @@ type Not struct { Predicate Predicate } -func (n Not) Matches(e queriedEntry) bool { - return !n.Predicate.Matches(e) +func (n Not) Matches(r klog.Record, e klog.Entry) bool { + return !n.Predicate.Matches(r, e) } func (n Not) MatchesEmptyRecord(r klog.Record) bool { @@ -139,17 +138,17 @@ type IsEntryType struct { Type EntryType } -func (t IsEntryType) Matches(e queriedEntry) bool { - return klog.Unbox[bool](&e.entry, func(r klog.Range) bool { +func (t IsEntryType) Matches(r klog.Record, e klog.Entry) bool { + return klog.Unbox[bool](&e, func(r klog.Range) bool { return t.Type == ENTRY_TYPE_RANGE }, func(duration klog.Duration) bool { if t.Type == ENTRY_TYPE_DURATION { return true } - if t.Type == ENTRY_TYPE_DURATION_POSITIVE && e.entry.Duration().InMinutes() >= 0 { + if t.Type == ENTRY_TYPE_DURATION_POSITIVE && e.Duration().InMinutes() >= 0 { return true } - if t.Type == ENTRY_TYPE_DURATION_NEGATIVE && e.entry.Duration().InMinutes() < 0 { + if t.Type == ENTRY_TYPE_DURATION_NEGATIVE && e.Duration().InMinutes() < 0 { return true } return false From c76b0b5fa974ece338d6621ff270b4e703f24544 Mon Sep 17 00:00:00 2001 From: Jan Heuermann Date: Sat, 21 Feb 2026 17:51:11 +0100 Subject: [PATCH 5/5] Docs, clean-ups --- klog/app/cli/args/misc.go | 3 +++ klog/app/cli/args/now.go | 2 ++ klog/app/cli/print.go | 2 +- klog/app/config.go | 2 +- klog/service/filter/filter.go | 6 +++--- klog/service/filter/filter_test.go | 25 +++++++++++++++---------- klog/service/warning.go | 2 +- 7 files changed, 26 insertions(+), 16 deletions(-) diff --git a/klog/app/cli/args/misc.go b/klog/app/cli/args/misc.go index cad71a5..f6b232c 100644 --- a/klog/app/cli/args/misc.go +++ b/klog/app/cli/args/misc.go @@ -13,6 +13,9 @@ type DiffArgs struct { Diff bool `name:"diff" short:"d" help:"Show difference between actual and should-total time."` } +// GetWarning returns a warning if the user applied entry-level filtering (partial +// records) *and* requested to compute the should-total diff, as that may yield +// nonsensical results. func (args *DiffArgs) GetWarning(filterArgs FilterArgs) service.UsageWarning { if args.Diff && filterArgs.hasPartialRecordsWithShouldTotal { return service.DiffEntryFilteringWarning diff --git a/klog/app/cli/args/now.go b/klog/app/cli/args/now.go index 80bb6ee..251280b 100644 --- a/klog/app/cli/args/now.go +++ b/klog/app/cli/args/now.go @@ -34,6 +34,8 @@ func (args *NowArgs) HadOpenRange() bool { return args.hadOpenRange } +// GetWarning warns the user that they specified the --now flag but there actually +// weren’t any closable ranges in the data. func (args *NowArgs) GetWarning() service.UsageWarning { if args.Now && !args.hadOpenRange { return service.PointlessNowWarning diff --git a/klog/app/cli/print.go b/klog/app/cli/print.go index e8adf16..82216d7 100644 --- a/klog/app/cli/print.go +++ b/klog/app/cli/print.go @@ -25,7 +25,7 @@ func (opt *Print) Help() string { Outputs data on the terminal, by default with syntax-highlighting turned on. Note that the output doesn’t resemble the file verbatim, but it may apply some minor formatting. -If run with filter flags, it only outputs those entries that match the filter clauses (i.e., you may see partial records). +If run with filter flags, it only outputs those entries that match the filter clauses. E.g., when filtering for a tag that only appears particular entries, it will exclude all other entries from that record. You can optionally also sort the records, or print out the total times for each record and entry. ` diff --git a/klog/app/config.go b/klog/app/config.go index bfd921b..649e3e4 100644 --- a/klog/app/config.go +++ b/klog/app/config.go @@ -245,7 +245,7 @@ var CONFIG_FILE_ENTRIES = []ConfigFileEntries[any]{ "`OVERLAPPING_RANGES` (for time ranges that overlap), " + "`MORE_THAN_24H` (if there is a record with more than 24h total), " + "`POINTLESS_NOW` (when using --now without any open ranges), " + - "`DIFF_ENTRY_FILTERING` (for combining --diff and entry-level filtering). " + + "`ENTRY_FILTERED_DIFFING` (when combining --diff and entry-level filtering). " + "Multiple values must be separated by a comma, e.g.: `UNCLOSED_OPEN_RANGE, MORE_THAN_24H`.", Default: "If absent/empty, klog prints all available warnings.", }, diff --git a/klog/service/filter/filter.go b/klog/service/filter/filter.go index afbcbfc..29f68ce 100644 --- a/klog/service/filter/filter.go +++ b/klog/service/filter/filter.go @@ -6,9 +6,9 @@ import ( // Filter goes through a list of records and only keeps those that match the // given predicate. The records may be returned partially, keeping only those -// entries that match the predicate. The second return value indicates whether -// there are partial records with ShouldTotal set, as this may yield nonsensical -// results in a subsequent evaluation. +// entries that match the predicate. +// The second return value indicates whether there are partial records with a +// should-total set, as this may yield nonsensical results in a subsequent evaluation. func Filter(p Predicate, rs []klog.Record) ([]klog.Record, bool) { var res []klog.Record hasPartialRecordsWithShouldTotal := false diff --git a/klog/service/filter/filter_test.go b/klog/service/filter/filter_test.go index c1128b0..db0fc06 100644 --- a/klog/service/filter/filter_test.go +++ b/klog/service/filter/filter_test.go @@ -11,6 +11,9 @@ import ( func sampleRecordsForQuerying() []klog.Record { rs, _, err := parser.NewSerialParser().Parse(` +1999-12-29 +No tags here + 1999-12-30 Hello World #foo #first @@ -62,6 +65,7 @@ func TestQueryWithNoClauses(t *testing.T) { rs, hprws := Filter(And{}, sampleRecordsForQuerying()) assert.False(t, hprws) assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 29), []int{}}, {klog.Ɀ_Date_(1999, 12, 30), []int{}}, {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, {klog.Ɀ_Date_(2000, 1, 1), []int{15, 360, -30}}, @@ -101,22 +105,13 @@ func TestQueryWithBefore(t *testing.T) { }, sampleRecordsForQuerying()) assert.False(t, hprws) assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 29), []int{}}, {klog.Ɀ_Date_(1999, 12, 30), []int{}}, {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, {klog.Ɀ_Date_(2000, 1, 1), []int{15, 360, -30}}, }, rs) } -func TestQueryWithTagOnEntries(t *testing.T) { - rs, hprws := Filter(HasTag{klog.NewTagOrPanic("bar", "")}, sampleRecordsForQuerying()) - assert.True(t, hprws) - assertResult(t, []expect{ - {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, - {klog.Ɀ_Date_(2000, 1, 1), []int{360}}, - {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, - }, rs) -} - func TestQueryWithTagOnOverallSummary(t *testing.T) { rs, hprws := Filter(HasTag{klog.NewTagOrPanic("foo", "")}, sampleRecordsForQuerying()) assert.False(t, hprws) @@ -128,6 +123,16 @@ func TestQueryWithTagOnOverallSummary(t *testing.T) { }, rs) } +func TestQueryWithTagOnEntries(t *testing.T) { + rs, hprws := Filter(HasTag{klog.NewTagOrPanic("bar", "")}, sampleRecordsForQuerying()) + assert.True(t, hprws) + assertResult(t, []expect{ + {klog.Ɀ_Date_(1999, 12, 31), []int{300}}, + {klog.Ɀ_Date_(2000, 1, 1), []int{360}}, + {klog.Ɀ_Date_(2000, 1, 3), []int{240, 180}}, + }, rs) +} + func TestQueryWithTagOnEntriesAndInSummary(t *testing.T) { rs, hprws := Filter(And{[]Predicate{HasTag{klog.NewTagOrPanic("foo", "")}, HasTag{klog.NewTagOrPanic("bar", "")}}}, sampleRecordsForQuerying()) assert.True(t, hprws) diff --git a/klog/service/warning.go b/klog/service/warning.go index 0104675..e1cfa20 100644 --- a/klog/service/warning.go +++ b/klog/service/warning.go @@ -19,7 +19,7 @@ var ( Message: "You specified --now, but there was no open-ended time range", } DiffEntryFilteringWarning = UsageWarning{ - Name: "DIFF_ENTRY_FILTERING", + Name: "ENTRY_FILTERED_DIFFING", Message: "Combining --diff and filtering at entry-level may yield nonsensical results", } )