Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package klog for openSUSE:Factory checked in at 2025-07-02 12:12:58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/klog (Old) and /work/SRC/openSUSE:Factory/.klog.new.7067 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "klog" Wed Jul 2 12:12:58 2025 rev:4 rq:1289751 version:6.6 Changes: -------- --- /work/SRC/openSUSE:Factory/klog/klog.changes 2024-11-30 13:28:59.448566796 +0100 +++ /work/SRC/openSUSE:Factory/.klog.new.7067/klog.changes 2025-07-02 12:15:57.574853286 +0200 @@ -1,0 +2,14 @@ +Wed Jul 02 05:20:24 UTC 2025 - Johannes Kastl <opensuse_buildserv...@ojkastl.de> + +- Update to version 6.6: + * [ FEATURE ] Add --chart (-c) flag to klog report command, which + includes bar chart renderings in the output, to allow for + convenient visual comparison at a glance. (See also --chart-res + for the chart resolution.) + * [ FEATURE ] Add --with-untagged (-u) flag to klog tags command, + which takes into account the remainder of any untagged entries. + * [ FIX ] Implement internal protection mechanism against integer + overflow. (This, however, is only relevant when dealing with a + few trillion years worth of time tracking data.) + +------------------------------------------------------------------- Old: ---- klog-6.5.obscpio New: ---- klog-6.6.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ klog.spec ++++++ --- /var/tmp/diff_new_pack.8yTM9r/_old 2025-07-02 12:15:58.410887945 +0200 +++ /var/tmp/diff_new_pack.8yTM9r/_new 2025-07-02 12:15:58.410887945 +0200 @@ -1,7 +1,7 @@ # # spec file for package klog # -# Copyright (c) 2024 SUSE LLC +# Copyright (c) 2025 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -17,7 +17,7 @@ Name: klog -Version: 6.5 +Version: 6.6 Release: 0 Summary: Time tracking in a human-readable, plain-text file format License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.8yTM9r/_old 2025-07-02 12:15:58.446889438 +0200 +++ /var/tmp/diff_new_pack.8yTM9r/_new 2025-07-02 12:15:58.450889604 +0200 @@ -3,19 +3,22 @@ <param name="url">https://github.com/jotaen/klog</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v6.5</param> + <param name="revision">v6.6</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> </service> <service name="set_version" mode="manual"> </service> - <service name="tar" mode="buildtime"/> + <service name="go_modules" mode="manual"> + <param name="basename">klog-6.6</param> + </service> + <!-- services below are running at buildtime --> + <service name="tar" mode="buildtime"> + </service> <service name="recompress" mode="buildtime"> <param name="file">*.tar</param> <param name="compression">gz</param> </service> - <service name="go_modules" mode="manual"> - </service> </services> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.8yTM9r/_old 2025-07-02 12:15:58.474890599 +0200 +++ /var/tmp/diff_new_pack.8yTM9r/_new 2025-07-02 12:15:58.478890764 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/jotaen/klog</param> - <param name="changesrevision">6f2c7a19f86b701b23cead08ac6b9a917a594e15</param></service></servicedata> + <param name="changesrevision">7b3cc55b96ab55203a6375c6eb6dff7ec7e12cd5</param></service></servicedata> (No newline at EOF) ++++++ klog-6.5.obscpio -> klog-6.6.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/.github/ISSUE_TEMPLATE/config.yml new/klog-6.6/.github/ISSUE_TEMPLATE/config.yml --- old/klog-6.5/.github/ISSUE_TEMPLATE/config.yml 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/.github/ISSUE_TEMPLATE/config.yml 2025-07-01 17:05:19.000000000 +0200 @@ -1,3 +1,4 @@ +blank_issues_enabled: false contact_links: - name: Feature ideas, feedback, or questions url: https://github.com/jotaen/klog/discussions diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/.github/benchmark.go new/klog-6.6/.github/benchmark.go --- old/klog-6.5/.github/benchmark.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/.github/benchmark.go 2025-07-01 17:05:19.000000000 +0200 @@ -25,6 +25,10 @@ // Generate records date := klog.Ɀ_Date_(0, 1, 1) for i := 0; i < iterations; i++ { + if date.IsEqualTo(klog.Ɀ_Date_(9999, 12, 31)) { + // Prevent date overflow + date = klog.Ɀ_Date_(0, 1, 1) + } date = date.PlusDays(1) r := klog.NewRecord(date) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/.github/workflows/ci.yml new/klog-6.6/.github/workflows/ci.yml --- old/klog-6.5/.github/workflows/ci.yml 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/.github/workflows/ci.yml 2025-07-01 17:05:19.000000000 +0200 @@ -1,9 +1,9 @@ name: CI on: [push, pull_request] env: - GO_VERSION: '1.23' - STATIC_CHECK_VERSION: '2024.1.1' - COUNT_LOC_DOCKER_IMAGE: 'aldanial/cloc:1.98' + GO_VERSION: '1.24' + STATIC_CHECK_VERSION: '2025.1' + COUNT_LOC_DOCKER_IMAGE: 'aldanial/cloc:2.02' jobs: statistics: name: Statistics diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/.github/workflows/release.yml new/klog-6.6/.github/workflows/release.yml --- old/klog-6.5/.github/workflows/release.yml 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/.github/workflows/release.yml 2025-07-01 17:05:19.000000000 +0200 @@ -6,7 +6,7 @@ description: 'Release id (tag name)' required: true env: - GO_VERSION: '1.23' + GO_VERSION: '1.24' jobs: create_release: name: Create release draft diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/CHANGELOG.md new/klog-6.6/CHANGELOG.md --- old/klog-6.5/CHANGELOG.md 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/CHANGELOG.md 2025-07-01 17:05:19.000000000 +0200 @@ -1,6 +1,16 @@ # Changelog **Summary of changes of the command line tool** +## v6.6 (2025-07-01) +- **[ FEATURE ]** Add `--chart` (`-c`) flag to `klog report` command, which + includes bar chart renderings in the output, to allow for convenient visual + comparison at a glance. (See also `--chart-res` for the chart resolution.) +- **[ FEATURE ]** Add `--with-untagged` (`-u`) flag to `klog tags` command, + which takes into account the remainder of any untagged entries. +- **[ FIX ]** Implement internal protection mechanism against integer overflow. + (This, however, is only relevant when dealing with a few trillion years worth of + time tracking data.) + ## v6.5 (2024-11-28) - **[ FEATURE ]** Introduce `basic` colour scheme based on the basic 8-bit ANSI colours – see `colour_scheme` entry in `config.ini` file. (Run `klog config` to diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/go.mod new/klog-6.6/go.mod --- old/klog-6.5/go.mod 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/go.mod 2025-07-01 17:05:19.000000000 +0200 @@ -1,15 +1,16 @@ module github.com/jotaen/klog -go 1.23 +go 1.24 require ( - cloud.google.com/go v0.116.0 - github.com/alecthomas/kong v1.4.0 + cloud.google.com/go v0.121.3 + github.com/alecthomas/kong v1.12.0 github.com/jotaen/genie v0.0.1 github.com/jotaen/kong-completion v0.0.6 + github.com/jotaen/safemath v0.0.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/posener/complete v1.2.3 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 ) require ( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/go.sum new/klog-6.6/go.sum --- old/klog-6.5/go.sum 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/go.sum 2025-07-01 17:05:19.000000000 +0200 @@ -1,16 +1,16 @@ -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= +cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.4.0 h1:UL7tzGMnnY0YRMMvJyITIRX1EpO6RbBRZDNcCevy3HA= -github.com/alecthomas/kong v1.4.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/kong v1.12.0 h1:oKd/0fHSdajj5PfGDd3ScvEvpVJf9mT2mb5r9xYadYM= +github.com/alecthomas/kong v1.12.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -23,6 +23,8 @@ github.com/jotaen/genie v0.0.1/go.mod h1:bu+PbJDEJ9915yp4xml7OXoM4iBsSDfgtGVwv5Ag0Gg= github.com/jotaen/kong-completion v0.0.6 h1:VP1KGvXPeB7MytYR+zZQoWw1gf/HIV1/EvWC38BHZN4= github.com/jotaen/kong-completion v0.0.6/go.mod h1:fuWw9snL6joY5mXbI0Dd5FWEZODaWXAeqaRxo6dAvLk= +github.com/jotaen/safemath v0.0.1 h1:YcUhSIUtwQY1rUUT3AeP+alzTHUAsM4Pap8ZMn3GOlc= +github.com/jotaen/safemath v0.0.1/go.mod h1:KlKBnI3qvGcr3+iuvp3vABBZNFRjRcwRUVQa/jM38xQ= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -33,8 +35,8 @@ github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/cli/report.go new/klog-6.6/klog/app/cli/report.go --- old/klog-6.5/klog/app/cli/report.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/cli/report.go 2025-07-01 17:05:19.000000000 +0200 @@ -8,12 +8,15 @@ "github.com/jotaen/klog/klog/app/cli/util" "github.com/jotaen/klog/klog/service" "github.com/jotaen/klog/klog/service/period" + "math" "strings" ) type Report struct { - AggregateBy string `name:"aggregate" placeholder:"KIND" short:"a" help:"How to aggregate the data. KIND can be 'day' (default), 'week', 'month', 'quarter' or 'year'." enum:"DAY,day,d,WEEK,week,w,MONTH,month,m,QUARTER,quarter,q,YEAR,year,y," default:"day"` - Fill bool `name:"fill" short:"f" help:"Fill the gaps and show a consecutive stream."` + AggregateBy string `name:"aggregate" placeholder:"KIND" short:"a" help:"How to aggregate the data. KIND can be 'day' (default), 'week', 'month', 'quarter' or 'year'." enum:"DAY,day,d,WEEK,week,w,MONTH,month,m,QUARTER,quarter,q,YEAR,year,y," default:"day"` + Fill bool `name:"fill" short:"f" help:"Fill any calendar gaps and show a consecutive sequence of dates."` + Chart bool `name:"chart" short:"c" help:"Includes a bar chart rendering, to aid visual comparison."` + ChartResolution int `name:"chart-res" help:"Configure the chart resolution. INT must be a positive integer, denoting the minutes per rendered block."` util.DiffArgs util.FilterArgs util.NowArgs @@ -36,6 +39,10 @@ func (opt *Report) Run(ctx app.Context) app.Error { opt.DecimalArgs.Apply(&ctx) opt.NoStyleArgs.Apply(&ctx) + cErr := opt.canonicaliseOpts() + if cErr != nil { + return cErr + } _, serialiser := ctx.Serialise() records, err := ctx.ReadInputs(opt.File...) if err != nil { @@ -51,7 +58,7 @@ return nErr } records = service.Sort(records, true) - aggregator := opt.findAggregator() + aggregator := opt.aggregator() recordGroups, dates := groupByDate(aggregator.DateHash, records) if opt.Fill { dates = allDatesRange(records[0].Date(), records[len(records)-1].Date()) @@ -59,10 +66,14 @@ // Table setup numberOfValueColumns := func() int { + n := 1 if opt.Diff { - return 3 + n += 2 + } + if opt.Chart { + n += 1 } - return 1 + return n }() table := tf.NewTable( aggregator.NumberOfPrefixColumns()+numberOfValueColumns, @@ -75,6 +86,9 @@ if opt.Diff { table.CellR(" Should").CellR(" Diff") } + if opt.Chart { + table.Skip(1) + } // Rows hashesAlreadyProcessed := make(map[period.Hash]bool) @@ -99,6 +113,9 @@ diff := service.Diff(should, total) table.CellR(serialiser.ShouldTotal(should)).CellR(serialiser.SignedDuration(diff)) } + if opt.Chart { + table.CellL(" " + renderBar(opt.ChartResolution, total)) + } } // Line @@ -106,9 +123,12 @@ if opt.Diff { table.Fill("=").Fill("=") } - grandTotal := service.Total(records...) + if opt.Chart { + table.Skip(1) + } // Footer + grandTotal := service.Total(records...) table.Skip(aggregator.NumberOfPrefixColumns()) table.CellR(serialiser.Duration(grandTotal)) if opt.Diff { @@ -116,21 +136,50 @@ grandDiff := service.Diff(grandShould, grandTotal) table.CellR(serialiser.ShouldTotal(grandShould)).CellR(serialiser.SignedDuration(grandDiff)) } + if opt.Chart { + table.Skip(1) + } table.Collect(ctx.Print) opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) return nil } -func (opt *Report) findAggregator() report.Aggregator { - category := (func() string { - if opt.AggregateBy == "" { - return "d" - } else { - return strings.ToLower(opt.AggregateBy[:1]) - } - })() - switch category { +func (opt *Report) canonicaliseOpts() app.Error { + if opt.AggregateBy == "" { + opt.AggregateBy = "d" + } else { + opt.AggregateBy = strings.ToLower(opt.AggregateBy[:1]) + } + + if opt.ChartResolution == 0 { + // If the resolution wasn’t explicitly specified, use a default value + // that aims for a good balance between granularity and overall row width + // in the context of the desired aggregation mode. + switch opt.AggregateBy { + case "y": + opt.ChartResolution = 60 * 8 * 7 // Full working week + case "q": + opt.ChartResolution = 60 * 8 // Full working day + case "m": + opt.ChartResolution = 60 * 4 // Half working day + case "w": + opt.ChartResolution = 60 + default: // "d" + opt.ChartResolution = 15 + } + } else if opt.ChartResolution > 0 { + // When chart resolution is specified, automatically assume --chart + // to be given as well. + opt.Chart = true + } else if opt.ChartResolution < 0 { + return app.NewErrorWithCode(app.LOGICAL_ERROR, "Invalid resolution", "The resolution must be a positive integer", nil) + } + return nil +} + +func (opt *Report) aggregator() report.Aggregator { + switch opt.AggregateBy { case "y": return report.NewYearAggregator() case "q": @@ -169,3 +218,15 @@ } return days, order } + +func renderBar(minutesPerUnit int, d klog.Duration) string { + block := "▇" + blocksCount := func() int { + mins := d.InMinutes() + if mins <= 0 { + return 0 + } + return int(math.Ceil(float64(mins) / float64(minutesPerUnit))) + }() + return strings.Repeat(block, blocksCount) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/cli/report_test.go new/klog-6.6/klog/app/cli/report_test.go --- old/klog-6.5/klog/app/cli/report_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/cli/report_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -280,3 +280,228 @@ 15h20m 15h49m! -29m `, state.printBuffer) } + +func TestReportWithChart(t *testing.T) { + t.Run("Daily (default) aggregation", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-11 + -2h + +2025-01-11 + 0m + +2025-01-13 + 1m + +2025-01-14 + 5h + +2025-01-16 + 5h1m + +2025-01-17 + 5h15m + +2025-01-18 + 5h30m + +2025-01-20 + 5h51m + +2025-01-22 + 6h + +2025-01-25 + 9h +`)._Run((&Report{Chart: true}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Jan Sat 11. -2h + Mon 13. 1m ▇ + Tue 14. 5h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Thu 16. 5h1m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Fri 17. 5h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Sat 18. 5h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Mon 20. 5h51m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Wed 22. 6h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Sat 25. 9h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + ======== + 39h38m +`, state.printBuffer) + }) + + t.Run("Weekly aggregation", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-01 + 40h + +2025-01-08 + 48h45m + +2025-01-15 + 31h15m +`)._Run((&Report{Chart: true, AggregateBy: "w", WarnArgs: util.WarnArgs{NoWarn: true}}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Week 1 40h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Week 2 48h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Week 3 31h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + ======== + 120h +`, state.printBuffer) + }) + + t.Run("Monthly aggregation", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-01 + 173h + +2025-02-01 + 208h30m + +2025-03-01 + 126h15m +`)._Run((&Report{Chart: true, AggregateBy: "m", WarnArgs: util.WarnArgs{NoWarn: true}}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Jan 173h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Feb 208h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Mar 126h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + ======== + 507h45m +`, state.printBuffer) + }) + + t.Run("Quarterly aggregation", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-01 + 316h + +2025-04-01 + 392h30m + +2025-07-01 + 237h45m +`)._Run((&Report{Chart: true, AggregateBy: "q", WarnArgs: util.WarnArgs{NoWarn: true}}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Q1 316h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Q2 392h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Q3 237h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + ======== + 946h15m +`, state.printBuffer) + }) + + t.Run("Yearly aggregation", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-01 + 1735h + +2026-01-01 + 2154h45m + +2027-01-01 + 1189h15m +`)._Run((&Report{Chart: true, AggregateBy: "y", WarnArgs: util.WarnArgs{NoWarn: true}}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 1735h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ +2026 2154h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ +2027 1189h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + ======== + 5079h +`, state.printBuffer) + }) +} + +func TestReportWithScaledChart(t *testing.T) { + t.Run("Custom resolution (large)", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-14 + 12h + +2025-01-16 + 18h37m +`)._Run((&Report{Chart: true, ChartResolution: 120}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Jan Tue 14. 12h ▇▇▇▇▇▇ + Thu 16. 18h37m ▇▇▇▇▇▇▇▇▇▇ + ======== + 30h37m +`, state.printBuffer) + }) + + t.Run("Custom resolution (small)", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-14 + 1h30m + +2025-01-16 + 45m +`)._Run((&Report{Chart: true, ChartResolution: 5}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Jan Tue 14. 1h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ + Thu 16. 45m ▇▇▇▇▇▇▇▇▇ + ======== + 2h15m +`, state.printBuffer) + }) + + t.Run("Setting resolution implies --chart", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-14 + 12h + +2025-01-16 + 18h37m +`)._Run((&Report{ChartResolution: 120}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total +2025 Jan Tue 14. 12h ▇▇▇▇▇▇ + Thu 16. 18h37m ▇▇▇▇▇▇▇▇▇▇ + ======== + 30h37m +`, state.printBuffer) + }) + + t.Run("With --diff flag", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +2025-01-14 (2h!) + 1h30m + +2025-01-16 (1h!) + 45m +`)._Run((&Report{Chart: true, DiffArgs: util.DiffArgs{Diff: true}}).Run) + require.Nil(t, err) + assert.Equal(t, ` + Total Should Diff +2025 Jan Tue 14. 1h30m 2h! -30m ▇▇▇▇▇▇ + Thu 16. 45m 1h! -15m ▇▇▇ + ======== ========= ======== + 2h15m 3h! -45m +`, state.printBuffer) + }) + + t.Run("Invalid resolution", func(t *testing.T) { + _, err := NewTestingContext()._SetRecords(` +2025-01-14 + 12h + +2025-01-16 + 18h37m +`)._Run((&Report{ChartResolution: -10}).Run) + require.Error(t, err) + assert.Equal(t, "Invalid resolution", err.Error()) + }) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/cli/tags.go new/klog-6.6/klog/app/cli/tags.go --- old/klog-6.5/klog/app/cli/tags.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/cli/tags.go 2025-07-01 17:05:19.000000000 +0200 @@ -9,8 +9,9 @@ ) type Tags struct { - Values bool `name:"values" short:"v" help:"Display breakdown of tag values (if the data contains any; e.g.: '#tag=value')."` - Count bool `name:"count" short:"c" help:"Display the number of matching entries per tag."` + Values bool `name:"values" short:"v" help:"Display breakdown of tag values (if the data contains any; e.g.: '#tag=value')."` + Count bool `name:"count" short:"c" help:"Display the number of matching entries per tag."` + WithUntagged bool `name:"with-untagged" short:"u" help:"Display remainder of any untagged entries"` util.FilterArgs util.NowArgs util.DecimalArgs @@ -46,10 +47,7 @@ if nErr != nil { return nErr } - totalByTag := service.AggregateTotalsByTags(records...) - if len(totalByTag) == 0 { - return nil - } + tagStats, untagged := service.AggregateTotalsByTags(records...) numberOfColumns := 2 if opt.Values { numberOfColumns++ @@ -57,10 +55,12 @@ if opt.Count { numberOfColumns++ } + countString := func(c int) string { + return styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(fmt.Sprintf(" (%d)", c)) + } table := tf.NewTable(numberOfColumns, " ") - for _, t := range totalByTag { + for _, t := range tagStats { totalString := serialiser.Duration(t.Total) - countString := styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(fmt.Sprintf(" (%d)", t.Count)) if t.Tag.Value() == "" { table.CellL("#" + t.Tag.Name()) table.CellL(totalString) @@ -68,17 +68,27 @@ table.Skip(1) } if opt.Count { - table.CellL(countString) + table.CellL(countString(t.Count)) } } else if opt.Values { table.CellL(" " + styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(t.Tag.Value())) table.Skip(1) table.CellL(totalString) if opt.Count { - table.CellL(countString) + table.CellL(countString(t.Count)) } } } + if opt.WithUntagged { + table.CellL("(untagged)") + table.CellL(serialiser.Duration(untagged.Total)) + if opt.Values { + table.Skip(1) + } + if opt.Count { + table.CellL(countString(untagged.Count)) + } + } table.Collect(ctx.Print) opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) return nil diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/cli/tags_test.go new/klog-6.6/klog/app/cli/tags_test.go --- old/klog-6.5/klog/app/cli/tags_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/cli/tags_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -19,12 +19,12 @@ - Sort output alphabetically - Print in tabular manner */ - state, err := NewTestingContext()._SetRecords(` + ctx := NewTestingContext()._SetRecords(` 1995-03-17 #sports 3h #badminton - 1h #running - 1h #running + 1h #running=home-trail + 1h #running=river-route 1995-03-28 Was #sick, need to compensate later @@ -38,59 +38,206 @@ #sports #running (Don’t count that twice!) 14:00 - 17:00 #sports #running -`)._Run((&Tags{}).Run) - require.Nil(t, err) - assert.Equal(t, ` +`) + + t.Run("Without argument", func(t *testing.T) { + state, err := ctx._Run((&Tags{}).Run) + require.Nil(t, err) + assert.Equal(t, ` #badminton 3h45m #running 4h30m #sick -30m #sports 8h `, state.printBuffer) + }) + + t.Run("With count", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + Count: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m (2) +#running 4h30m (4) +#sick -30m (1) +#sports 8h (4) +`, state.printBuffer) + }) + + t.Run("With values", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + Values: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m +#running 4h30m + home-trail 1h + river-route 1h +#sick -30m +#sports 8h +`, state.printBuffer) + }) + + t.Run("With values and count", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + Values: true, + Count: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m (2) +#running 4h30m (4) + home-trail 1h (1) + river-route 1h (1) +#sick -30m (1) +#sports 8h (4) +`, state.printBuffer) + }) + + t.Run("With untagged", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + WithUntagged: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m +#running 4h30m +#sick -30m +#sports 8h +(untagged) 9h +`, state.printBuffer) + }) + + t.Run("With untagged and count", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + WithUntagged: true, + Count: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m (2) +#running 4h30m (4) +#sick -30m (1) +#sports 8h (4) +(untagged) 9h (1) +`, state.printBuffer) + }) + + t.Run("With values and untagged", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + Values: true, + WithUntagged: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m +#running 4h30m + home-trail 1h + river-route 1h +#sick -30m +#sports 8h +(untagged) 9h +`, state.printBuffer) + }) + + t.Run("With values and untagged and count", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + Values: true, + WithUntagged: true, + Count: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#badminton 3h45m (2) +#running 4h30m (4) + home-trail 1h (1) + river-route 1h (1) +#sick -30m (1) +#sports 8h (4) +(untagged) 9h (1) +`, state.printBuffer) + }) +} + +func TestPrintUntaggedIfNoTags(t *testing.T) { + t.Run("No tags present", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +1995-03-17 + 1h +`)._Run((&Tags{ + WithUntagged: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +(untagged) 1h +`, state.printBuffer) + }) + + t.Run("Empty file", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +`)._Run((&Tags{ + WithUntagged: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +(untagged) 0m +`, state.printBuffer) + }) + + t.Run("Empty file (with count)", func(t *testing.T) { + state, err := NewTestingContext()._SetRecords(` +`)._Run((&Tags{ + WithUntagged: true, + Count: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +(untagged) 0m (0) +`, state.printBuffer) + }) } func TestPrintTagsWithUnicodeCharacters(t *testing.T) { state, err := NewTestingContext()._SetRecords(` 1995-03-17 1h #ascii - 2h #üñïčödę + 2h #üñïčöδę `)._Run((&Tags{}).Run) require.Nil(t, err) assert.Equal(t, ` #ascii 1h -#üñïčödę 2h +#üñïčöδę 2h `, state.printBuffer) } -func TestPrintTagsWithCount(t *testing.T) { - state, err := NewTestingContext()._SetRecords(` +func TestPrintTagsOverviewWithUntaggedEmptyStates(t *testing.T) { + ctx := NewTestingContext()._SetRecords(` 1995-03-17 -#sports - 3h #badminton - 1h #running - 1h #running - -1995-03-28 -Was #sick, need to compensate later - -30m #running - -1995-04-02 - 9h something untagged - 45m #badminton + 3h #ticket +`) + t.Run("Include 0 line", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + WithUntagged: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#ticket 3h +(untagged) 0m +`, state.printBuffer) + }) -1995-04-19 -#sports #running (Don’t count that twice!) - 14:00 - 17:00 #sports #running - -`)._Run((&Tags{ - Count: true, - }).Run) - require.Nil(t, err) - assert.Equal(t, ` -#badminton 3h45m (2) -#running 4h30m (4) -#sick -30m (1) -#sports 8h (4) + t.Run("Include 0 count", func(t *testing.T) { + state, err := ctx._Run((&Tags{ + WithUntagged: true, + Count: true, + }).Run) + require.Nil(t, err) + assert.Equal(t, ` +#ticket 3h (1) +(untagged) 0m (0) `, state.printBuffer) + }) } func TestPrintTagsOverviewWithValueGrouping(t *testing.T) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/cli/terminalformat/table.go new/klog-6.6/klog/app/cli/terminalformat/table.go --- old/klog-6.5/klog/app/cli/terminalformat/table.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/cli/terminalformat/table.go 2025-07-01 17:05:19.000000000 +0200 @@ -103,5 +103,7 @@ } } } - fn("\n") + if len(t.cells) > 0 { + fn("\n") + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/cli/terminalformat/table_test.go new/klog-6.6/klog/app/cli/terminalformat/table_test.go --- old/klog-6.5/klog/app/cli/terminalformat/table_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/cli/terminalformat/table_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -29,6 +29,14 @@ `, result) } +func TestPrintEmptyTable(t *testing.T) { + // If the table is empty, it shouldn’t print a trailing newline. + result := "" + table := NewTable(3, " ") + table.Collect(func(x string) { result += x }) + assert.Equal(t, ``, result) +} + func TestPrintTableWithUnicode(t *testing.T) { result := "" table := NewTable(3, " ") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/app/config.go new/klog-6.6/klog/app/config.go --- old/klog-6.5/klog/app/config.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/app/config.go 2025-07-01 17:05:19.000000000 +0200 @@ -207,7 +207,7 @@ case string(tf.COLOUR_THEME_BASIC): config.ColourScheme.override(tf.COLOUR_THEME_BASIC, configOriginFile) default: - return errors.New("The value must be `dark`, `light` or `no_colour`") + return errors.New("The value must be `dark`, `light`, `basic`, or `no_colour`") } return nil }, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/date.go new/klog-6.6/klog/date.go --- old/klog-6.5/klog/date.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/date.go 2025-07-01 17:05:19.000000000 +0200 @@ -98,7 +98,7 @@ d, err := NewDate(t.Year(), int(t.Month()), t.Day()) if err != nil { // This can/should never occur - panic("ILLEGAL_DATE") + panic("Illegal date") } return d } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/date_test.go new/klog-6.6/klog/date_test.go --- old/klog-6.5/klog/date_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/date_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -114,6 +114,9 @@ "20-12-12", "asdf", "01.01.2000", + "⠃⠚⠚⠚-⠁⠃-⠚⠛", // Braille digits + "二〇〇〇-一二-〇四", // Japanese digits + "᠒᠐᠐᠐-᠑᠒-᠐᠗", // Mongolean digits } { d, err := NewDateFromString(s) assert.Nil(t, d) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/duration.go new/klog-6.6/klog/duration.go --- old/klog-6.5/klog/duration.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/duration.go 2025-07-01 17:05:19.000000000 +0200 @@ -5,6 +5,8 @@ "fmt" "regexp" "strconv" + + "github.com/jotaen/safemath/safemath" ) // Duration represents a time span. @@ -49,7 +51,12 @@ } func NewDurationWithFormat(amountHours int, amountMinutes int, format DurationFormat) Duration { - return &duration{minutes: amountHours*60 + amountMinutes, format: format} + hoursToMins, err1 := safemath.Multiply(amountHours, 60) + totalMins, err2 := safemath.Add(hoursToMins, amountMinutes) + if err1 != nil || err2 != nil { + panic("Integer overflow") + } + return &duration{minutes: totalMins, format: format} } type duration struct { @@ -69,11 +76,15 @@ } func (d duration) Plus(additional Duration) Duration { - return NewDuration(0, d.InMinutes()+additional.InMinutes()) + mins, err := safemath.Add(d.InMinutes(), additional.InMinutes()) + if err != nil { + panic("Integer overflow") + } + return NewDuration(0, mins) } func (d duration) Minus(deductible Duration) Duration { - return NewDuration(0, d.InMinutes()-deductible.InMinutes()) + return d.Plus(NewDuration(0, deductible.InMinutes()*-1)) } func (d duration) ToString() string { @@ -111,7 +122,7 @@ return s } -var durationPattern = regexp.MustCompile(`^(-|\+)?((\d+)h)?((\d+)m)?$`) +var durationPattern = regexp.MustCompile(`^([-+])?((\d+)h)?((\d+)m)?$`) func NewDurationFromString(hhmm string) (Duration, error) { match := durationPattern.FindStringSubmatch(hhmm) @@ -129,9 +140,15 @@ if match[3] == "" && match[5] == "" { return nil, errors.New("MALFORMED_DURATION") } - amountOfHours, _ := strconv.Atoi(match[3]) - amountOfMinutes, _ := strconv.Atoi(match[5]) - if amountOfHours != 0 && amountOfMinutes >= 60 { + amountOfHours, a1Err := strconv.Atoi(match[3]) + if match[3] != "" && a1Err != nil { + panic(a1Err) + } + amountOfMinutes, a2Err := strconv.Atoi(match[5]) + if match[5] != "" && a2Err != nil { + panic(a2Err) + } + if match[3] != "" && amountOfMinutes >= 60 { return nil, errors.New("UNREPRESENTABLE_DURATION") } if amountOfHours == 0 && amountOfMinutes == 0 && match[1] != "" { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/duration_test.go new/klog-6.6/klog/duration_test.go --- old/klog-6.5/klog/duration_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/duration_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -2,17 +2,21 @@ import ( "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" ) func TestSerialiseDurationOnlyWithMeaningfulValues(t *testing.T) { assert.Equal(t, "0m", NewDuration(0, 0).ToString()) assert.Equal(t, "1m", NewDuration(0, 1).ToString()) + assert.Equal(t, "34m", NewDuration(0, 34).ToString()) + assert.Equal(t, "59m", NewDuration(0, 59).ToString()) + assert.Equal(t, "1h", NewDuration(1, 0).ToString()) assert.Equal(t, "15h", NewDuration(15, 0).ToString()) -} - -func TestSerialiseDurationOfLargeHourValues(t *testing.T) { + assert.Equal(t, "15h3m", NewDuration(15, 3).ToString()) assert.Equal(t, "265h45m", NewDuration(265, 45).ToString()) + assert.Equal(t, "4716278h48m", NewDuration(4716278, 48).ToString()) + assert.Equal(t, "153722867280912930h7m", NewDuration(0, 9223372036854775807).ToString()) } func TestSerialiseDurationWithoutLeadingZeros(t *testing.T) { @@ -20,9 +24,11 @@ } func TestSerialiseDurationOfNegativeValues(t *testing.T) { + assert.Equal(t, "-2h4m", NewDuration(-2, -4).ToString()) assert.Equal(t, "-3h18m", NewDuration(-3, -18).ToString()) - assert.Equal(t, "-3h", NewDuration(-3, 0).ToString()) + assert.Equal(t, "-812747h", NewDuration(-812747, 0).ToString()) assert.Equal(t, "-18m", NewDuration(0, -18).ToString()) + assert.Equal(t, "-153722867280912930h7m", NewDuration(0, -9223372036854775807).ToString()) } func TestSerialiseDurationWithSign(t *testing.T) { @@ -70,13 +76,19 @@ } func TestParsingDurationWithHourValueOnly(t *testing.T) { - for _, d := range []string{ - "13h", - "13h0m", + for _, d := range []struct { + text string + expect Duration + }{ + {"0h", NewDuration(0, 0)}, + {"1h", NewDuration(1, 0)}, + {"13h", NewDuration(13, 0)}, + {"9882187612h", NewDuration(9882187612, 0)}, + {"13h0m", NewDuration(13, 0)}, } { - duration, err := NewDurationFromString(d) + duration, err := NewDurationFromString(d.text) assert.Nil(t, err) - assert.Equal(t, NewDuration(13, 0), duration) + assert.Equal(t, d.expect, duration) } } @@ -85,12 +97,16 @@ text string expect Duration }{ + {"1m", NewDuration(0, 1)}, {"48m", NewDuration(0, 48)}, + {"59m", NewDuration(0, 59)}, + {"0h48m", NewDuration(0, 48)}, // Minutes >60 are okay if there is no hour part present + {"60m", NewDuration(1, 0)}, {"120m", NewDuration(2, 0)}, - {"150m", NewDuration(2, 30)}, + {"568721940327m", NewDuration(0, 568721940327)}, } { duration, err := NewDurationFromString(d.text) assert.Nil(t, err) @@ -130,6 +146,9 @@ "asdf", "6h asdf", "qwer 30m", + "⠙⠛m", // Braille digits + "四二h", // Japanese digits + "᠒h᠐᠒m", // Mongolean digits } { duration, err := NewDurationFromString(d) assert.EqualError(t, err, "MALFORMED_DURATION") @@ -140,6 +159,7 @@ func TestParsingFailsWithMinutesGreaterThan60WhenHourPartPresent(t *testing.T) { for _, d := range []string{ "1h60m", + "0h60m", "8h1653m", "-8h1653m", } { @@ -148,3 +168,72 @@ assert.Equal(t, nil, duration) } } + +func TestParsingDurationWithMaxValue(t *testing.T) { + t.Run("max", func(t *testing.T) { + d, err := NewDurationFromString("9223372036854775807m") + require.Nil(t, err) + assert.Equal(t, NewDuration(0, 9223372036854775807), d) + }) + t.Run("max", func(t *testing.T) { + d, err := NewDurationFromString("153722867280912930h7m") + require.Nil(t, err) + assert.Equal(t, NewDuration(153722867280912930, 7), d) + }) + t.Run("min", func(t *testing.T) { + d, err := NewDurationFromString("-9223372036854775807m") + require.Nil(t, err) + assert.Equal(t, NewDuration(0, -9223372036854775807), d) + }) + t.Run("max", func(t *testing.T) { + d, err := NewDurationFromString("-153722867280912930h7m") + require.Nil(t, err) + assert.Equal(t, NewDuration(-153722867280912930, -7), d) + }) +} + +func TestParsingDurationTooBigToRepresent(t *testing.T) { + for _, d := range []string{ + "9223372036854775808m", + "-9223372036854775808m", + "9223372036854775808h", + "-9223372036854775808h", + "153722867280912930h08m", + "-153722867280912930h08m", + } { + assert.Panics(t, func() { + _, _ = NewDurationFromString(d) + }, d) + } +} + +func TestDurationPlusMinus(t *testing.T) { + for _, d := range []struct { + sum Duration + expect int + }{ + {NewDuration(0, 0).Plus(NewDuration(0, 0)), 0}, + {NewDuration(0, 0).Plus(NewDuration(0, 1)), 1}, + {NewDuration(0, 0).Plus(NewDuration(1, 2)), 62}, + {NewDuration(1382, 9278).Plus(NewDuration(4718, 5010)), 380288}, + {NewDuration(0, 9223372036854775806).Plus(NewDuration(0, 1)), 9223372036854775807}, + {NewDuration(0, 0).Plus(NewDuration(0, -9223372036854775807)), -9223372036854775807}, + + {NewDuration(0, 0).Minus(NewDuration(0, 0)), 0}, + {NewDuration(0, 0).Minus(NewDuration(0, 1)), -1}, + {NewDuration(0, 0).Minus(NewDuration(1, 2)), -62}, + {NewDuration(1382, 9278).Minus(NewDuration(4718, 5010)), -195892}, + } { + assert.Equal(t, d.sum.InMinutes(), d.expect) + } +} + +func TestPanicsIfAdditionOverflows(t *testing.T) { + assert.Panics(t, func() { + NewDuration(0, 9223372036854775807).Plus(NewDuration(0, 1)) + }) + + assert.Panics(t, func() { + NewDuration(0, -9223372036854775807).Plus(NewDuration(0, -1)) + }) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/parser/engine/parallel.go new/klog-6.6/klog/parser/engine/parallel.go --- old/klog-6.5/klog/parser/engine/parallel.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/parser/engine/parallel.go 2025-07-01 17:05:19.000000000 +0200 @@ -23,7 +23,7 @@ func (p ParallelBatchParser[T]) Parse(text string) ([]T, []txt.Block, []txt.Error) { if p.NumberOfWorkers <= 0 { - panic("ILLEGAL_WORKER_SIZE") + panic("Illegal number of workers") } batches := splitIntoChunks(text, p.NumberOfWorkers) allResults := p.processAsync(batches, func(batchIndex int, batchText string) batchResult[T] { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/parser/reconciling/start_open_range.go new/klog-6.6/klog/parser/reconciling/start_open_range.go --- old/klog-6.5/klog/parser/reconciling/start_open_range.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/parser/reconciling/start_open_range.go 2025-07-01 17:05:19.000000000 +0200 @@ -14,7 +14,7 @@ // Re-parse time to apply format. reformattedTime, err := klog.NewTimeFromString(startTime.ToStringWithFormat(f)) if err != nil { - panic("INVALID_TIME") + panic("Invalid time") } startTime = reformattedTime }) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/parser/reconciling/style.go new/klog-6.6/klog/parser/reconciling/style.go --- old/klog-6.5/klog/parser/reconciling/style.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/parser/reconciling/style.go 2025-07-01 17:05:19.000000000 +0200 @@ -133,7 +133,7 @@ // `base` style that had been set explicitly take precedence. func elect(base style, rs []klog.Record, bs []txt.Block) *style { if len(rs) != len(bs) { - panic("ASSERTION_ERROR") + panic("Internal error") } lineEndingElection := newElection[string]() indentationElection := newElection[string]() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/service/tags.go new/klog-6.6/klog/service/tags.go --- old/klog-6.5/klog/service/tags.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/service/tags.go 2025-07-01 17:05:19.000000000 +0200 @@ -8,7 +8,7 @@ type TagStats struct { Tag klog.Tag - // Total is the total duration alloted to the tag. + // Total is the total duration allotted to the tag. Total klog.Duration // Count is the total number of matching entries for that tag. @@ -20,21 +20,32 @@ // AggregateTotalsByTags returns a list of tags (sorted by tag, alphanumerically) // that contains statistics about the tags appearing in the data. -func AggregateTotalsByTags(rs ...klog.Record) []*TagStats { - result := make(totalByTag) +func AggregateTotalsByTags(rs ...klog.Record) ([]TagStats, TagStats) { + tagStats := make(totalByTag) + untagged := TagStats{ + Tag: klog.NewTagOrPanic("_", ""), + Total: klog.NewDuration(0, 0), + Count: 0, + keyForSort: "", + } for _, r := range rs { for _, e := range r.Entries() { - alreadyCounted := make(map[klog.Tag]bool) allTags := klog.Merge(r.Summary().Tags(), e.Summary().Tags()) + if allTags.IsEmpty() { + untagged.Count += 1 + untagged.Total = untagged.Total.Plus(e.Duration()) + continue + } + alreadyCounted := make(map[klog.Tag]bool) for tag := range allTags.ForLookup() { if alreadyCounted[tag] { continue } - result.put(tag, e.Duration()) + tagStats.put(tag, e.Duration()) } } } - return result.toSortedList() + return tagStats.toSortedList(), untagged } // Structure: "tagName":"tagValue":TagStats @@ -59,11 +70,11 @@ stats.Count++ } -func (tbt totalByTag) toSortedList() []*TagStats { - var result []*TagStats +func (tbt totalByTag) toSortedList() []TagStats { + var result []TagStats for _, ts := range tbt { for _, t := range ts { - result = append(result, t) + result = append(result, *t) } } sort.Slice(result, func(i int, j int) bool { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/service/tags_test.go new/klog-6.6/klog/service/tags_test.go --- old/klog-6.5/klog/service/tags_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/service/tags_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -8,29 +8,54 @@ ) func TestAggregateTotalTimesByTag(t *testing.T) { - r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) - r.SetSummary(klog.Ɀ_RecordSummary_("#foo")) - r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=1")) - r.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#foo")) - r.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#test")) + rs := []klog.Record{ + func() klog.Record { + r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) + r.SetSummary(klog.Ɀ_RecordSummary_("#foo")) + r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=1")) + r.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#foo")) + r.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#test")) + return r + }(), + func() klog.Record { + r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 2)) + r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=2")) + r.AddDuration(klog.NewDuration(8, 0), klog.Ɀ_EntrySummary_("#bar")) + r.AddDuration(klog.NewDuration(0, 45), klog.Ɀ_EntrySummary_("no tag")) + return r + }(), + } + + tagStats, untagged := AggregateTotalsByTags(rs...) + require.Len(t, tagStats, 5) - totals := AggregateTotalsByTags(r) - require.Len(t, totals, 3) + assert.Equal(t, klog.NewDuration(0, 45), untagged.Total) + assert.Equal(t, 1, untagged.Count) i := 0 - assert.Equal(t, klog.NewTagOrPanic("foo", ""), totals[i].Tag) - assert.Equal(t, klog.NewDuration(4, 30), totals[i].Total) - assert.Equal(t, 3, totals[i].Count) + assert.Equal(t, klog.NewTagOrPanic("bar", ""), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(8, 0), tagStats[i].Total) + assert.Equal(t, 1, tagStats[i].Count) + + i++ + assert.Equal(t, klog.NewTagOrPanic("foo", ""), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(5, 30), tagStats[i].Total) + assert.Equal(t, 4, tagStats[i].Count) + + i++ + assert.Equal(t, klog.NewTagOrPanic("foo", "1"), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(1, 0), tagStats[i].Total) + assert.Equal(t, 1, tagStats[i].Count) i++ - assert.Equal(t, klog.NewTagOrPanic("foo", "1"), totals[i].Tag) - assert.Equal(t, klog.NewDuration(1, 0), totals[i].Total) - assert.Equal(t, 1, totals[i].Count) + assert.Equal(t, klog.NewTagOrPanic("foo", "2"), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(1, 0), tagStats[i].Total) + assert.Equal(t, 1, tagStats[i].Count) i++ - assert.Equal(t, klog.NewTagOrPanic("test", ""), totals[i].Tag) - assert.Equal(t, klog.NewDuration(0, 30), totals[i].Total) - assert.Equal(t, 1, totals[i].Count) + assert.Equal(t, klog.NewTagOrPanic("test", ""), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(0, 30), tagStats[i].Total) + assert.Equal(t, 1, tagStats[i].Count) } func TestAggregateTotalIgnoresRedundantTags(t *testing.T) { @@ -39,23 +64,23 @@ r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=1 #foo")) r.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#foo=2 #foo=1 #foo")) - totals := AggregateTotalsByTags(r) - require.Len(t, totals, 3) + tagStats, _ := AggregateTotalsByTags(r) + require.Len(t, tagStats, 3) i := 0 - assert.Equal(t, klog.NewTagOrPanic("foo", ""), totals[i].Tag) - assert.Equal(t, klog.NewDuration(4, 0), totals[i].Total) - assert.Equal(t, 2, totals[i].Count) + assert.Equal(t, klog.NewTagOrPanic("foo", ""), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(4, 0), tagStats[i].Total) + assert.Equal(t, 2, tagStats[i].Count) i++ - assert.Equal(t, klog.NewTagOrPanic("foo", "1"), totals[i].Tag) - assert.Equal(t, klog.NewDuration(4, 0), totals[i].Total) - assert.Equal(t, 2, totals[i].Count) + assert.Equal(t, klog.NewTagOrPanic("foo", "1"), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(4, 0), tagStats[i].Total) + assert.Equal(t, 2, tagStats[i].Count) i++ - assert.Equal(t, klog.NewTagOrPanic("foo", "2"), totals[i].Tag) - assert.Equal(t, klog.NewDuration(3, 0), totals[i].Total) - assert.Equal(t, 1, totals[i].Count) + assert.Equal(t, klog.NewTagOrPanic("foo", "2"), tagStats[i].Tag) + assert.Equal(t, klog.NewDuration(3, 0), tagStats[i].Total) + assert.Equal(t, 1, tagStats[i].Count) } func TestAggregateTotalTimesByTagSortsAlphabetically(t *testing.T) { @@ -68,19 +93,19 @@ r2.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#ccc=1")) r2.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#ccc=2")) - totals := AggregateTotalsByTags(r1, r2) - require.Len(t, totals, 6) + tagStats, _ := AggregateTotalsByTags(r1, r2) + require.Len(t, tagStats, 6) i := 0 - assert.Equal(t, klog.NewTagOrPanic("aaa", ""), totals[i].Tag) + assert.Equal(t, klog.NewTagOrPanic("aaa", ""), tagStats[i].Tag) i += 1 - assert.Equal(t, klog.NewTagOrPanic("bbb", ""), totals[i].Tag) + assert.Equal(t, klog.NewTagOrPanic("bbb", ""), tagStats[i].Tag) i += 1 - assert.Equal(t, klog.NewTagOrPanic("ccc", ""), totals[i].Tag) + assert.Equal(t, klog.NewTagOrPanic("ccc", ""), tagStats[i].Tag) i += 1 - assert.Equal(t, klog.NewTagOrPanic("ccc", "1"), totals[i].Tag) + assert.Equal(t, klog.NewTagOrPanic("ccc", "1"), tagStats[i].Tag) i += 1 - assert.Equal(t, klog.NewTagOrPanic("ccc", "2"), totals[i].Tag) + assert.Equal(t, klog.NewTagOrPanic("ccc", "2"), tagStats[i].Tag) i += 1 - assert.Equal(t, klog.NewTagOrPanic("ddd", ""), totals[i].Tag) + assert.Equal(t, klog.NewTagOrPanic("ddd", ""), tagStats[i].Tag) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/time.go new/klog-6.6/klog/time.go --- old/klog-6.5/klog/time.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/time.go 2025-07-01 17:05:19.000000000 +0200 @@ -128,7 +128,7 @@ time, err := NewTime(t.Hour(), t.Minute()) if err != nil { // This can/should never occur - panic("ILLEGAL_TIME") + panic("Illegal time") } return time } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/klog-6.5/klog/time_test.go new/klog-6.6/klog/time_test.go --- old/klog-6.5/klog/time_test.go 2024-11-28 11:22:57.000000000 +0100 +++ new/klog-6.6/klog/time_test.go 2025-07-01 17:05:19.000000000 +0200 @@ -207,6 +207,9 @@ "13:3", // Minutes must have 2 digits "-14:12", // Hours cannot be negative "14:-12", // Minutes cannot be negative + "⠃⠚:⠙⠛", // Braille digits + "四:二八", // Japanese digits + "᠒᠐:᠑᠒", // Mongolean digits } { tm, err := NewTimeFromString(s) require.Nil(t, tm, s) ++++++ klog.obsinfo ++++++ --- /var/tmp/diff_new_pack.8yTM9r/_old 2025-07-02 12:15:58.654898062 +0200 +++ /var/tmp/diff_new_pack.8yTM9r/_new 2025-07-02 12:15:58.658898228 +0200 @@ -1,5 +1,5 @@ name: klog -version: 6.5 -mtime: 1732789377 -commit: 6f2c7a19f86b701b23cead08ac6b9a917a594e15 +version: 6.6 +mtime: 1751382319 +commit: 7b3cc55b96ab55203a6375c6eb6dff7ec7e12cd5 ++++++ vendor.tar.gz ++++++ ++++ 4339 lines of diff (skipped)