├── .gitignore ├── klog ├── app │ ├── sys.go │ ├── cli │ │ ├── terminalformat │ │ │ ├── util.go │ │ │ ├── util_test.go │ │ │ ├── reflow.go │ │ │ ├── style.go │ │ │ ├── table_test.go │ │ │ ├── reflow_test.go │ │ │ ├── colour_theme.go │ │ │ └── table.go │ │ ├── report │ │ │ ├── aggregator.go │ │ │ ├── year.go │ │ │ ├── week.go │ │ │ ├── quarter.go │ │ │ ├── month.go │ │ │ └── day.go │ │ ├── command │ │ │ └── command.go │ │ ├── testspy_test.go │ │ ├── util │ │ │ ├── reconcile.go │ │ │ ├── with_repeat.go │ │ │ ├── prettifier_test.go │ │ │ └── prettifier.go │ │ ├── info.go │ │ ├── goto.go │ │ ├── json.go │ │ ├── edit.go │ │ ├── create.go │ │ ├── switch.go │ │ ├── total.go │ │ ├── track.go │ │ ├── stop.go │ │ ├── total_test.go │ │ ├── goto_test.go │ │ ├── config.go │ │ ├── start.go │ │ ├── print_test.go │ │ ├── tags.go │ │ └── print.go │ ├── sys_linux.go │ ├── sys_darwin.go │ ├── main │ │ ├── completion_predictors.go │ │ └── testutil_test.go │ ├── sys_windows.go │ ├── text_serialiser.go │ ├── retriever_test.go │ ├── error.go │ └── retriever.go ├── service │ ├── service.go │ ├── period │ │ ├── day.go │ │ ├── year.go │ │ ├── period_test.go │ │ ├── month.go │ │ ├── week.go │ │ ├── period.go │ │ ├── quarter.go │ │ ├── year_test.go │ │ ├── quarter_test.go │ │ ├── week_test.go │ │ └── month_test.go │ ├── evaluate.go │ ├── evaluate_test.go │ ├── datetime.go │ ├── record.go │ ├── datetime_test.go │ ├── rounding.go │ ├── tags.go │ ├── record_test.go │ └── query.go ├── shouldtotal.go ├── parser │ ├── reconciling │ │ ├── append_entry.go │ │ ├── start_open_range.go │ │ ├── close_open_range.go │ │ ├── style_reformat.go │ │ ├── pause_open_range.go │ │ └── creator.go │ ├── error_test.go │ ├── txt │ │ ├── line_test.go │ │ ├── error_test.go │ │ ├── util.go │ │ ├── indentation.go │ │ ├── line.go │ │ ├── block_test.go │ │ ├── indentation_test.go │ │ ├── error.go │ │ ├── parseable.go │ │ └── block.go │ ├── engine.go │ ├── engine │ │ ├── serial.go │ │ └── parallel_test.go │ ├── json │ │ ├── view.go │ │ ├── serialiser.go │ │ └── serialiser_test.go │ ├── serialiser.go │ └── error.go ├── entry.go ├── summary.go ├── testutil.go ├── range_test.go ├── range.go ├── record.go └── tag.go ├── staticcheck.conf ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug.md ├── install_linux.md ├── install_windows.md ├── benchmark.sh ├── install_darwin.md ├── smoke-test.sh ├── workflows │ └── ci.yml └── benchmark.go ├── go.mod ├── run.sh ├── LICENSE.txt ├── README.md ├── klog.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | tmp.klg 3 | -------------------------------------------------------------------------------- /klog/app/sys.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type KlogFolder struct { 4 | BasePathEnvVar string 5 | Location string 6 | } 7 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = [ 2 | "inherit", 3 | "-ST1001", # Allow dot imports 4 | "-ST1005", # Allow capitalized error strings 5 | ] 6 | -------------------------------------------------------------------------------- /klog/service/service.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package service contains utilities that address various common use-cases 3 | of processing records. 4 | */ 5 | package service 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature ideas, feedback, or questions 4 | url: https://github.com/jotaen/klog/discussions 5 | about: For all other things, please use Discussions 6 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/util.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | import "regexp" 4 | 5 | var ansiSequencePattern = regexp.MustCompile(`\x1b\[[\d;]+m`) 6 | 7 | func StripAllAnsiSequences(text string) string { 8 | return ansiSequencePattern.ReplaceAllString(text, "") 9 | } 10 | -------------------------------------------------------------------------------- /klog/shouldtotal.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | // ShouldTotal represents the targeted total time of a Record. 4 | type ShouldTotal Duration 5 | type shouldTotal struct { 6 | Duration 7 | } 8 | 9 | func NewShouldTotal(hours int, minutes int) ShouldTotal { 10 | return shouldTotal{NewDuration(hours, minutes)} 11 | } 12 | 13 | func (s shouldTotal) ToString() string { 14 | return s.Duration.ToString() + "!" 15 | } 16 | -------------------------------------------------------------------------------- /klog/parser/reconciling/append_entry.go: -------------------------------------------------------------------------------- 1 | package reconciling 2 | 3 | import "github.com/jotaen/klog/klog" 4 | 5 | // AppendEntry adds a new entry to the end of the record. 6 | // `newEntry` must include the entry value at the beginning of its first line. 7 | func (r *Reconciler) AppendEntry(newEntry klog.EntrySummary) error { 8 | r.insert(r.lastLinePointer, toMultilineEntryTexts("", newEntry)) 9 | return nil 10 | } 11 | -------------------------------------------------------------------------------- /klog/app/cli/report/aggregator.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package report is a utility for the report command. 3 | */ 4 | package report 5 | 6 | import ( 7 | "github.com/jotaen/klog/klog" 8 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 9 | "github.com/jotaen/klog/klog/service/period" 10 | ) 11 | 12 | type Aggregator interface { 13 | NumberOfPrefixColumns() int 14 | DateHash(klog.Date) period.Hash 15 | OnHeaderPrefix(*tf.Table) 16 | OnRowPrefix(*tf.Table, klog.Date) 17 | } 18 | -------------------------------------------------------------------------------- /klog/parser/error_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/parser/txt" 5 | ) 6 | 7 | type errData struct { 8 | id string 9 | lineNr int 10 | pos int 11 | len int 12 | } 13 | 14 | func (e HumanError) toErrData(lineNr int, pos int, len int) errData { 15 | return errData{e.code, lineNr, pos, len} 16 | } 17 | 18 | func toErrData(e txt.Error) errData { 19 | return errData{e.Code(), e.LineNumber(), e.Position(), e.Length()} 20 | } 21 | -------------------------------------------------------------------------------- /klog/service/period/day.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import "github.com/jotaen/klog/klog" 4 | 5 | type DayHash Hash 6 | 7 | type Day struct { 8 | date klog.Date 9 | } 10 | 11 | func NewDayFromDate(d klog.Date) Day { 12 | return Day{d} 13 | } 14 | 15 | func (d Day) Hash() DayHash { 16 | hash := newBitMask() 17 | hash.populate(uint32(d.date.Day()), 31) 18 | hash.populate(uint32(d.date.Month()), 12) 19 | hash.populate(uint32(d.date.Year()), 10000) 20 | return DayHash(hash.Value()) 21 | } 22 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/util_test.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestStripAllAnsiSequences(t *testing.T) { 9 | assert.Equal(t, "test 123", StripAllAnsiSequences("test 123")) 10 | assert.Equal(t, "test 123", StripAllAnsiSequences("test 123")) 11 | assert.Equal(t, "test 123", StripAllAnsiSequences("test \x1b[0m\x1b[4m123\x1b[0m")) 12 | assert.Equal(t, "test 123", StripAllAnsiSequences("\x1b[0m\x1b[4mtest\x1b[0m \x1b[0m\x1b[4m123\x1b[0m")) 13 | } 14 | -------------------------------------------------------------------------------- /klog/parser/txt/line_test.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestDeterminesLineEndings(t *testing.T) { 9 | ls := []Line{NewLineFromString( 10 | "foo\n"), 11 | NewLineFromString("bar\r\n"), 12 | NewLineFromString("baz"), 13 | } 14 | assert.Equal(t, "foo", ls[0].Text) 15 | assert.Equal(t, "\n", ls[0].LineEnding) 16 | assert.Equal(t, "bar", ls[1].Text) 17 | assert.Equal(t, "\r\n", ls[1].LineEnding) 18 | assert.Equal(t, "baz", ls[2].Text) 19 | assert.Equal(t, "", ls[2].LineEnding) 20 | } 21 | -------------------------------------------------------------------------------- /klog/parser/txt/error_test.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestCreateError(t *testing.T) { 9 | block, _ := ParseBlock("Hello World\n", 2) 10 | err := NewError(block, 0, 0, 5, "CODE", "Title", "Details") 11 | assert.Equal(t, "CODE", err.Code()) 12 | assert.Equal(t, "Title", err.Title()) 13 | assert.Equal(t, "Details", err.Details()) 14 | assert.Equal(t, 0, err.Position()) 15 | assert.Equal(t, 1, err.Column()) 16 | assert.Equal(t, 5, err.Length()) 17 | assert.Equal(t, "Title: Details", err.Message()) 18 | } 19 | -------------------------------------------------------------------------------- /klog/app/cli/command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "errors" 5 | "github.com/kballard/go-shellquote" 6 | ) 7 | 8 | type Command struct { 9 | Bin string 10 | Args []string 11 | } 12 | 13 | func NewFromString(command string) (Command, error) { 14 | words, err := shellquote.Split(command) 15 | if err != nil { 16 | return Command{}, err 17 | } 18 | if len(words) == 0 { 19 | return Command{}, errors.New("Empty command") 20 | } 21 | return New(words[0], words[1:]), nil 22 | } 23 | 24 | func New(bin string, args []string) Command { 25 | return Command{Bin: bin, Args: args} 26 | } 27 | -------------------------------------------------------------------------------- /.github/install_linux.md: -------------------------------------------------------------------------------- 1 | # Install klog 2 | 3 | In order to install the downloaded klog binary on your system, copy it 4 | to a location that’s included in your `$PATH` environment variable, e.g. 5 | `mv klog /usr/local/bin/klog` (might require `sudo`). 6 | 7 | For other install options, see [the documentation website](https://klog.jotaen.net/#get-klog). 8 | 9 | ## Check for updates 10 | 11 | In order to not miss any updates you can either subscribe to the release 12 | notifications on [Github](https://github.com/jotaen/klog) (at the top right: 13 | “Watch” → “Custom” → “Releases”), or you occasionally check by running 14 | `klog version`. 15 | -------------------------------------------------------------------------------- /.github/install_windows.md: -------------------------------------------------------------------------------- 1 | # Install klog 2 | 3 | In order to install the downloaded klog binary on your system, copy it 4 | to a location that’s included in your `PATH` environment variable, e.g. 5 | `C:\Windows\System32` (might require admin privileges). 6 | 7 | For other install options, see [the documentation website](https://klog.jotaen.net/#get-klog). 8 | 9 | ## Check for updates 10 | 11 | In order to not miss any updates you can either subscribe to the release 12 | notifications on [Github](https://github.com/jotaen/klog) (at the top right: 13 | “Watch” → “Custom” → “Releases”), or you occasionally check by running 14 | `klog version`. 15 | -------------------------------------------------------------------------------- /.github/benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | declare -a samples=(1 10 100 1000 10000 100000) 6 | ITERATIONS=3 7 | 8 | # Ensure binary is all set. 9 | klog > /dev/null 10 | 11 | # Run benchmark. 12 | TIMEFORMAT=%R 13 | for size in "${samples[@]}"; do 14 | printf "%8d: " $size 15 | for _ in $(seq $ITERATIONS); do 16 | # Generate new test data. 17 | file="$(mktemp)" 18 | go run benchmark.go "${size}" > "${file}" 19 | 20 | # Disable warnings, as the generated data will trigger lots of them. 21 | runtime=$( { time klog total --no-warn "${file}" > /dev/null; } 2>&1 ) 22 | printf "%ss " $runtime 23 | done 24 | echo 25 | done 26 | -------------------------------------------------------------------------------- /klog/app/sys_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package app 4 | 5 | import ( 6 | "github.com/jotaen/klog/klog/app/cli/command" 7 | ) 8 | 9 | var POTENTIAL_EDITORS = []command.Command{ 10 | command.New("vim", nil), 11 | command.New("vi", nil), 12 | command.New("nano", nil), 13 | command.New("pico", nil), 14 | } 15 | 16 | var POTENTIAL_FILE_EXLORERS = []command.Command{ 17 | command.New("xdg-open", nil), 18 | } 19 | 20 | var KLOG_CONFIG_FOLDER = []KlogFolder{ 21 | {"KLOG_CONFIG_HOME", ""}, 22 | {"XDG_CONFIG_HOME", "klog"}, 23 | {"HOME", ".config/klog"}, 24 | } 25 | 26 | func (kf KlogFolder) EnvVarSymbol() string { 27 | return "$" + kf.BasePathEnvVar 28 | } 29 | -------------------------------------------------------------------------------- /klog/app/sys_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package app 4 | 5 | import ( 6 | "github.com/jotaen/klog/klog/app/cli/command" 7 | ) 8 | 9 | var POTENTIAL_EDITORS = []command.Command{ 10 | command.New("vim", nil), 11 | command.New("vi", nil), 12 | command.New("nano", nil), 13 | command.New("pico", nil), 14 | command.New("open", []string{"-a", "TextEdit"}), 15 | } 16 | 17 | var POTENTIAL_FILE_EXLORERS = []command.Command{ 18 | command.New("open", nil), 19 | } 20 | 21 | var KLOG_CONFIG_FOLDER = []KlogFolder{ 22 | {"KLOG_CONFIG_HOME", ""}, 23 | {"XDG_CONFIG_HOME", "klog"}, 24 | {"HOME", ".klog"}, 25 | } 26 | 27 | func (kf KlogFolder) EnvVarSymbol() string { 28 | return "$" + kf.BasePathEnvVar 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report something that’s not working properly 4 | title: '' 5 | labels: BUG 6 | assignees: '' 7 | 8 | --- 9 | 10 | 20 | -------------------------------------------------------------------------------- /klog/app/cli/testspy_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/app/cli/command" 6 | ) 7 | 8 | type commandSpy struct { 9 | LastCmd command.Command 10 | Count int 11 | onCmd func(command.Command) app.Error 12 | } 13 | 14 | func (c *commandSpy) Execute(cmd command.Command) app.Error { 15 | c.LastCmd = cmd 16 | c.Count++ 17 | return c.onCmd(cmd) 18 | } 19 | 20 | func newCommandSpy(onCmd func(command.Command) app.Error) *commandSpy { 21 | if onCmd == nil { 22 | onCmd = func(_ command.Command) app.Error { 23 | return nil 24 | } 25 | } 26 | return &commandSpy{ 27 | LastCmd: command.Command{}, 28 | Count: 0, 29 | onCmd: onCmd, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /klog/app/cli/util/reconcile.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/parser" 6 | "github.com/jotaen/klog/klog/parser/reconciling" 7 | ) 8 | 9 | type ReconcileOpts struct { 10 | OutputFileArgs 11 | WarnArgs 12 | } 13 | 14 | func Reconcile(ctx app.Context, opts ReconcileOpts, creators []reconciling.Creator, reconcile ...reconciling.Reconcile) app.Error { 15 | result, err := ctx.ReconcileFile(opts.OutputFileArgs.File, creators, reconcile...) 16 | if err != nil { 17 | return err 18 | } 19 | _, serialiser := ctx.Serialise() 20 | ctx.Print("\n" + parser.SerialiseRecords(serialiser, result.Record).ToString() + "\n") 21 | opts.WarnArgs.PrintWarnings(ctx, result.AllRecords, nil) 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jotaen/klog 2 | 3 | go 1.25 4 | 5 | require ( 6 | cloud.google.com/go v0.121.6 7 | github.com/alecthomas/kong v1.12.1 8 | github.com/jotaen/genie v0.0.2 9 | github.com/jotaen/kong-completion v0.0.7 10 | github.com/jotaen/safemath v0.0.2 11 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 12 | github.com/posener/complete v1.2.3 13 | github.com/stretchr/testify v1.11.1 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/hashicorp/errwrap v1.1.0 // indirect 19 | github.com/hashicorp/go-multierror v1.1.1 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /klog/app/cli/info.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | ) 6 | 7 | type Info struct { 8 | Spec bool `name:"spec" help:"Print the .klg file format specification."` 9 | License bool `name:"license" help:"Print license / copyright information."` 10 | About bool `name:"about" help:"Print meta information about klog."` 11 | } 12 | 13 | func (opt *Info) Run(ctx app.Context) app.Error { 14 | if opt.Spec { 15 | ctx.Print(ctx.Meta().Specification + "\n") 16 | } else if opt.License { 17 | ctx.Print(ctx.Meta().License + "\n") 18 | } else if opt.About { 19 | ctx.Print(INTRO_SUMMARY) 20 | } else { 21 | return app.NewErrorWithCode( 22 | app.GENERAL_ERROR, 23 | "No flag specified", 24 | "Run with `--help` for more info", 25 | nil, 26 | ) 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /klog/app/cli/report/year.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/service/period" 8 | ) 9 | 10 | type yearAggregator struct{} 11 | 12 | func NewYearAggregator() Aggregator { 13 | return &yearAggregator{} 14 | } 15 | 16 | func (a *yearAggregator) NumberOfPrefixColumns() int { 17 | return 1 18 | } 19 | 20 | func (a *yearAggregator) DateHash(date klog.Date) period.Hash { 21 | return period.Hash(period.NewYearFromDate(date).Hash()) 22 | } 23 | 24 | func (a *yearAggregator) OnHeaderPrefix(table *tf.Table) { 25 | table. 26 | CellL(" ") // 2020 27 | } 28 | 29 | func (a *yearAggregator) OnRowPrefix(table *tf.Table, date klog.Date) { 30 | // Year 31 | table.CellR(fmt.Sprint(date.Year())) 32 | } 33 | -------------------------------------------------------------------------------- /klog/app/cli/goto.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/app/cli/util" 6 | ) 7 | 8 | type Goto struct { 9 | util.OutputFileArgs 10 | } 11 | 12 | func (opt *Goto) Run(ctx app.Context) app.Error { 13 | target, rErr := ctx.RetrieveTargetFile(opt.File) 14 | if rErr != nil { 15 | return rErr 16 | } 17 | 18 | hasSucceeded := false 19 | for _, c := range ctx.FileExplorers() { 20 | c.Args = append(c.Args, target.Location()) 21 | cErr := ctx.Execute(c) 22 | if cErr != nil { 23 | continue 24 | } 25 | hasSucceeded = true 26 | break 27 | } 28 | 29 | if !hasSucceeded { 30 | return app.NewError( 31 | "Failed to open file browser", 32 | "Opening a file browser doesn’t seem possible on your system.", 33 | nil, 34 | ) 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /klog/parser/txt/util.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | // SubRune returns a subset of a rune list. It might be shorter than 4 | // the requested length, if the text doesn’t contain enough characters. 5 | // It returns empty, if the start position is bigger than the length. 6 | func SubRune(text []rune, start int, length int) []rune { 7 | if start >= len(text) { 8 | return nil 9 | } 10 | if start+length > len(text) { 11 | length = len(text) - start 12 | } 13 | return text[start : start+length] 14 | } 15 | 16 | // IsSpaceOrTab checks whether a rune is a space or a tab character. 17 | func IsSpaceOrTab(r rune) bool { 18 | return r == ' ' || r == '\t' 19 | } 20 | 21 | func Is(matchingCharacter ...rune) func(rune) bool { 22 | return func(r rune) bool { 23 | for _, m := range matchingCharacter { 24 | if m == r { 25 | return true 26 | } 27 | } 28 | return false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install all dependencies 4 | run::install() { 5 | go get -t ./... 6 | go mod tidy 7 | } 8 | 9 | # Compile to ./out/klog 10 | # Takes two positional arguments: 11 | # - The version (e.g.: v1.2) 12 | # - The build hash (7 chars hex) 13 | run::build() { 14 | go build \ 15 | -ldflags "\ 16 | -X 'main.BinaryVersion=${1:-v?.?}' \ 17 | -X 'main.BinaryBuildHash=${2:-???????}' \ 18 | " \ 19 | -o ./out/klog \ 20 | klog.go 21 | } 22 | 23 | # Execute all tests 24 | run::test() { 25 | go test ./... 26 | } 27 | 28 | # Reformat all code 29 | run::format() { 30 | go fmt ./... 31 | } 32 | 33 | # Static code (style) analysis 34 | run::lint() { 35 | set -o errexit 36 | go vet ./... 37 | staticcheck ./... 38 | } 39 | 40 | # Run CLI from sources “on the fly” 41 | # Passes through all input args 42 | run::cli() { 43 | go run klog.go "$@" 44 | } 45 | -------------------------------------------------------------------------------- /klog/service/evaluate.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | ) 6 | 7 | // Total calculates the overall time spent in records. 8 | // It disregards open ranges. 9 | func Total(rs ...klog.Record) klog.Duration { 10 | total := klog.NewDuration(0, 0) 11 | for _, r := range rs { 12 | for _, e := range r.Entries() { 13 | total = total.Plus(e.Duration()) 14 | } 15 | } 16 | return total 17 | } 18 | 19 | // ShouldTotalSum calculates the overall should-total time of records. 20 | func ShouldTotalSum(rs ...klog.Record) klog.ShouldTotal { 21 | total := klog.NewDuration(0, 0) 22 | for _, r := range rs { 23 | total = total.Plus(r.ShouldTotal()) 24 | } 25 | return klog.NewShouldTotal(0, total.InMinutes()) 26 | } 27 | 28 | // Diff calculates the difference between should-total and actual total 29 | func Diff(should klog.ShouldTotal, actual klog.Duration) klog.Duration { 30 | return actual.Minus(should) 31 | } 32 | -------------------------------------------------------------------------------- /.github/install_darwin.md: -------------------------------------------------------------------------------- 1 | # Install klog 2 | 3 | In order to install the downloaded klog binary on your system, follow these steps: 4 | 5 | 1. Make [MacOS “Gatekeeper”](https://support.apple.com/en-us/HT202491) trust the executable: 6 | - Either right-click on the binary in the Finder, and select “Open“ 7 | - Or remove the “quarantine” flag from the binary via the CLI: 8 | `xattr -d com.apple.quarantine klog` 9 | 2. Copy the executable to a location that’s included in your `$PATH` environment variable, e.g. 10 | `mv klog /usr/local/bin/klog` (might require `sudo`) 11 | 12 | For other install options, see [the documentation website](https://klog.jotaen.net/#get-klog). 13 | 14 | ## Check for updates 15 | 16 | In order to not miss any updates you can either subscribe to the release 17 | notifications on [Github](https://github.com/jotaen/klog) (at the top right: 18 | “Watch” → “Custom” → “Releases”), or you occasionally check by running 19 | `klog version`. 20 | -------------------------------------------------------------------------------- /klog/parser/reconciling/start_open_range.go: -------------------------------------------------------------------------------- 1 | package reconciling 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | ) 7 | 8 | // StartOpenRange appends a new open range entry in a record. 9 | func (r *Reconciler) StartOpenRange(startTime klog.Time, format ReformatDirective[klog.TimeFormat], entrySummary klog.EntrySummary) error { 10 | if r.findOpenRangeIndex() != -1 { 11 | return errors.New("There is already an open range in this record") 12 | } 13 | format.apply(r.style.timeFormat(), func(f klog.TimeFormat) { 14 | // Re-parse time to apply format. 15 | reformattedTime, err := klog.NewTimeFromString(startTime.ToStringWithFormat(f)) 16 | if err != nil { 17 | panic("Invalid time") 18 | } 19 | startTime = reformattedTime 20 | }) 21 | or := klog.NewOpenRangeWithFormat(startTime, r.style.openRangeFormat()) 22 | entryValue := or.ToString() 23 | r.insert(r.lastLinePointer, toMultilineEntryTexts(entryValue, entrySummary)) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /klog/app/main/completion_predictors.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/posener/complete" 6 | ) 7 | 8 | func predictBookmarks(ctx app.Context) complete.Predictor { 9 | thunk := func() []string { 10 | names := make([]string, 0) 11 | bookmarksCollection, err := ctx.ReadBookmarks() 12 | if err != nil { 13 | return names 14 | } 15 | for _, bookmark := range bookmarksCollection.All() { 16 | names = append(names, bookmark.Name().ValuePretty()) 17 | } 18 | return names 19 | } 20 | return complete.PredictFunc(func(a complete.Args) []string { return thunk() }) 21 | } 22 | 23 | func CompletionPredictors(ctx app.Context) map[string]complete.Predictor { 24 | return map[string]complete.Predictor{ 25 | "file": complete.PredictFiles("*.klg"), 26 | "bookmark": predictBookmarks(ctx), 27 | "file_or_bookmark": complete.PredictOr(complete.PredictFiles("*.klg"), predictBookmarks(ctx)), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /klog/service/evaluate_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestTotalSumUpZeroIfNoTimesSpecified(t *testing.T) { 10 | r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 11 | assert.Equal(t, klog.NewDuration(0, 0), Total(r)) 12 | } 13 | 14 | func TestTotalSumsUpTimesAndRangesButNotOpenRanges(t *testing.T) { 15 | r1 := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 16 | r1.AddDuration(klog.NewDuration(3, 0), nil) 17 | r1.AddDuration(klog.NewDuration(1, 33), nil) 18 | r1.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(8, 0), klog.Ɀ_TimeTomorrow_(12, 0)), nil) 19 | r1.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(13, 49), klog.Ɀ_Time_(17, 12)), nil) 20 | _ = r1.Start(klog.NewOpenRange(klog.Ɀ_Time_(1, 2)), nil) 21 | r2 := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 2)) 22 | r2.AddDuration(klog.NewDuration(7, 55), nil) 23 | assert.Equal(t, klog.NewDuration(3+1+(16+24+12)+3+7, 33+11+12+55), Total(r1, r2)) 24 | } 25 | -------------------------------------------------------------------------------- /klog/app/cli/util/with_repeat.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | gotime "time" 9 | ) 10 | 11 | // WithRepeat repetitively invokes the callback at the desired rate. 12 | // It always clears the terminal screen. 13 | func WithRepeat(print func(string), interval gotime.Duration, fn func(int64) app.Error) app.Error { 14 | // Handle ^C gracefully 15 | c := make(chan os.Signal, 1) 16 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 17 | go func() { 18 | <-c 19 | os.Exit(0) 20 | }() 21 | 22 | // Call handler function repetitively 23 | print("\033[2J") // Initial screen clearing 24 | ticker := gotime.NewTicker(interval) 25 | defer ticker.Stop() 26 | secondsCounter := int64(0) // Choose large type because of overflow 27 | for ; true; <-ticker.C { 28 | secondsCounter += 1 29 | print("\033[H\033[J") // Cursor reset 30 | err := fn(secondsCounter) 31 | if err != nil { 32 | return err 33 | } 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /klog/app/cli/report/week.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/service/period" 8 | ) 9 | 10 | type weekAggregator struct { 11 | y int 12 | } 13 | 14 | func NewWeekAggregator() Aggregator { 15 | return &weekAggregator{-1} 16 | } 17 | 18 | func (a *weekAggregator) NumberOfPrefixColumns() int { 19 | return 2 20 | } 21 | 22 | func (a *weekAggregator) DateHash(date klog.Date) period.Hash { 23 | return period.Hash(period.NewWeekFromDate(date).Hash()) 24 | } 25 | 26 | func (a *weekAggregator) OnHeaderPrefix(table *tf.Table) { 27 | table. 28 | CellL(" "). // 2020 29 | CellL(" ") // Week 33 30 | } 31 | 32 | func (a *weekAggregator) OnRowPrefix(table *tf.Table, date klog.Date) { 33 | year, week := date.WeekNumber() 34 | 35 | if year != a.y { 36 | table.CellR(fmt.Sprint(year)) 37 | a.y = year 38 | } else { 39 | table.Skip(1) 40 | } 41 | 42 | table.CellR(fmt.Sprintf("Week %2v", week)) 43 | } 44 | -------------------------------------------------------------------------------- /klog/app/cli/report/quarter.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/service/period" 8 | ) 9 | 10 | type quarterAggregator struct { 11 | y int 12 | } 13 | 14 | func NewQuarterAggregator() Aggregator { 15 | return &quarterAggregator{-1} 16 | } 17 | 18 | func (a *quarterAggregator) NumberOfPrefixColumns() int { 19 | return 2 20 | } 21 | 22 | func (a *quarterAggregator) DateHash(date klog.Date) period.Hash { 23 | return period.Hash(period.NewQuarterFromDate(date).Hash()) 24 | } 25 | 26 | func (a *quarterAggregator) OnHeaderPrefix(table *tf.Table) { 27 | table. 28 | CellL(" "). // 2020 29 | CellL(" ") // Q2 30 | } 31 | 32 | func (a *quarterAggregator) OnRowPrefix(table *tf.Table, date klog.Date) { 33 | // Year 34 | if date.Year() != a.y { 35 | table.CellR(fmt.Sprint(date.Year())) 36 | a.y = date.Year() 37 | } else { 38 | table.Skip(1) 39 | } 40 | 41 | // Quarter 42 | table.CellR(fmt.Sprintf("Q%1v", date.Quarter())) 43 | } 44 | -------------------------------------------------------------------------------- /klog/app/cli/report/month.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/app/cli/util" 8 | "github.com/jotaen/klog/klog/service/period" 9 | ) 10 | 11 | type monthAggregator struct { 12 | y int 13 | } 14 | 15 | func NewMonthAggregator() Aggregator { 16 | return &monthAggregator{-1} 17 | } 18 | 19 | func (a *monthAggregator) NumberOfPrefixColumns() int { 20 | return 2 21 | } 22 | 23 | func (a *monthAggregator) DateHash(date klog.Date) period.Hash { 24 | return period.Hash(period.NewMonthFromDate(date).Hash()) 25 | } 26 | 27 | func (a *monthAggregator) OnHeaderPrefix(table *tf.Table) { 28 | table. 29 | CellL(" "). // 2020 30 | CellL(" ") // Dec 31 | } 32 | 33 | func (a *monthAggregator) OnRowPrefix(table *tf.Table, date klog.Date) { 34 | // Year 35 | if date.Year() != a.y { 36 | table.CellR(fmt.Sprint(date.Year())) 37 | a.y = date.Year() 38 | } else { 39 | table.Skip(1) 40 | } 41 | 42 | // Month 43 | table.CellR(util.PrettyMonth(date.Month())[:3]) 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2020 Jan Heuermann (https://www.jotaen.net) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /klog/service/datetime.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | gotime "time" 6 | ) 7 | 8 | // DateTime represents a point in time with a normalized time value. 9 | type DateTime struct { 10 | Date klog.Date 11 | Time klog.Time 12 | } 13 | 14 | func NewDateTime(d klog.Date, t klog.Time) DateTime { 15 | normalizedTime, _ := klog.NewTime(t.Hour(), t.Minute()) 16 | dayOffset := func() int { 17 | if t.IsTomorrow() { 18 | return 1 19 | } else if t.IsYesterday() { 20 | return -1 21 | } 22 | return 0 23 | }() 24 | return DateTime{ 25 | Date: d.PlusDays(dayOffset), 26 | Time: normalizedTime, 27 | } 28 | } 29 | 30 | func NewDateTimeFromGo(reference gotime.Time) DateTime { 31 | date := klog.NewDateFromGo(reference) 32 | time := klog.NewTimeFromGo(reference) 33 | return NewDateTime(date, time) 34 | } 35 | 36 | func (dt DateTime) IsEqual(compare DateTime) bool { 37 | return dt.Date.IsEqualTo(compare.Date) && dt.Time.IsEqualTo(compare.Time) 38 | } 39 | 40 | func (dt DateTime) IsAfterOrEqual(compare DateTime) bool { 41 | if dt.Date.IsEqualTo(compare.Date) { 42 | return dt.Time.IsAfterOrEqual(compare.Time) 43 | } 44 | return dt.Date.IsAfterOrEqual(compare.Date) 45 | } 46 | -------------------------------------------------------------------------------- /.github/smoke-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Performs a test of the built binary. That includes: 4 | # - Doing a brief smoke test to check that the binary can be invoked 5 | # - Checking that all build-time information got compiled in correctly 6 | 7 | set -e 8 | 9 | echo 'Print help text...' 10 | klog --help 1>/dev/null 11 | 12 | echo 'Create sample file...' 13 | FILE='time.klg' 14 | echo ' 15 | 2020-01-15 16 | Did #something 17 | 1h this 18 | 13:00-14:00 that 19 | ' > "${FILE}" 20 | 21 | echo 'Evaluate sample file...' 22 | klog total "${FILE}" 1>/dev/null 23 | 24 | echo 'Check version...' 25 | ACTUAL_VERSION="$(klog version --no-check --quiet)" 26 | [[ "${ACTUAL_VERSION}" == "${EXPECTED_VERSION}" ]] || exit 1 27 | 28 | echo 'Check build hash...' 29 | ACTUAL_BUILD_HASH="$(klog version --no-check | grep -oE '\[[abcdef0123456789]{7}]')" 30 | [[ "${ACTUAL_BUILD_HASH}" == "[${EXPECTED_BUILD_HASH::7}]" ]] || exit 1 31 | 32 | echo 'Check embedded spec file...' 33 | ACTUAL_SPEC="$(klog info spec)" 34 | [[ "${ACTUAL_SPEC}" == "$(cat "${EXPECTED_SPEC_PATH}")" ]] || exit 1 35 | 36 | echo 'Check embedded license file...' 37 | ACTUAL_LICENSE="$(klog info license)" 38 | [[ "${ACTUAL_LICENSE}" == "$(cat "${EXPECTED_LICENSE_PATH}")" ]] || exit 1 39 | -------------------------------------------------------------------------------- /klog/app/sys_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package app 4 | 5 | import ( 6 | "github.com/jotaen/klog/klog/app/cli/command" 7 | "os" 8 | "syscall" 9 | "unsafe" 10 | ) 11 | 12 | var POTENTIAL_EDITORS = []command.Command{ 13 | command.New("notepad", nil), 14 | } 15 | 16 | var POTENTIAL_FILE_EXLORERS = []command.Command{ 17 | command.New("cmd.exe", []string{"/C", "start"}), 18 | } 19 | 20 | var KLOG_CONFIG_FOLDER = []KlogFolder{ 21 | {"KLOG_CONFIG_HOME", ""}, 22 | {"AppData", "klog"}, 23 | } 24 | 25 | func (kf KlogFolder) EnvVarSymbol() string { 26 | return "%" + kf.BasePathEnvVar + "%" 27 | } 28 | 29 | func init() { 30 | enableAnsiEscapeSequences() 31 | } 32 | 33 | func enableAnsiEscapeSequences() { 34 | const enableVirtualTerminalProcessing = 0x0004 35 | 36 | var ( 37 | kernel32 = syscall.NewLazyDLL("kernel32.dll") 38 | procGetConsoleMode = kernel32.NewProc("GetConsoleMode") 39 | procSetConsoleMode = kernel32.NewProc("SetConsoleMode") 40 | ) 41 | 42 | var mode uint32 43 | procGetConsoleMode.Call(os.Stdout.Fd(), uintptr(unsafe.Pointer(&mode))) 44 | if (mode & enableVirtualTerminalProcessing) != enableVirtualTerminalProcessing { 45 | procSetConsoleMode.Call(os.Stdout.Fd(), uintptr(mode|enableVirtualTerminalProcessing)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /klog/parser/reconciling/close_open_range.go: -------------------------------------------------------------------------------- 1 | package reconciling 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "regexp" 7 | ) 8 | 9 | // CloseOpenRange tries to close the open time range. 10 | func (r *Reconciler) CloseOpenRange(endTime klog.Time, format ReformatDirective[klog.TimeFormat], additionalSummary klog.EntrySummary) error { 11 | openRangeEntryIndex := r.findOpenRangeIndex() 12 | if openRangeEntryIndex == -1 { 13 | return errors.New("No open time range") 14 | } 15 | eErr := r.Record.EndOpenRange(endTime) 16 | if eErr != nil { 17 | return errors.New("Start and end time must be in chronological order") 18 | } 19 | 20 | // Replace question mark with end time. 21 | openRangeValueLineIndex := r.lastLinePointer - countLines(r.Record.Entries()[openRangeEntryIndex:]) 22 | endTimeValue := endTime.ToString() 23 | format.apply(r.style.timeFormat(), func(f klog.TimeFormat) { 24 | endTimeValue = endTime.ToStringWithFormat(f) 25 | }) 26 | r.lines[openRangeValueLineIndex].Text = regexp.MustCompile(`^(.*?)\?+(.*)$`). 27 | ReplaceAllString( 28 | r.lines[openRangeValueLineIndex].Text, 29 | "${1}"+endTimeValue+"${2}", 30 | ) 31 | 32 | r.concatenateSummary(openRangeEntryIndex, openRangeValueLineIndex, additionalSummary) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/reflow.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | import "strings" 4 | 5 | type Reflower struct { 6 | maxLength int 7 | newLine string 8 | } 9 | 10 | func NewReflower(maxLineLength int, newLineChar string) Reflower { 11 | return Reflower{ 12 | maxLength: maxLineLength, 13 | newLine: newLineChar, 14 | } 15 | } 16 | 17 | func (b Reflower) Reflow(text string, linePrefixes []string) string { 18 | SPACE := " " 19 | var resultParagraphs []string 20 | 21 | for _, paragraph := range strings.Split(text, b.newLine) { 22 | words := strings.Split(paragraph, SPACE) 23 | lines := []string{""} 24 | currentLinePrefix := "" 25 | for i, word := range words { 26 | nr := len(lines) - 1 27 | isLastWordOfText := i == len(words)-1 28 | if !isLastWordOfText && len(lines[nr])+len(words[i+1]) > b.maxLength { 29 | lines = append(lines, "") 30 | nr = len(lines) - 1 31 | } 32 | if lines[nr] == "" { 33 | if len(linePrefixes) > nr { 34 | currentLinePrefix = linePrefixes[nr] 35 | } 36 | lines[nr] += currentLinePrefix 37 | } else { 38 | lines[nr] += SPACE 39 | } 40 | lines[nr] += word 41 | } 42 | resultParagraphs = append(resultParagraphs, strings.Join(lines, b.newLine)) 43 | } 44 | return strings.Join(resultParagraphs, b.newLine) 45 | } 46 | -------------------------------------------------------------------------------- /klog/service/record.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | gotime "time" 7 | ) 8 | 9 | // CloseOpenRanges closes open ranges at the time of `endTime`. Returns an error 10 | // if a range is not closeable at that point in time. 11 | // This method alters the provided records! 12 | // The bool return value indicates whether any open ranges have been closed. 13 | func CloseOpenRanges(endTime gotime.Time, rs ...klog.Record) (bool, error) { 14 | thisDay := klog.NewDateFromGo(endTime) 15 | theDayBefore := thisDay.PlusDays(-1) 16 | hasClosedAnyRange := false 17 | for _, r := range rs { 18 | if r.OpenRange() == nil { 19 | continue 20 | } 21 | end, tErr := func() (klog.Time, error) { 22 | end := klog.NewTimeFromGo(endTime) 23 | if r.Date().IsEqualTo(thisDay) { 24 | return end, nil 25 | } 26 | if r.Date().IsEqualTo(theDayBefore) { 27 | return end.Plus(klog.NewDuration(24, 0)) 28 | } 29 | return nil, errors.New("Encountered uncloseable open range") 30 | }() 31 | if tErr != nil { 32 | return false, tErr 33 | } 34 | eErr := r.EndOpenRange(end) 35 | hasClosedAnyRange = true 36 | if eErr != nil { 37 | return false, errors.New("Encountered uncloseable open range") 38 | } 39 | } 40 | return hasClosedAnyRange, nil 41 | } 42 | -------------------------------------------------------------------------------- /klog/service/period/year.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "regexp" 7 | "strconv" 8 | ) 9 | 10 | type Year struct { 11 | date klog.Date 12 | } 13 | 14 | type YearHash Hash 15 | 16 | var yearPattern = regexp.MustCompile(`^\d{4}$`) 17 | 18 | func NewYearFromDate(d klog.Date) Year { 19 | return Year{d} 20 | } 21 | 22 | func NewYearFromString(yyyy string) (Year, error) { 23 | if !yearPattern.MatchString(yyyy) { 24 | return Year{}, errors.New("INVALID_YEAR_PERIOD") 25 | } 26 | year, err := strconv.Atoi(yyyy) 27 | if err != nil { 28 | return Year{}, errors.New("INVALID_YEAR_PERIOD") 29 | } 30 | d, dErr := klog.NewDate(year, 1, 1) 31 | if dErr != nil { 32 | return Year{}, errors.New("INVALID_YEAR_PERIOD") 33 | } 34 | return Year{d}, nil 35 | } 36 | 37 | func (y Year) Period() Period { 38 | since, _ := klog.NewDate(y.date.Year(), 1, 1) 39 | until, _ := klog.NewDate(y.date.Year(), 12, 31) 40 | return NewPeriod(since, until) 41 | } 42 | 43 | func (y Year) Previous() Year { 44 | lastYear, err := klog.NewDate(y.date.Year()-1, 1, 1) 45 | if err != nil { 46 | panic("Invalid year") 47 | } 48 | return Year{lastYear} 49 | } 50 | 51 | func (y Year) Hash() YearHash { 52 | hash := newBitMask() 53 | hash.populate(uint32(y.date.Year()), 10000) 54 | return YearHash(hash.Value()) 55 | } 56 | -------------------------------------------------------------------------------- /klog/entry.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | // Entry is a time value and an associated entry summary. 4 | // A time value can be a Range, a Duration, or an OpenRange. 5 | type Entry struct { 6 | value any 7 | summary EntrySummary 8 | } 9 | 10 | func NewEntryFromDuration(value Duration, summary EntrySummary) Entry { 11 | return Entry{value, summary} 12 | } 13 | 14 | func NewEntryFromRange(value Range, summary EntrySummary) Entry { 15 | return Entry{value, summary} 16 | } 17 | 18 | func NewEntryFromOpenRange(value OpenRange, summary EntrySummary) Entry { 19 | return Entry{value, summary} 20 | } 21 | 22 | func (e *Entry) Summary() EntrySummary { 23 | return e.summary 24 | } 25 | 26 | // Unbox converts the underlying time value. 27 | func Unbox[TargetT any](e *Entry, r func(Range) TargetT, d func(Duration) TargetT, o func(OpenRange) TargetT) TargetT { 28 | switch x := e.value.(type) { 29 | case Range: 30 | return r(x) 31 | case Duration: 32 | return d(x) 33 | case OpenRange: 34 | return o(x) 35 | } 36 | panic("Incomplete switch statement") 37 | } 38 | 39 | // Duration returns the duration value of the underlying time value. 40 | func (e *Entry) Duration() Duration { 41 | return Unbox[Duration](e, 42 | func(r Range) Duration { return r.Duration() }, 43 | func(d Duration) Duration { return NewDuration(0, d.InMinutes()) }, 44 | func(o OpenRange) Duration { return NewDuration(0, 0) }, 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /klog/parser/txt/indentation.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import "strings" 4 | 5 | // NewIndentator creates an indentator object, if the given line is indented 6 | // according to the allowed styles. 7 | func NewIndentator(allowedIndentationStyles []string, l Line) *Indentator { 8 | for _, s := range allowedIndentationStyles { 9 | if strings.HasPrefix(l.Text, s) { 10 | return &Indentator{s} 11 | } 12 | } 13 | return nil 14 | } 15 | 16 | // Indentator is a utility to check to process indented text. It is initialised 17 | // with the first indented line, and determines the indentation style of that line. 18 | // For all subsequent lines, it can then create Parseable’s that are already pre- 19 | // processed. 20 | type Indentator struct { 21 | indentationStyle string 22 | } 23 | 24 | // NewIndentedParseable returns a Parseable with already skipped indentation. 25 | // It returns `nil` if the encountered indentation level is smaller than `atLevel`. 26 | // It only consumes the desired indentation and disregards any additional indentation. 27 | func (i *Indentator) NewIndentedParseable(l Line, atLevel int) *Parseable { 28 | expectedIndentation := strings.Repeat(i.indentationStyle, atLevel) 29 | if !strings.HasPrefix(l.Text, expectedIndentation) { 30 | return nil 31 | } 32 | return NewParseable(l, len(expectedIndentation)) 33 | } 34 | 35 | func (i *Indentator) Style() string { 36 | return i.indentationStyle 37 | } 38 | -------------------------------------------------------------------------------- /klog/app/cli/report/day.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/app/cli/util" 8 | "github.com/jotaen/klog/klog/service/period" 9 | ) 10 | 11 | type dayAggregator struct { 12 | y int 13 | m int 14 | } 15 | 16 | func NewDayAggregator() Aggregator { 17 | return &dayAggregator{-1, -1} 18 | } 19 | 20 | func (a *dayAggregator) NumberOfPrefixColumns() int { 21 | return 4 22 | } 23 | 24 | func (a *dayAggregator) DateHash(date klog.Date) period.Hash { 25 | return period.Hash(period.NewDayFromDate(date).Hash()) 26 | } 27 | 28 | func (a *dayAggregator) OnHeaderPrefix(table *tf.Table) { 29 | table. 30 | CellL(" "). // 2020 31 | CellL(" "). // Dec 32 | CellL(" "). // Sun 33 | CellR(" ") // 17. 34 | } 35 | 36 | func (a *dayAggregator) OnRowPrefix(table *tf.Table, date klog.Date) { 37 | // Year 38 | if date.Year() != a.y { 39 | a.m = -1 // force month to be recalculated 40 | table.CellR(fmt.Sprint(date.Year())) 41 | a.y = date.Year() 42 | } else { 43 | table.Skip(1) 44 | } 45 | 46 | // Month 47 | if date.Month() != a.m { 48 | a.m = date.Month() 49 | table.CellR(util.PrettyMonth(a.m)[:3]) 50 | } else { 51 | table.Skip(1) 52 | } 53 | 54 | // Day 55 | table.CellR(util.PrettyDay(date.Weekday())[:3]).CellR(fmt.Sprintf("%2v.", date.Day())) 56 | } 57 | -------------------------------------------------------------------------------- /klog/parser/engine.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/parser/engine" 6 | "github.com/jotaen/klog/klog/parser/txt" 7 | ) 8 | 9 | // Parser parses a text into a list of Record datastructures. On success, it returns 10 | // the parsed records. Otherwise, it returns all encountered parser errors. 11 | type Parser interface { 12 | // Parse parses records from a string. It returns them along with the 13 | // respective blocks. Those two arrays have the same length. 14 | // Errors are reported via the last error array. In this case, the records 15 | // and blocks are nil. Note, one record can produce multiple errors, 16 | // so the length of the error array doesn’t say anything about the number 17 | // of records. 18 | Parse(string) ([]klog.Record, []txt.Block, []txt.Error) 19 | } 20 | 21 | // NewSerialParser returns a new parser, which processes the input text 22 | // serially, i.e. one after the other. 23 | func NewSerialParser() Parser { 24 | return serialParser 25 | } 26 | 27 | // NewParallelParser returns a new parser, which processes the input text 28 | // in parallel. The parsing result is the same as with the serial parser. 29 | func NewParallelParser(numberOfWorkers int) Parser { 30 | return engine.ParallelBatchParser[klog.Record]{ 31 | SerialParser: serialParser, 32 | NumberOfWorkers: numberOfWorkers, 33 | } 34 | } 35 | 36 | var serialParser = engine.SerialParser[klog.Record]{ 37 | ParseOne: parse, 38 | } 39 | -------------------------------------------------------------------------------- /klog/parser/engine/serial.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import "github.com/jotaen/klog/klog/parser/txt" 4 | 5 | type SerialParser[T any] struct { 6 | ParseOne func(txt.Block) (T, []txt.Error) 7 | } 8 | 9 | func (p SerialParser[T]) Parse(text string) ([]T, []txt.Block, []txt.Error) { 10 | ts, blocks, _, errs, hasErrors := p.mapParse(text) 11 | if hasErrors { 12 | return nil, nil, flatten[txt.Error](errs) 13 | } 14 | return ts, blocks, nil 15 | } 16 | 17 | // mapParse parses the text. All 3 return arrays have the same arity, and the last 18 | // bool indicates whether any errors occurred. 19 | func (p SerialParser[T]) mapParse(text string) ([]T, []txt.Block, int, [][]txt.Error, bool) { 20 | var ts []T 21 | var blocks []txt.Block 22 | var errs [][]txt.Error 23 | totalBytesConsumed := 0 24 | totalLines := 0 25 | hasErrors := false 26 | for { 27 | block, bytesConsumed := txt.ParseBlock(text[totalBytesConsumed:], totalLines) 28 | if bytesConsumed == 0 || block == nil { 29 | break 30 | } 31 | totalLines += len(block.Lines()) 32 | totalBytesConsumed += bytesConsumed 33 | t, err := p.ParseOne(block) 34 | ts = append(ts, t) 35 | blocks = append(blocks, block) 36 | errs = append(errs, err) 37 | if err != nil { 38 | hasErrors = true 39 | } 40 | } 41 | return ts, blocks, totalBytesConsumed, errs, hasErrors 42 | } 43 | 44 | func flatten[T any](xss [][]T) []T { 45 | var result []T 46 | for _, xs := range xss { 47 | if len(xs) == 0 { 48 | continue 49 | } 50 | result = append(result, xs...) 51 | } 52 | return result 53 | } 54 | -------------------------------------------------------------------------------- /klog/app/cli/json.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/app/cli/util" 6 | "github.com/jotaen/klog/klog/parser/json" 7 | ) 8 | 9 | type Json struct { 10 | Pretty bool `name:"pretty" help:"Pretty-print output."` 11 | util.NowArgs 12 | util.FilterArgs 13 | util.SortArgs 14 | util.InputFilesArgs 15 | } 16 | 17 | func (opt *Json) Help() string { 18 | return ` 19 | The output structure is a JSON object which contains two properties at the top level: 'records' and 'errors'. 20 | If the file is valid, 'records' is an array containing a JSON object for each record, and 'errors' is 'null'. 21 | If the file has syntax errors, 'records' is 'null', and 'errors' contains an array of error objects. 22 | 23 | The structure of the 'record' and 'error' objects is always uniform and should be self-explanatory. 24 | You can best explore it by running the command with the --pretty flag. 25 | ` 26 | } 27 | 28 | func (opt *Json) Run(ctx app.Context) app.Error { 29 | records, err := ctx.ReadInputs(opt.File...) 30 | if err != nil { 31 | parserErrs, isParserErr := err.(app.ParserErrors) 32 | if isParserErr { 33 | ctx.Print(json.ToJson(nil, parserErrs.All(), opt.Pretty) + "\n") 34 | return nil 35 | } 36 | return err 37 | } 38 | now := ctx.Now() 39 | nErr := opt.ApplyNow(now, records...) 40 | if nErr != nil { 41 | return nErr 42 | } 43 | records = opt.ApplyFilter(now, records) 44 | records = opt.ApplySort(records) 45 | ctx.Print(json.ToJson(records, nil, opt.Pretty) + "\n") 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /klog/service/period/period_test.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestDeserialisePattern(t *testing.T) { 10 | for _, x := range []string{ 11 | "2022", 12 | "2022-05", 13 | "2022-Q2", 14 | "2022-W18", 15 | } { 16 | period, err := NewPeriodFromPatternString(x) 17 | assert.Nil(t, err) 18 | assert.IsType(t, NewPeriod(klog.Ɀ_Date_(1, 1, 1), klog.Ɀ_Date_(1, 1, 1)), period) 19 | } 20 | } 21 | 22 | func TestDeserialisePatternFails(t *testing.T) { 23 | period, err := NewPeriodFromPatternString("x") 24 | assert.Error(t, err) 25 | assert.Nil(t, period) 26 | } 27 | 28 | func TestHashYieldsDistinctValues(t *testing.T) { 29 | dayHashes := make(map[DayHash]bool) 30 | weekHashes := make(map[WeekHash]bool) 31 | monthHashes := make(map[MonthHash]bool) 32 | quarterHashes := make(map[QuarterHash]bool) 33 | yearHashes := make(map[YearHash]bool) 34 | 35 | // 1.1.1000 is a Wednesday. 1000 days later it’s Sunday, 27.9.1002 36 | initialDate := klog.Ɀ_Date_(1000, 1, 1) 37 | for i := 0; i < 1000; i++ { 38 | d := initialDate.PlusDays(i) 39 | dayHashes[NewDayFromDate(d).Hash()] = true 40 | weekHashes[NewWeekFromDate(d).Hash()] = true 41 | monthHashes[NewMonthFromDate(d).Hash()] = true 42 | quarterHashes[NewQuarterFromDate(d).Hash()] = true 43 | yearHashes[NewYearFromDate(d).Hash()] = true 44 | } 45 | 46 | assert.Len(t, dayHashes, 1000) 47 | assert.Len(t, weekHashes, 144) 48 | assert.Len(t, monthHashes, 33) 49 | assert.Len(t, quarterHashes, 11) 50 | assert.Len(t, yearHashes, 3) 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | klog logo 5 | 6 | 7 | # klog 8 | 9 | klog is a plain-text file format and a command line tool for time tracking. 10 | 11 | 📕 [**Documentation**](https://klog.jotaen.net) – **Learn what klog is and how to use it** 12 | 13 | 📥 [Install](https://klog.jotaen.net#get-klog) – Get the latest version 14 | 15 | 📢 [Changelog](CHANGELOG.md) – See what’s new 16 | 17 | 💬 [Discussions](https://github.com/jotaen/klog/discussions) – Ask questions and share feedback 18 | 19 | 💡 [Specification](Specification.md) – Study the file format 20 | 21 | ## Participate 22 | 23 | If you have questions, feedback, feature ideas, or want to report something that’s not working properly, 24 | feel invited to [start a discussion](https://github.com/jotaen/klog/discussions). 25 | 26 | If you’d like to contribute code, please discuss your intended change beforehand. Please refrain from submitting pull requests proactively. 27 | 28 | ## About 29 | 30 | klog was created by [Jan Heuermann](https://www.jotaen.net). 31 | 32 | You are free to use it under the following terms: 33 | 34 | - Command line tool: [MIT license](LICENSE.txt) 35 | - File specification: [public domain (CC0/OWFa)](Specification.md#License) 36 | 37 | Note that the command line tool and the file format specification have independent version numbering schemes. 38 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/style.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | type StyleProps struct { 4 | Color Colour 5 | Background Colour 6 | IsBold bool 7 | IsUnderlined bool 8 | } 9 | 10 | type Styler struct { 11 | props StyleProps 12 | colourCodes map[Colour]string 13 | reset string 14 | foregroundPrefix string 15 | backgroundPrefix string 16 | colourSuffix string 17 | underlined string 18 | bold string 19 | } 20 | 21 | type Colour int 22 | 23 | const ( 24 | unspecified = iota 25 | TEXT 26 | TEXT_SUBDUED 27 | TEXT_INVERSE 28 | GREEN 29 | RED 30 | YELLOW 31 | BLUE_DARK 32 | BLUE_LIGHT 33 | PURPLE 34 | ) 35 | 36 | func (s Styler) Format(text string) string { 37 | return s.seqs() + text + s.reset 38 | } 39 | 40 | func (s Styler) Props(p StyleProps) Styler { 41 | newS := s 42 | newS.props = p 43 | return newS 44 | } 45 | 46 | func (s Styler) FormatAndRestore(text string, previousStyle Styler) string { 47 | return s.Format(text) + previousStyle.seqs() 48 | } 49 | 50 | func (s Styler) seqs() string { 51 | seqs := s.reset 52 | 53 | if s.props.Color != unspecified && s.colourCodes[s.props.Color] != "" { 54 | seqs = seqs + s.foregroundPrefix + s.colourCodes[s.props.Color] + s.colourSuffix 55 | } 56 | 57 | if s.props.Background != unspecified && s.colourCodes[s.props.Background] != "" { 58 | seqs = seqs + s.backgroundPrefix + s.colourCodes[s.props.Background] + s.colourSuffix 59 | } 60 | 61 | if s.props.IsUnderlined { 62 | seqs = seqs + s.underlined 63 | } 64 | 65 | if s.props.IsBold { 66 | seqs = seqs + s.bold 67 | } 68 | 69 | return seqs 70 | } 71 | -------------------------------------------------------------------------------- /klog/parser/reconciling/style_reformat.go: -------------------------------------------------------------------------------- 1 | package reconciling 2 | 3 | import "github.com/jotaen/klog/klog" 4 | 5 | // ReformatDirective tells the reconciler whether or in which way the 6 | // date or time value is supposed to be reformatted when serialising it. 7 | type ReformatDirective[T klog.TimeFormat | klog.DateFormat] struct { 8 | Value T 9 | mode int // 0 = Don’t do anything; 1 = Reformat from `Value`; 2 = Apply auto-styling 10 | } 11 | 12 | // NoReformat means the time/date value should be taken as is, without touching 13 | // its own existing format. 14 | func NoReformat[T klog.TimeFormat | klog.DateFormat]() ReformatDirective[T] { 15 | return ReformatDirective[T]{mode: 0} 16 | } 17 | 18 | // ReformatExplicitly means that the time/date value should be reformatted 19 | // according to the provided format. 20 | func ReformatExplicitly[T klog.TimeFormat | klog.DateFormat](value T) ReformatDirective[T] { 21 | return ReformatDirective[T]{Value: value, mode: 1} 22 | } 23 | 24 | // ReformatAutoStyle means that the time/date value should be reformatted 25 | // in accordance to what prevalent style the reconciler detects in the file. 26 | // If the style cannot be determined from the file, it falls back to the 27 | // recommended style (as of the file format specification). 28 | func ReformatAutoStyle[T klog.TimeFormat | klog.DateFormat]() ReformatDirective[T] { 29 | return ReformatDirective[T]{mode: 2} 30 | } 31 | 32 | func (r ReformatDirective[T]) apply(autoStyle T, reformat func(T)) { 33 | if r.mode == 0 { 34 | return 35 | } 36 | format := autoStyle 37 | if r.mode == 1 { 38 | format = r.Value 39 | } 40 | reformat(format) 41 | } 42 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/table_test.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestPrintTable(t *testing.T) { 9 | result := "" 10 | styler := NewStyler(COLOUR_THEME_DARK) 11 | table := NewTable(3, " ") 12 | table. 13 | Cell("FIRST", Options{align: ALIGN_LEFT}). 14 | Cell("SECOND", Options{align: ALIGN_RIGHT}). 15 | Cell("THIRD", Options{align: ALIGN_RIGHT}). 16 | CellL("1"). 17 | CellR("2"). 18 | CellR("3"). 19 | Cell("long-text", Options{align: ALIGN_LEFT}). 20 | Cell(styler.Props(StyleProps{IsUnderlined: true}).Format("asdf"), Options{align: ALIGN_RIGHT}). 21 | Fill("-"). 22 | Skip(2). 23 | Cell("foo", Options{align: ALIGN_LEFT}) 24 | table.Collect(func(x string) { result += x }) 25 | assert.Equal(t, `FIRST SECOND THIRD 26 | 1 2 3 27 | long-text `+"\x1b[0m\x1b[4m"+`asdf`+"\x1b[0m"+` ----- 28 | foo 29 | `, result) 30 | } 31 | 32 | func TestPrintEmptyTable(t *testing.T) { 33 | // If the table is empty, it shouldn’t print a trailing newline. 34 | result := "" 35 | table := NewTable(3, " ") 36 | table.Collect(func(x string) { result += x }) 37 | assert.Equal(t, ``, result) 38 | } 39 | 40 | func TestPrintTableWithUnicode(t *testing.T) { 41 | result := "" 42 | table := NewTable(3, " ") 43 | table. 44 | Cell("FIRST", Options{align: ALIGN_LEFT}). 45 | Cell("SECOND", Options{align: ALIGN_LEFT}). 46 | Cell("THIRD", Options{align: ALIGN_LEFT}). 47 | CellL("first"). 48 | CellR("șëčøñd"). 49 | CellR("third") 50 | table.Collect(func(x string) { result += x }) 51 | assert.Equal(t, `FIRST SECOND THIRD 52 | first șëčøñd third 53 | `, result) 54 | } 55 | -------------------------------------------------------------------------------- /klog/app/cli/edit.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/app/cli/command" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | ) 8 | 9 | type Edit struct { 10 | util.QuietArgs 11 | util.OutputFileArgs 12 | } 13 | 14 | const hint = "You can specify your preferred editor via the $EDITOR environment variable, or via the klog config file." 15 | 16 | func (opt *Edit) Help() string { 17 | return hint 18 | } 19 | 20 | func (opt *Edit) Run(ctx app.Context) app.Error { 21 | target, err := ctx.RetrieveTargetFile(opt.File) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | explicitEditor, autoEditors := ctx.Editors() 27 | 28 | if explicitEditor != "" { 29 | c, cErr := command.NewFromString(explicitEditor) 30 | if cErr != nil { 31 | return app.NewError( 32 | "Invalid editor setting", 33 | "Please check the value for invalid syntax: "+explicitEditor, 34 | cErr, 35 | ) 36 | } 37 | c.Args = append(c.Args, target.Path()) 38 | rErr := ctx.Execute(c) 39 | if rErr != nil { 40 | return app.NewError( 41 | "Cannot open preferred editor", 42 | "Editor command was: "+explicitEditor+"\nNote that if your editor path contains spaces, you have to quote it.", 43 | nil, 44 | ) 45 | } 46 | } else { 47 | hasSucceeded := false 48 | for _, c := range autoEditors { 49 | c.Args = append(c.Args, target.Path()) 50 | rErr := ctx.Execute(c) 51 | if rErr == nil { 52 | hasSucceeded = true 53 | break 54 | } 55 | } 56 | 57 | if !hasSucceeded { 58 | return app.NewError( 59 | "Cannot open any editor", 60 | hint, 61 | nil, 62 | ) 63 | } 64 | 65 | if !opt.Quiet { 66 | ctx.Print(hint + "\n") 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /klog/service/period/month.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type Month struct { 12 | date klog.Date 13 | } 14 | 15 | type MonthHash Hash 16 | 17 | var monthPattern = regexp.MustCompile(`^\d{4}-\d{2}$`) 18 | 19 | func NewMonthFromDate(d klog.Date) Month { 20 | return Month{d} 21 | } 22 | 23 | func NewMonthFromString(yyyymm string) (Month, error) { 24 | if !monthPattern.MatchString(yyyymm) { 25 | return Month{}, errors.New("INVALID_MONTH_PERIOD") 26 | } 27 | parts := strings.Split(yyyymm, "-") 28 | year, _ := strconv.Atoi(parts[0]) 29 | month, _ := strconv.Atoi(parts[1]) 30 | d, err := klog.NewDate(year, month, 1) 31 | if err != nil { 32 | return Month{}, errors.New("INVALID_MONTH_PERIOD") 33 | } 34 | return Month{d}, nil 35 | } 36 | 37 | func (m Month) Period() Period { 38 | since, _ := klog.NewDate(m.date.Year(), m.date.Month(), 1) 39 | until, _ := klog.NewDate(m.date.Year(), m.date.Month(), 28) 40 | for { 41 | if until.Year() == 9999 && until.Month() == 12 && until.Day() == 31 { 42 | // 9999-12-31 is the last representable date, so we can’t peak forward from it. 43 | break 44 | } 45 | next := until.PlusDays(1) 46 | if next.Month() != until.Month() { 47 | break 48 | } 49 | until = next 50 | } 51 | return NewPeriod(since, until) 52 | } 53 | 54 | func (m Month) Previous() Month { 55 | result := m.date 56 | for { 57 | result = result.PlusDays(-25) 58 | if result.Month() != m.date.Month() { 59 | return Month{result} 60 | } 61 | } 62 | } 63 | 64 | func (m Month) Hash() MonthHash { 65 | hash := newBitMask() 66 | hash.populate(uint32(m.date.Month()), 12) 67 | hash.populate(uint32(m.date.Year()), 10000) 68 | return MonthHash(hash.Value()) 69 | } 70 | -------------------------------------------------------------------------------- /klog/app/cli/create.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/app" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | "github.com/jotaen/klog/klog/parser/reconciling" 8 | ) 9 | 10 | type Create struct { 11 | ShouldTotal klog.ShouldTotal `name:"should" placeholder:"DURATION" help:"The should-total of the record."` 12 | ShouldTotalAlias klog.ShouldTotal `name:"should-total" placeholder:"DURATION" hidden:""` // Alias for “canonical” term 13 | Summary klog.RecordSummary `name:"summary" short:"s" placeholder:"TEXT" help:"Summary text for the new record."` 14 | util.AtDateArgs 15 | util.NoStyleArgs 16 | util.WarnArgs 17 | util.OutputFileArgs 18 | } 19 | 20 | func (opt *Create) Help() string { 21 | return ` 22 | You can set a should-total value via '--should' and a record summary via '--summary'. 23 | 24 | The new record is inserted into the file at the chronologically correct position. 25 | (Assuming that the records are sorted from oldest to latest.) 26 | ` 27 | } 28 | 29 | func (opt *Create) Run(ctx app.Context) app.Error { 30 | opt.NoStyleArgs.Apply(&ctx) 31 | date := opt.AtDate(ctx.Now()) 32 | additionalData := reconciling.AdditionalData{ShouldTotal: opt.GetShouldTotal(), Summary: opt.Summary} 33 | if additionalData.ShouldTotal == nil { 34 | ctx.Config().DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) { 35 | additionalData.ShouldTotal = s 36 | }) 37 | } 38 | return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs}, 39 | []reconciling.Creator{ 40 | reconciling.NewReconcilerForNewRecord(date, opt.DateFormat(ctx.Config()), additionalData), 41 | }, 42 | ) 43 | } 44 | 45 | func (opt *Create) GetShouldTotal() klog.ShouldTotal { 46 | if opt.ShouldTotal != nil { 47 | return opt.ShouldTotal 48 | } 49 | return opt.ShouldTotalAlias 50 | } 51 | -------------------------------------------------------------------------------- /klog/parser/txt/line.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Line is a data structure that represent one line of the source file. 8 | type Line struct { 9 | // Text contains the copy of the line. 10 | Text string 11 | 12 | // LineEnding is the encountered line ending sequence `\n` or `\r\n`. 13 | // Note that for the last line in a file, there might be no line ending. 14 | LineEnding string 15 | } 16 | 17 | var LineEndings = []string{"\r\n", "\n"} 18 | 19 | var Indentations = []string{" ", " ", " ", "\t"} 20 | 21 | // NewLineFromString turns data into a Line object. 22 | func NewLineFromString(rawLineText string) Line { 23 | text, lineEnding := splitOffLineEnding(rawLineText) 24 | return Line{ 25 | Text: text, 26 | LineEnding: lineEnding, 27 | } 28 | } 29 | 30 | // Original returns the (byte-wise) identical line of text as it appeared in the file. 31 | func (l *Line) Original() string { 32 | return l.Text + l.LineEnding 33 | } 34 | 35 | // IsBlank checks whether a line is all spaces or tabs. 36 | func (l *Line) IsBlank() bool { 37 | if len(l.Text) == 0 { 38 | return true 39 | } 40 | for _, c := range l.Text { 41 | if c != ' ' && c != '\t' { 42 | return false 43 | } 44 | } 45 | return true 46 | } 47 | 48 | // Indentation returns the indentation sequence of a line that is indented at least once. 49 | // Note: it cannot determine the level of indentation. If the line is not indented, 50 | // it returns empty string. 51 | func (l *Line) Indentation() string { 52 | for _, i := range Indentations { 53 | if strings.HasPrefix(l.Original(), i) { 54 | return i 55 | } 56 | } 57 | return "" 58 | } 59 | 60 | func splitOffLineEnding(text string) (string, string) { 61 | for _, e := range LineEndings { 62 | if strings.HasSuffix(text, e) { 63 | return text[:len(text)-len(e)], e 64 | } 65 | } 66 | return text, "" 67 | } 68 | -------------------------------------------------------------------------------- /klog/app/cli/switch.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/app/cli/util" 6 | "github.com/jotaen/klog/klog/parser/reconciling" 7 | ) 8 | 9 | type Switch struct { 10 | util.SummaryArgs 11 | util.AtDateAndTimeArgs 12 | util.NoStyleArgs 13 | util.WarnArgs 14 | util.OutputFileArgs 15 | } 16 | 17 | func (opt *Switch) Help() string { 18 | return ` 19 | Closes a previously ongoing activity (i.e., open time range), and starts a new one. 20 | This is basically a convenience for doing 'klog stop' and 'klog start' – however, in contrast to issuing both commands separately, 'klog switch' guarantees that the end time of the previous activity will be the same as the start time for the new entry. 21 | 22 | By default, it uses the record at today’s date for the new entry. You can otherwise specify a date with '--date'. 23 | 24 | Unless the '--time' flag is specified, it defaults to the current time as start/stop time. 25 | If you prefer your time to be rounded, you can use the '--round' flag. 26 | ` 27 | } 28 | 29 | func (opt *Switch) Run(ctx app.Context) app.Error { 30 | opt.NoStyleArgs.Apply(&ctx) 31 | now := ctx.Now() 32 | date := opt.AtDate(now) 33 | time, tErr := opt.AtTime(now, ctx.Config()) 34 | if tErr != nil { 35 | return tErr 36 | } 37 | 38 | return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs}, 39 | []reconciling.Creator{ 40 | reconciling.NewReconcilerAtRecord(date), 41 | }, 42 | 43 | func(reconciler *reconciling.Reconciler) error { 44 | return reconciler.CloseOpenRange(time, opt.TimeFormat(ctx.Config()), nil) 45 | }, 46 | func(reconciler *reconciling.Reconciler) error { 47 | summary, sErr := opt.Summary(reconciler.Record, nil) 48 | if sErr != nil { 49 | return sErr 50 | } 51 | return reconciler.StartOpenRange(time, opt.TimeFormat(ctx.Config()), summary) 52 | }, 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /klog/app/main/testutil_test.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 6 | "github.com/stretchr/testify/require" 7 | "io" 8 | "os" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | type Env struct { 14 | files map[string]string 15 | } 16 | 17 | type invocation struct { 18 | args []string 19 | test func(t *testing.T, code int, out string) 20 | } 21 | 22 | func (e *Env) execute(t *testing.T, is ...invocation) { 23 | // Create temp directory and change work dir to it. 24 | tmpDir, tErr := os.MkdirTemp("", "") 25 | assertNil(tErr) 26 | cErr := os.Chdir(tmpDir) 27 | assertNil(cErr) 28 | 29 | // Write out all files from `Env`. 30 | for name, contents := range e.files { 31 | err := os.WriteFile(name, []byte(contents), 0644) 32 | assertNil(err) 33 | } 34 | 35 | // Capture “old” stdout, so that we can restore later. 36 | oldStdout := os.Stdout 37 | 38 | // Run all commands one after the other. 39 | for _, invoke := range is { 40 | r, w, _ := os.Pipe() 41 | os.Stdout = w 42 | 43 | config := app.NewDefaultConfig(tf.COLOUR_THEME_NO_COLOUR) 44 | code, runErr := Run(app.NewFileOrPanic(tmpDir), app.Meta{ 45 | Specification: "[Specification text]", 46 | License: "[License text]", 47 | Version: "v0.0", 48 | SrcHash: "abc1234", 49 | }, config, invoke.args) 50 | 51 | _ = w.Close() 52 | 53 | t.Run(strings.Join(invoke.args, "__"), func(t *testing.T) { 54 | if runErr != nil { 55 | require.NotEqual(t, 0, code, "App returned error, but exit code was 0") 56 | } else { 57 | out, _ := io.ReadAll(r) 58 | invoke.test(t, code, tf.StripAllAnsiSequences(string(out))) 59 | } 60 | }) 61 | } 62 | 63 | // Clean up temp dir. 64 | rErr := os.RemoveAll(tmpDir) 65 | assertNil(rErr) 66 | os.Stdout = oldStdout 67 | } 68 | 69 | func assertNil(e error) { 70 | if e != nil { 71 | panic(e) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /klog/app/cli/total.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog/app" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | "github.com/jotaen/klog/klog/service" 8 | ) 9 | 10 | type Total struct { 11 | util.FilterArgs 12 | util.DiffArgs 13 | util.NowArgs 14 | util.DecimalArgs 15 | util.WarnArgs 16 | util.NoStyleArgs 17 | util.InputFilesArgs 18 | } 19 | 20 | func (opt *Total) Help() string { 21 | return ` 22 | By default, the total time consists of all durations and time ranges, but it doesn’t include open-ended time ranges (e.g., '8:00 - ?'). 23 | If you want to factor them in anyway, you can use the '--now' option, which treats all open-ended time ranges as if they were closed “right now”. 24 | 25 | If the records contain should-total values, you can also compute the difference between should-total and actual total by using the '--diff' flag. 26 | ` 27 | } 28 | 29 | func (opt *Total) Run(ctx app.Context) app.Error { 30 | opt.DecimalArgs.Apply(&ctx) 31 | opt.NoStyleArgs.Apply(&ctx) 32 | _, serialiser := ctx.Serialise() 33 | records, err := ctx.ReadInputs(opt.File...) 34 | if err != nil { 35 | return err 36 | } 37 | now := ctx.Now() 38 | records = opt.ApplyFilter(now, records) 39 | nErr := opt.ApplyNow(now, records...) 40 | if nErr != nil { 41 | return nErr 42 | } 43 | total := service.Total(records...) 44 | ctx.Print(fmt.Sprintf("Total: %s\n", serialiser.Duration(total))) 45 | if opt.Diff { 46 | should := service.ShouldTotalSum(records...) 47 | diff := service.Diff(should, total) 48 | ctx.Print(fmt.Sprintf("Should: %s\n", serialiser.ShouldTotal(should))) 49 | ctx.Print(fmt.Sprintf("Diff: %s\n", serialiser.SignedDuration(diff))) 50 | } 51 | ctx.Print(fmt.Sprintf("(In %d record%s)\n", len(records), func() string { 52 | if len(records) == 1 { 53 | return "" 54 | } 55 | return "s" 56 | }())) 57 | 58 | opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /klog/parser/json/view.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | // Envelop is the top level data structure of the JSON output. 4 | // It contains two nodes, `records` and `errors`, one of which is always `null`. 5 | type Envelop struct { 6 | Records []RecordView `json:"records"` 7 | Errors []ErrorView `json:"errors"` 8 | } 9 | 10 | // RecordView is the JSON representation of a record. 11 | // It also contains some evaluation data, such as the total time. 12 | type RecordView struct { 13 | Date string `json:"date"` 14 | Summary string `json:"summary"` 15 | Total string `json:"total"` 16 | TotalMins int `json:"total_mins"` 17 | ShouldTotal string `json:"should_total"` 18 | ShouldTotalMins int `json:"should_total_mins"` 19 | Diff string `json:"diff"` 20 | DiffMins int `json:"diff_mins"` 21 | Tags []string `json:"tags"` 22 | Entries []any `json:"entries"` 23 | } 24 | 25 | // EntryView is the JSON representation of an entry. 26 | type EntryView struct { 27 | // Type is one of `range`, `duration`, or `open_range`. 28 | Type string `json:"type"` 29 | Summary string `json:"summary"` 30 | 31 | // Tags is a list of all tags that the entry summary contains. 32 | Tags []string `json:"tags"` 33 | Total string `json:"total"` 34 | TotalMins int `json:"total_mins"` 35 | } 36 | 37 | type OpenRangeView struct { 38 | EntryView 39 | Start string `json:"start"` 40 | StartMins int `json:"start_mins"` 41 | } 42 | 43 | type RangeView struct { 44 | OpenRangeView 45 | End string `json:"end"` 46 | EndMins int `json:"end_mins"` 47 | } 48 | 49 | // ErrorView is the JSON representation of a parsing error. 50 | type ErrorView struct { 51 | Line int `json:"line"` 52 | Column int `json:"column"` 53 | Length int `json:"length"` 54 | Title string `json:"title"` 55 | Details string `json:"details"` 56 | File string `json:"file"` 57 | } 58 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/reflow_test.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestLineBreakerReflowsText(t *testing.T) { 9 | reflower := NewReflower(60, "\n") 10 | original := `This is a very long line and it should be reflowed so that it doesn’t run so wide, because that’s easier to read and it looks better. 11 | However, existing line breaks are respected. 12 | 13 | The same is true for blank-line-separated paragraphs` 14 | assert.Equal(t, `This is a very long line and it should be reflowed so that 15 | it doesn’t run so wide, because that’s easier to read and 16 | it looks better. 17 | However, existing line breaks are respected. 18 | 19 | The same is true for blank-line-separated paragraphs`, reflower.Reflow(original, nil)) 20 | } 21 | 22 | func TestLineBreakerDoesNotDoAnythingIfEmptyInput(t *testing.T) { 23 | reflower := NewReflower(60, "\n") 24 | assert.Equal(t, "", reflower.Reflow("", nil)) 25 | assert.Equal(t, "", reflower.Reflow(" ", nil)) 26 | assert.Equal(t, "\n", reflower.Reflow("\n", nil)) 27 | } 28 | 29 | func TestLineBreakerPrependsPrefix(t *testing.T) { 30 | reflower := NewReflower(60, "\n") 31 | original := "This is a very long line and it should be reflowed so that it doesn’t run so wide, because that’s easier to read." 32 | assert.Equal(t, ` This is a very long line and it should be reflowed so that 33 | it doesn’t run so wide, because that’s easier to read.`, reflower.Reflow(original, []string{" "})) 34 | } 35 | 36 | func TestLineBreakerPrependsMultiplePrefixes(t *testing.T) { 37 | reflower := NewReflower(30, "\n") 38 | original := "This is a very long line and it should be reflowed so that it doesn’t run so wide, because that’s easier to read." 39 | assert.Equal(t, `This is a very long line and 40 | | it should be reflowed so that 41 | | it doesn’t run so wide, 42 | | because that’s easier to read.`, reflower.Reflow(original, []string{"", "| "})) 43 | } 44 | -------------------------------------------------------------------------------- /klog/service/datetime_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestCreatesNormalizedDateTime(t *testing.T) { 10 | for _, x := range []struct { 11 | date klog.Date 12 | time klog.Time 13 | }{ 14 | {klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(15, 00)}, 15 | {klog.Ɀ_Date_(1000, 7, 14), klog.Ɀ_TimeTomorrow_(15, 00)}, 16 | {klog.Ɀ_Date_(1000, 7, 16), klog.Ɀ_TimeYesterday_(15, 00)}, 17 | } { 18 | dt := NewDateTime(x.date, x.time) 19 | assert.Equal(t, "1000-07-15", dt.Date.ToString()) 20 | assert.Equal(t, "15:00", dt.Time.ToString()) 21 | } 22 | } 23 | 24 | func TestEqualsDateTime(t *testing.T) { 25 | dt1 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 00)) 26 | dt2 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 01)) 27 | dt3 := NewDateTime(klog.Ɀ_Date_(1000, 7, 16), klog.Ɀ_Time_(12, 00)) 28 | assert.True(t, dt1.IsEqual(dt1)) 29 | assert.False(t, dt1.IsEqual(dt2)) 30 | assert.False(t, dt1.IsEqual(dt3)) 31 | assert.False(t, dt2.IsEqual(dt3)) 32 | } 33 | 34 | func TestIsAfterOrEqualsDateTime(t *testing.T) { 35 | dt1 := NewDateTime(klog.Ɀ_Date_(1000, 7, 14), klog.Ɀ_Time_(13, 00)) 36 | dt2 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(11, 59)) 37 | dt3 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 00)) 38 | dt4 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 01)) 39 | dt5 := NewDateTime(klog.Ɀ_Date_(1000, 7, 16), klog.Ɀ_Time_(11, 01)) 40 | 41 | assert.True(t, dt2.IsAfterOrEqual(dt1)) 42 | assert.True(t, dt3.IsAfterOrEqual(dt2)) 43 | assert.True(t, dt4.IsAfterOrEqual(dt3)) 44 | assert.True(t, dt5.IsAfterOrEqual(dt4)) 45 | 46 | assert.True(t, dt5.IsAfterOrEqual(dt1)) 47 | assert.True(t, dt5.IsAfterOrEqual(dt1)) 48 | 49 | assert.False(t, dt1.IsAfterOrEqual(dt2)) 50 | assert.False(t, dt1.IsAfterOrEqual(dt3)) 51 | assert.False(t, dt1.IsAfterOrEqual(dt5)) 52 | assert.False(t, dt2.IsAfterOrEqual(dt3)) 53 | } 54 | -------------------------------------------------------------------------------- /klog/parser/reconciling/pause_open_range.go: -------------------------------------------------------------------------------- 1 | package reconciling 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // AppendPause adds a new pause entry to a record that contains an open range. 11 | func (r *Reconciler) AppendPause(summary klog.EntrySummary, appendTags bool) error { 12 | openEntryI := r.findOpenRangeIndex() 13 | if openEntryI == -1 { 14 | return errors.New("No open time range found") 15 | } 16 | entryValue := "-0m" 17 | if len(summary) == 0 { 18 | summary, _ = klog.NewEntrySummary("") 19 | } 20 | if len(summary[0]) > 0 { 21 | entryValue += " " 22 | } 23 | summary[0] = entryValue + summary[0] 24 | if appendTags { 25 | openEntry := r.Record.Entries()[openEntryI] 26 | appendableTags := strings.Join(openEntry.Summary().Tags().ToStrings(), " ") 27 | summary = summary.Append(appendableTags) 28 | } 29 | return r.AppendEntry(summary) 30 | } 31 | 32 | // ExtendPause extends an existing pause entry. 33 | func (r *Reconciler) ExtendPause(increment klog.Duration) error { 34 | if r.findOpenRangeIndex() == -1 { 35 | return errors.New("No open time range found") 36 | } 37 | 38 | pauseEntryI := r.findLastEntry(func(e klog.Entry) bool { 39 | return klog.Unbox[bool](&e, func(_ klog.Range) bool { 40 | return false 41 | }, func(d klog.Duration) bool { 42 | return d.InMinutes() <= 0 43 | }, func(_ klog.OpenRange) bool { 44 | return false 45 | }) 46 | }) 47 | if pauseEntryI == -1 { 48 | return errors.New("Could not find existing pause to extend") 49 | } 50 | 51 | extendedPause := r.Record.Entries()[pauseEntryI].Duration().Plus(increment) 52 | pauseLineIndex := r.lastLinePointer - countLines(r.Record.Entries()[pauseEntryI:]) 53 | durationPattern := regexp.MustCompile(`(-\w+)`) 54 | value := durationPattern.FindString(r.lines[pauseLineIndex].Text) 55 | if extendedPause.InMinutes() != 0 { 56 | r.lines[pauseLineIndex].Text = strings.Replace(r.lines[pauseLineIndex].Text, value, extendedPause.ToString(), 1) 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /klog/app/cli/track.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/app" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | "github.com/jotaen/klog/klog/parser/reconciling" 8 | ) 9 | 10 | type Track struct { 11 | Entry klog.EntrySummary `arg:"" required:"" placeholder:"ENTRY" help:"The new entry to add."` 12 | util.AtDateArgs 13 | util.NoStyleArgs 14 | util.WarnArgs 15 | util.OutputFileArgs 16 | } 17 | 18 | func (opt *Track) Help() string { 19 | return ` 20 | The given text is appended to the record as new entry (taken over as is, i.e. including the entry summary). Example invocations: 21 | 22 | klog track '1h' file.klg 23 | klog track '15:00 - 16:00 Went out running' file.klg 24 | klog track '6h30m #work' file.klg 25 | 26 | It uses the record at today’s date for the new entry, or creates a new record if there no record at today’s date. 27 | You can otherwise specify a date with '--date'. 28 | 29 | Remember to use 'quotes' if the entry consists of multiple words, to avoid the text being split or otherwise pre-processed by your shell. 30 | There is still one quirk: if you want to track a negative duration, you have to escape the leading minus with a backslash, e.g. '\-45m lunch break', to prevent it from being mistakenly interpreted as a flag. 31 | ` 32 | } 33 | 34 | func (opt *Track) Run(ctx app.Context) app.Error { 35 | opt.NoStyleArgs.Apply(&ctx) 36 | now := ctx.Now() 37 | date := opt.AtDate(now) 38 | additionalData := reconciling.AdditionalData{} 39 | ctx.Config().DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) { 40 | additionalData.ShouldTotal = s 41 | }) 42 | return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs}, 43 | []reconciling.Creator{ 44 | reconciling.NewReconcilerAtRecord(date), 45 | reconciling.NewReconcilerForNewRecord(date, opt.DateFormat(ctx.Config()), additionalData), 46 | }, 47 | 48 | func(reconciler *reconciling.Reconciler) error { 49 | return reconciler.AppendEntry(opt.Entry) 50 | }, 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /klog/parser/txt/block_test.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func TestGroupEmptyInput(t *testing.T) { 10 | for _, ls := range []string{ 11 | ``, 12 | "\n \n\t\t\n ", 13 | } { 14 | block, _ := ParseBlock(ls, 0) 15 | assert.Nil(t, block) 16 | } 17 | } 18 | 19 | func TestParseBlock(t *testing.T) { 20 | for _, x := range []struct { 21 | text string 22 | expect []string // expected significant line contents 23 | expectHead int 24 | expectTail int 25 | }{ 26 | // Single line 27 | {"a", []string{"a"}, 0, 0}, 28 | {"\nfoo", []string{"foo"}, 1, 0}, 29 | {"\n12345\n", []string{"12345"}, 1, 0}, 30 | {" \ntest ", []string{"test "}, 1, 0}, 31 | {" \na\ta\n", []string{"a\ta"}, 1, 0}, 32 | {"\t\na1\n\t \t ", []string{"a1"}, 1, 1}, 33 | {"\n\na1\n\n", []string{"a1"}, 2, 1}, 34 | {"喜左衛門", []string{"喜左衛門"}, 0, 0}, 35 | {"喜左衛門\n", []string{"喜左衛門"}, 0, 0}, 36 | {"\n😀·½\n ", []string{"😀·½"}, 1, 1}, 37 | 38 | // Multiple lines 39 | {"a1\na2", []string{"a1", "a2"}, 0, 0}, 40 | {"\nasdf\nasdf", []string{"asdf", "asdf"}, 1, 0}, 41 | {"\nHey 🥰!\n«How is it?»\n", []string{"Hey 🥰!", "«How is it?»"}, 1, 0}, 42 | {"\n \t\nA\nB", []string{"A", "B"}, 2, 0}, 43 | {"\n \t\na b c \n a b c\n \t \n", []string{"a b c ", " a b c"}, 2, 1}, 44 | {"\n \t\n _ \n - \n\n", []string{" _ ", " - "}, 2, 1}, 45 | {" \t \t\nAS:FLKJH\n!(@* #&\n\t", []string{"AS:FLKJH", "!(@* #&"}, 1, 1}, 46 | {" \n\t\n1—2\n·½⅓•ÄflÑ\n\n\n ", []string{"1—2", "·½⅓•ÄflÑ"}, 2, 3}, 47 | } { 48 | b, _ := ParseBlock(x.text, 0) 49 | sgLines, head, tail := b.SignificantLines() 50 | 51 | require.NotNil(t, b) 52 | require.Len(t, b.Lines(), len(x.expect)+x.expectHead+x.expectTail) 53 | require.Len(t, sgLines, len(x.expect)) 54 | for i, l := range x.expect { 55 | assert.Equal(t, l, sgLines[i].Text) 56 | } 57 | assert.Equal(t, x.expectHead, head) 58 | assert.Equal(t, x.expectTail, tail) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /klog/parser/txt/indentation_test.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func TestCreateIndentatorFromLine(t *testing.T) { 10 | for _, indentator := range []*Indentator{ 11 | NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")), 12 | NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")), 13 | NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")), 14 | NewIndentator([]string{"\t"}, NewLineFromString("\tHello")), 15 | } { 16 | require.NotNil(t, indentator) 17 | } 18 | } 19 | 20 | func TestCreatesNoIndentatorIfLineIsNotIndentatedAccordingly(t *testing.T) { 21 | for _, indentator := range []*Indentator{ 22 | NewIndentator([]string{" ", " "}, NewLineFromString("Hello")), 23 | NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")), 24 | NewIndentator([]string{"\t"}, NewLineFromString(" Hello")), 25 | } { 26 | require.Nil(t, indentator) 27 | } 28 | } 29 | 30 | func TestCreatesIndentedParseable(t *testing.T) { 31 | indentator := Indentator{"\t"} 32 | 33 | p1 := indentator.NewIndentedParseable(NewLineFromString("Hello"), 0) 34 | require.NotNil(t, p1) 35 | assert.Equal(t, p1.PointerPosition, 0) 36 | assert.Equal(t, []rune{'H', 'e', 'l', 'l', 'o'}, p1.Chars) 37 | 38 | p2 := indentator.NewIndentedParseable(NewLineFromString("\tHello"), 1) 39 | require.NotNil(t, p2) 40 | assert.Equal(t, 1, p2.PointerPosition) 41 | 42 | p3 := indentator.NewIndentedParseable(NewLineFromString("\t\tHello"), 1) 43 | require.NotNil(t, p3) 44 | assert.Equal(t, 1, p3.PointerPosition) 45 | } 46 | 47 | func TestCreatesNoParseableForIndentationMismatch(t *testing.T) { 48 | indentator := Indentator{"\t"} 49 | for _, p := range []*Parseable{ 50 | indentator.NewIndentedParseable(NewLineFromString("Hello"), 1), 51 | indentator.NewIndentedParseable(NewLineFromString("\tHello"), 2), 52 | indentator.NewIndentedParseable(NewLineFromString("\t\tHello"), 5), 53 | } { 54 | require.Nil(t, p) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /klog/service/period/week.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type Week struct { 12 | date klog.Date 13 | } 14 | 15 | type WeekHash Hash 16 | 17 | var weekPattern = regexp.MustCompile(`^\d{4}-W\d{1,2}$`) 18 | 19 | func NewWeekFromDate(d klog.Date) Week { 20 | return Week{d} 21 | } 22 | 23 | func NewWeekFromString(yyyyWww string) (Week, error) { 24 | if !weekPattern.MatchString(yyyyWww) { 25 | return Week{}, errors.New("INVALID_WEEK_PERIOD") 26 | } 27 | parts := strings.Split(yyyyWww, "-") 28 | year, _ := strconv.Atoi(parts[0]) 29 | week, _ := strconv.Atoi(strings.TrimPrefix(parts[1], "W")) 30 | if week < 1 { 31 | return Week{}, errors.New("INVALID_WEEK_PERIOD") 32 | } 33 | reference, err := func() (klog.Date, error) { 34 | ref, yErr := klog.NewDate(year, 7, 1) 35 | if yErr != nil { 36 | return nil, errors.New("INVALID_WEEK_PERIOD") 37 | } 38 | for ref.Weekday() != 1 { 39 | ref = ref.PlusDays(-1) 40 | } 41 | _, w := ref.WeekNumber() 42 | ref = ref.PlusDays((week - w) * 7) 43 | return ref, nil 44 | }() 45 | if err != nil { 46 | return Week{}, err 47 | } 48 | if _, refWeekNr := reference.WeekNumber(); refWeekNr != week { 49 | // Prevent implicit roll over. 50 | return Week{}, errors.New("INVALID_WEEK_PERIOD") 51 | } 52 | return Week{reference}, nil 53 | } 54 | 55 | func (w Week) Period() Period { 56 | since := w.date 57 | until := w.date 58 | for { 59 | if since.Weekday() == 1 { 60 | break 61 | } 62 | since = since.PlusDays(-1) 63 | } 64 | for { 65 | if until.Weekday() == 7 { 66 | break 67 | } 68 | until = until.PlusDays(1) 69 | } 70 | return NewPeriod(since, until) 71 | } 72 | 73 | func (w Week) Previous() Week { 74 | return NewWeekFromDate(w.date.PlusDays(-7)) 75 | } 76 | 77 | func (w Week) Hash() WeekHash { 78 | hash := newBitMask() 79 | year, week := w.date.WeekNumber() 80 | hash.populate(uint32(week), 53) 81 | hash.populate(uint32(year), 10000) 82 | return WeekHash(hash.Value()) 83 | } 84 | -------------------------------------------------------------------------------- /klog/service/period/period.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "math" 7 | ) 8 | 9 | // Period is an inclusive date range. 10 | type Period interface { 11 | Since() klog.Date 12 | Until() klog.Date 13 | } 14 | 15 | type periodData struct { 16 | since klog.Date 17 | until klog.Date 18 | } 19 | 20 | func NewPeriod(since klog.Date, until klog.Date) Period { 21 | return &periodData{since, until} 22 | } 23 | 24 | func NewPeriodFromPatternString(pattern string) (Period, error) { 25 | type PeriodCreator interface{ Period() Period } 26 | for _, create := range []func(string) (PeriodCreator, error){ 27 | func(s string) (PeriodCreator, error) { return NewYearFromString(s) }, 28 | func(s string) (PeriodCreator, error) { return NewMonthFromString(s) }, 29 | func(s string) (PeriodCreator, error) { return NewQuarterFromString(s) }, 30 | func(s string) (PeriodCreator, error) { return NewWeekFromString(s) }, 31 | } { 32 | p, err := create(pattern) 33 | if err == nil { 34 | return p.Period(), nil 35 | } 36 | } 37 | return nil, errors.New("INVALID_PERIOD_PATTERN") 38 | } 39 | 40 | func (p *periodData) Since() klog.Date { 41 | return p.since 42 | } 43 | 44 | func (p *periodData) Until() klog.Date { 45 | return p.until 46 | } 47 | 48 | // Hash is a super type for date-related hashes. Such a hash is 49 | // the same when two dates fall into the same bucket, e.g. the same 50 | // year and week for WeekHash or the same year, month and day for DayHash. 51 | // The underlying int type doesn’t have any meaning. 52 | type Hash uint32 53 | 54 | type bitMask struct { 55 | value uint32 56 | bitsConsumed uint 57 | } 58 | 59 | func newBitMask() bitMask { 60 | return bitMask{0, 0} 61 | } 62 | 63 | func (b *bitMask) Value() Hash { 64 | return Hash(b.value) 65 | } 66 | 67 | func (b *bitMask) populate(value uint32, maxValue uint32) { 68 | b.value = b.value | value< 32 { 72 | panic("Overflow") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /klog/app/cli/stop.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/app" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | "github.com/jotaen/klog/klog/parser/reconciling" 8 | ) 9 | 10 | type Stop struct { 11 | Summary klog.EntrySummary `name:"summary" short:"s" placeholder:"TEXT" help:"Text to append to the entry summary."` 12 | util.AtDateAndTimeArgs 13 | util.NoStyleArgs 14 | util.WarnArgs 15 | util.OutputFileArgs 16 | } 17 | 18 | func (opt *Stop) Help() string { 19 | return ` 20 | If the record contains an open-ended time range (e.g. '18:00 - ?') then this command will replace the end placeholder with the current time. 21 | By default, it targets the record at today’s date. 22 | You can otherwise specify a date with '--date'. 23 | 24 | Unless the '--time' flag is specified, it defaults to the current time as end time. If you prefer your time to be rounded, you can use the '--round' flag. 25 | You may also specify a summary via '--summary', which will be appended to the existing summary of the entry. 26 | ` 27 | } 28 | 29 | func (opt *Stop) Run(ctx app.Context) app.Error { 30 | opt.NoStyleArgs.Apply(&ctx) 31 | now := ctx.Now() 32 | date := opt.AtDate(now) 33 | time, err := opt.AtTime(now, ctx.Config()) 34 | if err != nil { 35 | return err 36 | } 37 | // Only fall back to yesterday if no explicit date has been given. 38 | // Otherwise, it wouldn’t make sense to decrement the day. 39 | shouldTryYesterday := opt.WasAutomatic() 40 | yesterday := date.PlusDays(-1) 41 | return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs}, 42 | []reconciling.Creator{ 43 | reconciling.NewReconcilerAtRecord(date), 44 | func() reconciling.Creator { 45 | if shouldTryYesterday { 46 | return reconciling.NewReconcilerAtRecord(yesterday) 47 | } 48 | return nil 49 | }(), 50 | }, 51 | 52 | func(reconciler *reconciling.Reconciler) error { 53 | if shouldTryYesterday && reconciler.Record.Date().IsEqualTo(yesterday) { 54 | time, _ = time.Plus(klog.NewDuration(24, 0)) 55 | } 56 | return reconciler.CloseOpenRange(time, opt.TimeFormat(ctx.Config()), opt.Summary) 57 | }, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /klog/app/cli/total_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app/cli/util" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestTotalOfEmptyInput(t *testing.T) { 11 | state, err := NewTestingContext()._SetRecords(``)._Run((&Total{}).Run) 12 | require.Nil(t, err) 13 | assert.Equal(t, "\nTotal: 0m\n(In 0 records)\n", state.printBuffer) 14 | } 15 | 16 | func TestTotalOfInput(t *testing.T) { 17 | state, err := NewTestingContext()._SetRecords(` 18 | 2018-11-08 19 | 1h 20 | 21 | 2018-11-09 22 | 16:00-17:00 23 | 24 | 2150-11-10 25 | Open ranges are not considered 26 | 16:00 - ? 27 | `)._Run((&Total{WarnArgs: util.WarnArgs{NoWarn: true}}).Run) 28 | require.Nil(t, err) 29 | assert.Equal(t, "\nTotal: 2h\n(In 3 records)\n", state.printBuffer) 30 | } 31 | 32 | func TestTotalWithDiffing(t *testing.T) { 33 | state, err := NewTestingContext()._SetRecords(` 34 | 2018-11-08 (8h!) 35 | 8h30m 36 | 37 | 2018-11-09 (7h45m!) 38 | 8:00 - 16:00 39 | `)._Run((&Total{DiffArgs: util.DiffArgs{Diff: true}}).Run) 40 | require.Nil(t, err) 41 | assert.Equal(t, "\nTotal: 16h30m\nShould: 15h45m!\nDiff: +45m\n(In 2 records)\n", state.printBuffer) 42 | } 43 | 44 | func TestTotalWithNow(t *testing.T) { 45 | state, err := NewTestingContext()._SetRecords(` 46 | 2018-11-08 (8h!) 47 | 8h30m 48 | 49 | 2018-11-09 (7h45m!) 50 | 8:00 - ? 51 | `)._SetNow(2018, 11, 9, 8, 30)._Run((&Total{NowArgs: util.NowArgs{Now: true}}).Run) 52 | require.Nil(t, err) 53 | assert.Equal(t, "\nTotal: 9h\n(In 2 records)\n", state.printBuffer) 54 | } 55 | 56 | func TestTotalWithNowUncloseable(t *testing.T) { 57 | _, err := NewTestingContext()._SetRecords(` 58 | 2018-11-08 (8h!) 59 | 8h30m 60 | 61 | 2018-11-09 (7h45m!) 62 | 8:00 - ? 63 | `)._SetNow(2018, 13, 9, 8, 30)._Run((&Total{NowArgs: util.NowArgs{Now: true}}).Run) 64 | require.Error(t, err) 65 | } 66 | 67 | func TestTotalAsDecimal(t *testing.T) { 68 | state, err := NewTestingContext()._SetRecords(` 69 | 2018-11-08 (8h!) 70 | 8h30m 71 | `)._SetNow(2018, 11, 9, 8, 30)._Run((&Total{DecimalArgs: util.DecimalArgs{Decimal: true}}).Run) 72 | require.Nil(t, err) 73 | assert.Equal(t, "\nTotal: 510\n(In 1 record)\n", state.printBuffer) 74 | } 75 | -------------------------------------------------------------------------------- /klog/service/period/quarter.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type Quarter struct { 12 | date klog.Date 13 | } 14 | 15 | type QuarterHash Hash 16 | 17 | var quarterPattern = regexp.MustCompile(`^\d{4}-Q\d$`) 18 | 19 | func NewQuarterFromDate(d klog.Date) Quarter { 20 | return Quarter{d} 21 | } 22 | 23 | func NewQuarterFromString(yyyyQq string) (Quarter, error) { 24 | if !quarterPattern.MatchString(yyyyQq) { 25 | return Quarter{}, errors.New("INVALID_QUARTER_PERIOD") 26 | } 27 | parts := strings.Split(yyyyQq, "-") 28 | year, _ := strconv.Atoi(parts[0]) 29 | quarter, _ := strconv.Atoi(strings.TrimPrefix(parts[1], "Q")) 30 | if quarter < 1 || quarter > 4 { 31 | return Quarter{}, errors.New("INVALID_QUARTER_PERIOD") 32 | } 33 | month := quarter * 3 34 | d, err := klog.NewDate(year, month, 1) 35 | if err != nil { 36 | return Quarter{}, errors.New("INVALID_QUARTER_PERIOD") 37 | } 38 | return Quarter{d}, nil 39 | } 40 | 41 | func (q Quarter) Period() Period { 42 | switch q.date.Quarter() { 43 | case 1: 44 | since, _ := klog.NewDate(q.date.Year(), 1, 1) 45 | until, _ := klog.NewDate(q.date.Year(), 3, 31) 46 | return NewPeriod(since, until) 47 | case 2: 48 | since, _ := klog.NewDate(q.date.Year(), 4, 1) 49 | until, _ := klog.NewDate(q.date.Year(), 6, 30) 50 | return NewPeriod(since, until) 51 | case 3: 52 | since, _ := klog.NewDate(q.date.Year(), 7, 1) 53 | until, _ := klog.NewDate(q.date.Year(), 9, 30) 54 | return NewPeriod(since, until) 55 | case 4: 56 | since, _ := klog.NewDate(q.date.Year(), 10, 1) 57 | until, _ := klog.NewDate(q.date.Year(), 12, 31) 58 | return NewPeriod(since, until) 59 | } 60 | // This can/should never happen 61 | panic("Invalid quarter") 62 | } 63 | 64 | func (q Quarter) Previous() Quarter { 65 | result := q.date 66 | for { 67 | result = result.PlusDays(-80) 68 | if result.Quarter() != q.date.Quarter() { 69 | return Quarter{result} 70 | } 71 | } 72 | } 73 | 74 | func (q Quarter) Hash() QuarterHash { 75 | hash := newBitMask() 76 | hash.populate(uint32(q.date.Quarter()), 4) 77 | hash.populate(uint32(q.date.Year()), 10000) 78 | return QuarterHash(hash.Value()) 79 | } 80 | -------------------------------------------------------------------------------- /klog/service/rounding.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // Rounding is an integer divider of 60 that Time values can be rounded to. 11 | type Rounding interface { 12 | ToInt() int 13 | ToString() string 14 | } 15 | 16 | type rounding int 17 | 18 | func (r rounding) ToInt() int { 19 | return int(r) 20 | } 21 | 22 | func (r rounding) ToString() string { 23 | return strconv.Itoa(r.ToInt()) + "m" 24 | } 25 | 26 | // NewRounding creates a Rounding from an integer. For non-allowed 27 | // values, it returns error. 28 | func NewRounding(r int) (Rounding, error) { 29 | for _, validRounding := range []int{5, 10, 12, 15, 20, 30, 60} { 30 | if r == validRounding { 31 | return rounding(r), nil 32 | } 33 | } 34 | return nil, errors.New("INVALID_ROUNDING") 35 | } 36 | 37 | // NewRoundingFromString parses a string containing a rounding value. 38 | // The string might be suffixed with `m`. Additionally, it might be `1h`, 39 | // which is equivalent to `60m`. 40 | func NewRoundingFromString(v string) (Rounding, error) { 41 | r := func() int { 42 | if v == "1h" { 43 | return 60 44 | } 45 | v = strings.TrimSuffix(v, "m") 46 | number, err := strconv.Atoi(v) 47 | if err != nil { 48 | return -1 49 | } 50 | return number 51 | }() 52 | return NewRounding(r) 53 | } 54 | 55 | // RoundToNearest rounds a time (up or down) to the nearest given rounding multiple. 56 | // E.g., for rounding=5m: 8:03 => 8:05, or for rounding=30m: 15:12 => 15:00 57 | func RoundToNearest(t klog.Time, r Rounding) klog.Time { 58 | midnightOffset := t.MidnightOffset().InMinutes() 59 | v := r.ToInt() 60 | remainder := midnightOffset % v 61 | uprounder := func() int { // Decide whether to round up the value. 62 | if remainder >= (v/2 + v%2) { 63 | return v 64 | } 65 | return 0 66 | }() 67 | roundedMidnightOffset := midnightOffset - remainder + uprounder 68 | 69 | midnight, _ := klog.NewTime(0, 0) 70 | roundedTime, err := midnight.Plus(klog.NewDuration(0, roundedMidnightOffset)) 71 | if err != nil { 72 | // This is the special case where we can’t round up after `23:59>`. 73 | maxTime, _ := klog.NewTimeTomorrow(23, 59) 74 | return maxTime 75 | } 76 | return roundedTime 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | env: 4 | GO_VERSION: '1.25' 5 | STATIC_CHECK_VERSION: '2025.1.1' 6 | COUNT_LOC_DOCKER_IMAGE: 'aldanial/cloc:2.06' 7 | jobs: 8 | statistics: 9 | name: Statistics 10 | runs-on: ubuntu-latest 11 | env: 12 | TARGET: klog/ 13 | TEST_FILE_PATTERN: .*_test\.go 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Prepare tooling 17 | run: docker pull "${COUNT_LOC_DOCKER_IMAGE}" 18 | - name: LOC of source files 19 | run: docker run --rm -v $(pwd):/wdir:ro -w /wdir "${COUNT_LOC_DOCKER_IMAGE}" --not-match-f="${TEST_FILE_PATTERN}" "${TARGET}" 20 | - name: LOC of test files 21 | run: docker run --rm -v $(pwd):/wdir:ro -w /wdir "${COUNT_LOC_DOCKER_IMAGE}" --match-f="${TEST_FILE_PATTERN}" "${TARGET}" 22 | benchmark: 23 | name: Benchmark 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-go@v2 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | - name: Build 31 | run: | 32 | source ./run.sh && run::build 33 | mv out/klog /usr/local/bin/klog 34 | - name: Run benchmark 35 | run: cd .github/ && ./benchmark.sh 36 | format: 37 | name: Static analysis 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v2 41 | - uses: actions/setup-go@v2 42 | with: 43 | go-version: ${{ env.GO_VERSION }} 44 | - name: Check format 45 | run: | 46 | source ./run.sh 47 | dirty_files="$(run::format)" 48 | if [[ "${dirty_files}" != "" ]]; then $(exit 1); fi 49 | - name: Run linters 50 | run: | 51 | go install "honnef.co/go/tools/cmd/staticcheck@${STATIC_CHECK_VERSION}" 52 | source ./run.sh 53 | run::lint 54 | test: 55 | name: Unit Tests 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v2 59 | - uses: actions/setup-go@v2 60 | with: 61 | go-version: ${{ env.GO_VERSION }} 62 | - name: Print info about environment 63 | run: go version 64 | - name: Install dependencies 65 | run: source ./run.sh && run::install 66 | - name: Run unit tests 67 | run: source ./run.sh && run::test 68 | -------------------------------------------------------------------------------- /klog/service/tags.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "sort" 6 | ) 7 | 8 | type TagStats struct { 9 | Tag klog.Tag 10 | 11 | // Total is the total duration allotted to the tag. 12 | Total klog.Duration 13 | 14 | // Count is the total number of matching entries for that tag. 15 | // I.e., this is *not* how often a tag appears in the record text. 16 | Count int 17 | 18 | keyForSort string 19 | } 20 | 21 | // AggregateTotalsByTags returns a list of tags (sorted by tag, alphanumerically) 22 | // that contains statistics about the tags appearing in the data. 23 | func AggregateTotalsByTags(rs ...klog.Record) ([]TagStats, TagStats) { 24 | tagStats := make(totalByTag) 25 | untagged := TagStats{ 26 | Tag: klog.NewTagOrPanic("_", ""), 27 | Total: klog.NewDuration(0, 0), 28 | Count: 0, 29 | keyForSort: "", 30 | } 31 | for _, r := range rs { 32 | for _, e := range r.Entries() { 33 | allTags := klog.Merge(r.Summary().Tags(), e.Summary().Tags()) 34 | if allTags.IsEmpty() { 35 | untagged.Count += 1 36 | untagged.Total = untagged.Total.Plus(e.Duration()) 37 | continue 38 | } 39 | alreadyCounted := make(map[klog.Tag]bool) 40 | for tag := range allTags.ForLookup() { 41 | if alreadyCounted[tag] { 42 | continue 43 | } 44 | tagStats.put(tag, e.Duration()) 45 | } 46 | } 47 | } 48 | return tagStats.toSortedList(), untagged 49 | } 50 | 51 | // Structure: "tagName":"tagValue":TagStats 52 | type totalByTag map[string]map[string]*TagStats 53 | 54 | func (tbt totalByTag) put(t klog.Tag, d klog.Duration) { 55 | if tbt[t.Name()] == nil { 56 | tbt[t.Name()] = make(map[string]*TagStats) 57 | } 58 | 59 | if tbt[t.Name()][t.Value()] == nil { 60 | tbt[t.Name()][t.Value()] = &TagStats{ 61 | Tag: t, 62 | Total: klog.NewDuration(0, 0), 63 | Count: 0, 64 | keyForSort: t.Name() + "=" + t.Value(), 65 | } 66 | } 67 | 68 | stats := tbt[t.Name()][t.Value()] 69 | stats.Total = stats.Total.Plus(d) 70 | stats.Count++ 71 | } 72 | 73 | func (tbt totalByTag) toSortedList() []TagStats { 74 | var result []TagStats 75 | for _, ts := range tbt { 76 | for _, t := range ts { 77 | result = append(result, *t) 78 | } 79 | } 80 | sort.Slice(result, func(i int, j int) bool { 81 | return result[i].keyForSort < result[j].keyForSort 82 | }) 83 | return result 84 | } 85 | -------------------------------------------------------------------------------- /klog/app/cli/goto_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app" 5 | "github.com/jotaen/klog/klog/app/cli/command" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "testing" 10 | ) 11 | 12 | func TestGotoLocation(t *testing.T) { 13 | spy := newCommandSpy(nil) 14 | _, err := NewTestingContext()._SetFileExplorers([]command.Command{ 15 | command.New("goto", []string{"--file"}), 16 | })._SetExecute(spy.Execute)._Run((&Goto{ 17 | OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"}, 18 | }).Run) 19 | require.Nil(t, err) 20 | assert.Equal(t, 1, spy.Count) 21 | assert.Equal(t, "goto", spy.LastCmd.Bin) 22 | assert.Equal(t, []string{"--file", "/tmp"}, spy.LastCmd.Args) 23 | } 24 | 25 | func TestGotoLocationFirstSucceeds(t *testing.T) { 26 | spy := newCommandSpy(func(c command.Command) app.Error { 27 | if c.Bin == "goto2" { 28 | return nil 29 | } 30 | return app.NewError("Error", "Error", nil) 31 | }) 32 | _, err := NewTestingContext()._SetFileExplorers([]command.Command{ 33 | command.New("goto1", []string{"--file"}), 34 | command.New("goto2", nil), 35 | command.New("goto3", []string{"--file"}), 36 | })._SetExecute(spy.Execute)._Run((&Goto{ 37 | OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"}, 38 | }).Run) 39 | require.Nil(t, err) 40 | assert.Equal(t, 2, spy.Count) 41 | assert.Equal(t, "goto2", spy.LastCmd.Bin) 42 | assert.Equal(t, []string{"/tmp"}, spy.LastCmd.Args) 43 | } 44 | 45 | func TestGotoFails(t *testing.T) { 46 | spy := newCommandSpy(func(_ command.Command) app.Error { 47 | return app.NewError("Error", "Error", nil) 48 | }) 49 | _, err := NewTestingContext()._SetFileExplorers([]command.Command{ 50 | command.New("goto", []string{"--file"}), 51 | })._SetExecute(spy.Execute)._Run((&Goto{ 52 | OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"}, 53 | }).Run) 54 | require.Error(t, err) 55 | assert.Equal(t, 1, spy.Count) 56 | assert.Equal(t, "Failed to open file browser", err.Error()) 57 | } 58 | 59 | func TestGotoFailsWithoutFile(t *testing.T) { 60 | spy := newCommandSpy(nil) 61 | _, err := NewTestingContext()._SetEditors([]command.Command{ 62 | command.New("goto", []string{"--file"}), 63 | }, "")._SetExecute(spy.Execute)._Run((&Goto{}).Run) 64 | require.Error(t, err) 65 | assert.Equal(t, 0, spy.Count) 66 | } 67 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/colour_theme.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | type ColourTheme string 4 | type colourCodes map[Colour]string 5 | 6 | const ( 7 | COLOUR_THEME_NO_COLOUR = ColourTheme("no_colour") 8 | COLOUR_THEME_DARK = ColourTheme("dark") 9 | COLOUR_THEME_LIGHT = ColourTheme("light") 10 | COLOUR_THEME_BASIC = ColourTheme("basic") 11 | ) 12 | 13 | func NewStyler(c ColourTheme) Styler { 14 | switch c { 15 | case COLOUR_THEME_NO_COLOUR: 16 | return Styler{ 17 | props: StyleProps{}, 18 | colourCodes: make(colourCodes), 19 | reset: "", 20 | foregroundPrefix: "", 21 | backgroundPrefix: "", 22 | colourSuffix: "", 23 | underlined: "", 24 | bold: "", 25 | } 26 | case COLOUR_THEME_DARK: 27 | return newStyler256bit(colourCodes{ 28 | TEXT: "015", 29 | TEXT_SUBDUED: "249", 30 | TEXT_INVERSE: "000", 31 | GREEN: "120", 32 | RED: "167", 33 | BLUE_DARK: "117", 34 | BLUE_LIGHT: "027", 35 | PURPLE: "213", 36 | YELLOW: "221", 37 | }) 38 | case COLOUR_THEME_LIGHT: 39 | return newStyler256bit(colourCodes{ 40 | TEXT: "000", 41 | TEXT_SUBDUED: "237", 42 | TEXT_INVERSE: "015", 43 | GREEN: "028", 44 | RED: "124", 45 | BLUE_DARK: "025", 46 | BLUE_LIGHT: "033", 47 | PURPLE: "055", 48 | YELLOW: "208", 49 | }) 50 | case COLOUR_THEME_BASIC: 51 | return newStyler8bit(colourCodes{ 52 | TEXT: "", // Disabled 53 | TEXT_SUBDUED: "", // Disabled 54 | TEXT_INVERSE: "0", 55 | GREEN: "2", 56 | RED: "1", 57 | BLUE_DARK: "4", 58 | BLUE_LIGHT: "6", 59 | PURPLE: "5", 60 | YELLOW: "3", 61 | }) 62 | } 63 | panic("Unknown colour theme") 64 | } 65 | 66 | func newStyler256bit(cc colourCodes) Styler { 67 | return Styler{ 68 | props: StyleProps{}, 69 | colourCodes: cc, 70 | reset: "\033[0m", 71 | foregroundPrefix: "\033[38;5;", 72 | backgroundPrefix: "\033[48;5;", 73 | colourSuffix: "m", 74 | underlined: "\033[4m", 75 | bold: "\033[1m", 76 | } 77 | } 78 | 79 | func newStyler8bit(cc colourCodes) Styler { 80 | return Styler{ 81 | props: StyleProps{}, 82 | colourCodes: cc, 83 | reset: "\033[0m", 84 | foregroundPrefix: "\033[3", 85 | backgroundPrefix: "\033[4", 86 | colourSuffix: "m", 87 | underlined: "\033[4m", 88 | bold: "\033[1m", 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /klog/parser/engine/parallel_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/parser/txt" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | var identityParser = ParallelBatchParser[string]{ 10 | SerialParser: SerialParser[string]{ 11 | ParseOne: func(b txt.Block) (string, []txt.Error) { 12 | original := "" 13 | for _, l := range b.Lines() { 14 | original += l.Original() 15 | } 16 | return original, nil 17 | }, 18 | }, 19 | NumberOfWorkers: 100, 20 | } 21 | 22 | func TestParallelParserDoesNotMessUpBatchOrder(t *testing.T) { 23 | // The mock parser has 100 workers, so the batch size will be 1 char per worker. 24 | // The serial parser is basically an identity function, so it returns the input 25 | // text of the block, i.e. that one char per worker. The parallel parser is now 26 | // expected to re-construct the original order of the input after batching. 27 | // If it wouldn’t do that, the return text would be messed up, e.g. `7369285014` 28 | // instead of `1234567890`. 29 | val, _, _ := identityParser.Parse("1234567890") 30 | assert.Equal(t, []string{"1234567890"}, val) 31 | } 32 | 33 | func TestParallelParser(t *testing.T) { 34 | for _, x := range []struct { 35 | txt string 36 | chunks int 37 | exp []string 38 | }{ 39 | // Small ASCII strings: 40 | {"Hello", 1, []string{"Hello"}}, 41 | {"Hello", 2, []string{"Hel", "lo"}}, 42 | {"Hello", 3, []string{"He", "ll", "o"}}, 43 | {"Hello", 4, []string{"He", "ll", "o", ""}}, 44 | {"Hello", 5, []string{"H", "e", "l", "l", "o"}}, 45 | {"Hello", 6, []string{"H", "e", "l", "l", "o", ""}}, 46 | {"Hello", 8, []string{"H", "e", "l", "l", "o", "", "", ""}}, 47 | 48 | // Larger ASCII strings: 49 | {"abcdefghijklmnopqrstuvwxyz", 3, []string{"abcdefghi", "jklmnopqr", "stuvwxyz"}}, 50 | {"abcdefghijklmnopqrstuvwxyz", 13, []string{"ab", "cd", "ef", "gh", "ij", "kl", "mn", "op", "qr", "st", "uv", "wx", "yz"}}, 51 | 52 | // UTF-8 strings: (reminder: the chunks are supposed to have similar byte-size, not character-count!) 53 | {"藤本太郎喜左衛門将時能", 4, []string{"藤本太", "郎喜左", "衛門将", "時能"}}, 54 | {"藤本太郎喜左衛門将時能", 11, []string{"藤", "本", "太", "郎", "喜", "左", "衛", "門", "将", "時", "能"}}, 55 | {"藤😀abcdef©½, ★Test🤡äß©•¥üöπგამარჯობა", 3, []string{"藤😀abcdef©½, ★Tes", "t🤡äß©•¥üöπგ", "ამარჯობა"}}, 56 | } { 57 | chunks := splitIntoChunks(x.txt, x.chunks) 58 | assert.Equal(t, x.exp, chunks) 59 | 60 | val, _, errs := identityParser.Parse(x.txt) 61 | assert.Nil(t, errs) 62 | assert.Equal(t, []string{x.txt}, val) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /klog/parser/txt/error.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | // Error contains infos about a parsing error in a Line. 4 | type Error interface { 5 | // Error is an alias for Message. 6 | Error() string 7 | 8 | // LineNumber returns the logical line number, as shown in an editor. 9 | LineNumber() int 10 | 11 | // LineText is the original text of the line. 12 | LineText() string 13 | 14 | // Position is the cursor position in the line, excluding the indentation. 15 | Position() int 16 | 17 | // Column is the cursor position in the line, including the indentation. 18 | Column() int 19 | 20 | // Length returns the number of erroneous characters. 21 | Length() int 22 | 23 | // Code returns a unique identifier of the error kind. 24 | Code() string 25 | 26 | // Title returns a short error description. 27 | Title() string 28 | 29 | // Details returns additional information, such as hints or further explanations. 30 | Details() string 31 | 32 | // Message is a combination of Title and Details. 33 | Message() string 34 | 35 | // Origin returns the origin of the error, such as the file name. 36 | Origin() string 37 | SetOrigin(string) Error 38 | } 39 | 40 | type err struct { 41 | context Block 42 | origin string 43 | line int 44 | position int 45 | length int 46 | code string 47 | title string 48 | details string 49 | } 50 | 51 | func (e *err) Error() string { return e.Message() } 52 | func (e *err) LineNumber() int { return e.context.OverallLineIndex(e.line) + 1 } 53 | func (e *err) LineText() string { return e.context.Lines()[e.line].Text } 54 | func (e *err) Position() int { return e.position } 55 | func (e *err) Column() int { return e.position + 1 } 56 | func (e *err) Length() int { return e.length } 57 | func (e *err) Code() string { return e.code } 58 | func (e *err) Title() string { return e.title } 59 | func (e *err) Details() string { return e.details } 60 | func (e *err) Message() string { return e.title + ": " + e.details } 61 | func (e *err) Origin() string { return e.origin } 62 | func (e *err) SetOrigin(origin string) Error { e.origin = origin; return e } 63 | 64 | func NewError(b Block, line int, start int, length int, code string, title string, details string) Error { 65 | return &err{ 66 | context: b, 67 | line: line, 68 | position: start, 69 | length: length, 70 | code: code, 71 | title: title, 72 | details: details, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /klog/service/period/year_test.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestParseValidYear(t *testing.T) { 11 | for _, x := range []struct { 12 | text string 13 | expect Period 14 | }{ 15 | {"0000", NewPeriod(klog.Ɀ_Date_(0, 1, 1), klog.Ɀ_Date_(0, 12, 31))}, 16 | {"0475", NewPeriod(klog.Ɀ_Date_(475, 1, 1), klog.Ɀ_Date_(475, 12, 31))}, 17 | {"2008", NewPeriod(klog.Ɀ_Date_(2008, 1, 1), klog.Ɀ_Date_(2008, 12, 31))}, 18 | {"8641", NewPeriod(klog.Ɀ_Date_(8641, 1, 1), klog.Ɀ_Date_(8641, 12, 31))}, 19 | {"9999", NewPeriod(klog.Ɀ_Date_(9999, 1, 1), klog.Ɀ_Date_(9999, 12, 31))}, 20 | } { 21 | year, err := NewYearFromString(x.text) 22 | require.Nil(t, err) 23 | assert.True(t, x.expect.Since().IsEqualTo(year.Period().Since())) 24 | assert.True(t, x.expect.Until().IsEqualTo(year.Period().Until())) 25 | } 26 | } 27 | 28 | func TestRejectsInvalidYear(t *testing.T) { 29 | for _, x := range []string{ 30 | "-5", 31 | "10000", 32 | "9823746", 33 | } { 34 | _, err := NewYearFromString(x) 35 | require.Error(t, err) 36 | } 37 | } 38 | 39 | func TestRejectsMalformedYear(t *testing.T) { 40 | for _, x := range []string{ 41 | "", 42 | "asdf", 43 | "2oo1", 44 | } { 45 | _, err := NewYearFromString(x) 46 | require.Error(t, err) 47 | } 48 | } 49 | 50 | func TestYearPeriod(t *testing.T) { 51 | for _, x := range []struct { 52 | initial Year 53 | expected Period 54 | }{ 55 | {NewYearFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 1, 1), klog.Ɀ_Date_(1987, 12, 31))}, 56 | {NewYearFromDate(klog.Ɀ_Date_(2000, 3, 31)), NewPeriod(klog.Ɀ_Date_(2000, 1, 1), klog.Ɀ_Date_(2000, 12, 31))}, 57 | {NewYearFromDate(klog.Ɀ_Date_(2555, 12, 31)), NewPeriod(klog.Ɀ_Date_(2555, 1, 1), klog.Ɀ_Date_(2555, 12, 31))}, 58 | } { 59 | p := x.initial.Period() 60 | assert.Equal(t, x.expected, p) 61 | } 62 | } 63 | 64 | func TestYearPreviousYear(t *testing.T) { 65 | for _, x := range []struct { 66 | initial Year 67 | expected Period 68 | }{ 69 | {NewYearFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1986, 1, 1), klog.Ɀ_Date_(1986, 12, 31))}, 70 | {NewYearFromDate(klog.Ɀ_Date_(2000, 3, 31)), NewPeriod(klog.Ɀ_Date_(1999, 1, 1), klog.Ɀ_Date_(1999, 12, 31))}, 71 | {NewYearFromDate(klog.Ɀ_Date_(2555, 12, 31)), NewPeriod(klog.Ɀ_Date_(2554, 1, 1), klog.Ɀ_Date_(2554, 12, 31))}, 72 | } { 73 | previous := x.initial.Previous().Period() 74 | assert.Equal(t, x.expected, previous) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /klog/parser/serialiser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "strings" 6 | ) 7 | 8 | // Serialiser is used when the output should be modified, e.g. coloured. 9 | type Serialiser interface { 10 | Date(klog.Date) string 11 | ShouldTotal(klog.Duration) string 12 | Summary(SummaryText) string 13 | Range(klog.Range) string 14 | OpenRange(klog.OpenRange) string 15 | Duration(klog.Duration) string 16 | SignedDuration(klog.Duration) string 17 | Time(klog.Time) string 18 | } 19 | 20 | type Line struct { 21 | Text string 22 | Record klog.Record 23 | EntryI int 24 | } 25 | 26 | type Lines []Line 27 | 28 | var canonicalLineEnding = "\n" 29 | var canonicalIndentation = " " 30 | 31 | func (ls Lines) ToString() string { 32 | result := "" 33 | for _, l := range ls { 34 | result += l.Text + canonicalLineEnding 35 | } 36 | return result 37 | } 38 | 39 | // SerialiseRecords serialises records into the canonical string representation. 40 | // (So it doesn’t and cannot restore the original formatting!) 41 | func SerialiseRecords(s Serialiser, rs ...klog.Record) Lines { 42 | var lines []Line 43 | for i, r := range rs { 44 | lines = append(lines, serialiseRecord(s, r)...) 45 | if i < len(rs)-1 { 46 | lines = append(lines, Line{"", nil, -1}) 47 | } 48 | } 49 | return lines 50 | } 51 | 52 | func serialiseRecord(s Serialiser, r klog.Record) []Line { 53 | var lines []Line 54 | headline := s.Date(r.Date()) 55 | if r.ShouldTotal().InMinutes() != 0 { 56 | headline += " (" + s.ShouldTotal(r.ShouldTotal()) + ")" 57 | } 58 | lines = append(lines, Line{headline, r, -1}) 59 | for _, l := range r.Summary().Lines() { 60 | lines = append(lines, Line{s.Summary([]string{l}), r, -1}) 61 | } 62 | for entryI, e := range r.Entries() { 63 | entryValue := klog.Unbox[string](&e, 64 | func(r klog.Range) string { return s.Range(r) }, 65 | func(d klog.Duration) string { return s.Duration(d) }, 66 | func(o klog.OpenRange) string { return s.OpenRange(o) }, 67 | ) 68 | lines = append(lines, Line{canonicalIndentation + entryValue, r, entryI}) 69 | for i, l := range e.Summary().Lines() { 70 | summaryText := s.Summary([]string{l}) 71 | if i == 0 && l != "" { 72 | lines[len(lines)-1].Text += " " + summaryText 73 | } else if i >= 1 { 74 | lines = append(lines, Line{canonicalIndentation + canonicalIndentation + summaryText, r, entryI}) 75 | } 76 | } 77 | } 78 | return lines 79 | } 80 | 81 | type SummaryText []string 82 | 83 | func (s SummaryText) ToString() string { 84 | return strings.Join(s, canonicalLineEnding) 85 | } 86 | -------------------------------------------------------------------------------- /klog/app/cli/terminalformat/table.go: -------------------------------------------------------------------------------- 1 | package terminalformat 2 | 3 | import ( 4 | "strings" 5 | "unicode/utf8" 6 | ) 7 | 8 | type Options struct { 9 | fill bool 10 | align Alignment 11 | } 12 | 13 | type cell struct { 14 | Options 15 | value string 16 | len int 17 | } 18 | 19 | type Table struct { 20 | cells []cell 21 | numberOfColumns int 22 | longestCell []int 23 | currentColumn int 24 | columnSeparator string 25 | } 26 | 27 | type Alignment int 28 | 29 | const ( 30 | ALIGN_LEFT Alignment = iota 31 | ALIGN_RIGHT 32 | ) 33 | 34 | func NewTable(numberOfColumns int, columnSeparator string) *Table { 35 | if numberOfColumns <= 1 { 36 | panic("Column count must be greater than 1") 37 | } 38 | return &Table{ 39 | cells: []cell{}, 40 | numberOfColumns: numberOfColumns, 41 | longestCell: make([]int, numberOfColumns), 42 | currentColumn: 0, 43 | columnSeparator: columnSeparator, 44 | } 45 | } 46 | 47 | func (t *Table) Cell(text string, opts Options) *Table { 48 | c := cell{ 49 | Options: opts, 50 | value: text, 51 | len: utf8.RuneCountInString(StripAllAnsiSequences(text)), 52 | } 53 | t.cells = append(t.cells, c) 54 | if c.len > t.longestCell[t.currentColumn] { 55 | t.longestCell[t.currentColumn] = c.len 56 | } 57 | t.currentColumn++ 58 | if t.currentColumn >= t.numberOfColumns { 59 | t.currentColumn = 0 60 | } 61 | return t 62 | } 63 | 64 | func (t *Table) CellL(text string) *Table { 65 | return t.Cell(text, Options{align: ALIGN_LEFT}) 66 | } 67 | 68 | func (t *Table) CellR(text string) *Table { 69 | return t.Cell(text, Options{align: ALIGN_RIGHT}) 70 | } 71 | 72 | func (t *Table) Skip(numberOfCells int) *Table { 73 | for i := 0; i < numberOfCells; i++ { 74 | t.Cell("", Options{}) 75 | } 76 | return t 77 | } 78 | 79 | func (t *Table) Fill(sequence string) *Table { 80 | t.Cell(sequence, Options{fill: true}) 81 | return t 82 | } 83 | 84 | func (t *Table) Collect(fn func(string)) { 85 | for i, c := range t.cells { 86 | col := i % t.numberOfColumns 87 | if i > 0 && col == 0 { 88 | fn("\n") 89 | } 90 | if col > 0 { 91 | fn(t.columnSeparator) 92 | } 93 | if c.fill { 94 | fn(strings.Repeat(c.value, t.longestCell[col])) 95 | } else { 96 | padding := strings.Repeat(" ", t.longestCell[col]-c.len) 97 | if c.align == ALIGN_RIGHT { 98 | fn(padding) 99 | } 100 | fn(c.value) 101 | if c.align == ALIGN_LEFT { 102 | fn(padding) 103 | } 104 | } 105 | } 106 | if len(t.cells) > 0 { 107 | fn("\n") 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /klog/app/text_serialiser.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 6 | "github.com/jotaen/klog/klog/parser" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | // TextSerialiser is a specialised parser.Serialiser implementation for the terminal. 12 | type TextSerialiser struct { 13 | DecimalDuration bool 14 | Styler tf.Styler 15 | } 16 | 17 | func NewSerialiser(styler tf.Styler, decimal bool) TextSerialiser { 18 | return TextSerialiser{ 19 | DecimalDuration: decimal, 20 | Styler: styler, 21 | } 22 | } 23 | 24 | func (cs TextSerialiser) duration(d klog.Duration, withSign bool) string { 25 | if cs.DecimalDuration { 26 | return strconv.Itoa(d.InMinutes()) 27 | } 28 | if withSign { 29 | return d.ToStringWithSign() 30 | } 31 | return d.ToString() 32 | } 33 | 34 | func (cs TextSerialiser) Date(d klog.Date) string { 35 | return cs.Styler.Props(tf.StyleProps{Color: tf.TEXT, IsUnderlined: true}).Format(d.ToString()) 36 | } 37 | 38 | func (cs TextSerialiser) ShouldTotal(d klog.Duration) string { 39 | return cs.Styler.Props(tf.StyleProps{Color: tf.PURPLE}).Format(cs.duration(d, false)) 40 | } 41 | 42 | func (cs TextSerialiser) Summary(s parser.SummaryText) string { 43 | txt := s.ToString() 44 | summaryStyler := cs.Styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}) 45 | txt = klog.HashTagPattern.ReplaceAllStringFunc(txt, func(h string) string { 46 | return cs.Styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED, IsBold: true}).FormatAndRestore( 47 | h, summaryStyler, 48 | ) 49 | }) 50 | return summaryStyler.Format(txt) 51 | } 52 | 53 | func (cs TextSerialiser) Range(r klog.Range) string { 54 | return cs.Styler.Props(tf.StyleProps{Color: tf.BLUE_DARK}).Format(r.ToString()) 55 | } 56 | 57 | func (cs TextSerialiser) OpenRange(or klog.OpenRange) string { 58 | return cs.Styler.Props(tf.StyleProps{Color: tf.BLUE_LIGHT}).Format(or.ToString()) 59 | } 60 | 61 | func (cs TextSerialiser) Duration(d klog.Duration) string { 62 | var c tf.Colour = tf.GREEN 63 | if strings.HasPrefix(d.ToStringWithSign(), "-") { 64 | c = tf.RED 65 | } 66 | return cs.Styler.Props(tf.StyleProps{Color: c}).Format(cs.duration(d, false)) 67 | } 68 | 69 | func (cs TextSerialiser) SignedDuration(d klog.Duration) string { 70 | var c tf.Colour = tf.GREEN 71 | if strings.HasPrefix(d.ToStringWithSign(), "-") { 72 | c = tf.RED 73 | } 74 | return cs.Styler.Props(tf.StyleProps{Color: c}).Format(cs.duration(d, true)) 75 | } 76 | 77 | func (cs TextSerialiser) Time(t klog.Time) string { 78 | return cs.Styler.Props(tf.StyleProps{Color: tf.BLUE_LIGHT}).Format(t.ToString()) 79 | } 80 | -------------------------------------------------------------------------------- /klog/parser/txt/parseable.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package txt is a generic utility for parsing and processing a text 3 | that is structured in individual lines. 4 | */ 5 | package txt 6 | 7 | import "unicode/utf8" 8 | 9 | // Parseable is utility data structure for parsing a piece of text. 10 | type Parseable struct { 11 | Chars []rune 12 | PointerPosition int 13 | } 14 | 15 | // NewParseable creates a parseable from the given line. 16 | func NewParseable(l Line, startPointerPosition int) *Parseable { 17 | return &Parseable{ 18 | PointerPosition: startPointerPosition, 19 | Chars: []rune(l.Text), 20 | } 21 | } 22 | 23 | // Peek returns the next character, or `utf8.RuneError` if there is none anymore. 24 | func (p *Parseable) Peek() rune { 25 | char := SubRune(p.Chars, p.PointerPosition, 1) 26 | if char == nil { 27 | return utf8.RuneError 28 | } 29 | return char[0] 30 | } 31 | 32 | // PeekUntil moves the cursor forward until the condition is satisfied, or until the end 33 | // of the line is reached. It returns a Parseable containing the consumed part of the line, 34 | // as well as a bool to indicate whether the condition was met (`true`) or the end of the 35 | // line was encountered (`false`). 36 | func (p *Parseable) PeekUntil(isMatch func(rune) bool) (Parseable, bool) { 37 | matchLength := 0 38 | hasMatched := false 39 | for i := p.PointerPosition; i < len(p.Chars); i++ { 40 | matchLength++ 41 | if isMatch(SubRune(p.Chars, i, 1)[0]) { 42 | matchLength -= 1 43 | hasMatched = true 44 | break 45 | } 46 | } 47 | return Parseable{ 48 | PointerPosition: p.PointerPosition, 49 | Chars: SubRune(p.Chars, p.PointerPosition, matchLength), 50 | }, hasMatched 51 | } 52 | 53 | // Remainder returns the rest of the text. 54 | func (p *Parseable) Remainder() Parseable { 55 | rest, _ := p.PeekUntil(Is(utf8.RuneError)) 56 | return rest 57 | } 58 | 59 | // Advance moves forward the cursor position. 60 | func (p *Parseable) Advance(increment int) { 61 | p.PointerPosition += increment 62 | } 63 | 64 | // SkipWhile consumes all upcoming characters that match the predicate. 65 | func (p *Parseable) SkipWhile(isMatch func(rune) bool) { 66 | for isMatch(p.Peek()) { 67 | p.Advance(1) 68 | } 69 | } 70 | 71 | // Length returns the total length of the line. 72 | func (p *Parseable) Length() int { 73 | return len(p.Chars) 74 | } 75 | 76 | // RemainingLength returns the number of chars until the end of the line. 77 | func (p *Parseable) RemainingLength() int { 78 | return p.Length() - p.PointerPosition 79 | } 80 | 81 | // ToString returns the line text as string. 82 | func (p *Parseable) ToString() string { 83 | return string(p.Chars) 84 | } 85 | -------------------------------------------------------------------------------- /klog.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/jotaen/klog/klog/app" 10 | "github.com/jotaen/klog/klog/app/cli/util" 11 | "github.com/jotaen/klog/klog/app/main" 12 | ) 13 | 14 | //go:embed Specification.md 15 | var specification string 16 | 17 | //go:embed LICENSE.txt 18 | var license string 19 | 20 | var BinaryVersion string // Set via build flag 21 | var BinaryBuildHash string // Set via build flag 22 | 23 | func main() { 24 | if len(BinaryBuildHash) > 7 { 25 | BinaryBuildHash = BinaryBuildHash[:7] 26 | } 27 | 28 | klogFolder := func() app.File { 29 | f, err := determineKlogConfigFolder() 30 | if err != nil { 31 | fail(util.PrettifyAppError(err, false), app.CONFIG_ERROR.ToInt()) 32 | } 33 | return f 34 | }() 35 | 36 | configFile := func() string { 37 | c, err := readConfigFile(app.Join(klogFolder, app.CONFIG_FILE_NAME)) 38 | if err != nil { 39 | fail(util.PrettifyAppError(err, false), app.CONFIG_ERROR.ToInt()) 40 | } 41 | return c 42 | }() 43 | 44 | config := func() app.Config { 45 | c, err := app.NewConfig(runtime.NumCPU(), os.Getenv, configFile) 46 | if err != nil { 47 | fail(util.PrettifyAppError(err, false), app.CONFIG_ERROR.ToInt()) 48 | } 49 | return c 50 | }() 51 | 52 | code, err := klog.Run(klogFolder, app.Meta{ 53 | Specification: specification, 54 | License: license, 55 | Version: BinaryVersion, 56 | SrcHash: BinaryBuildHash, 57 | }, config, os.Args[1:]) 58 | if err != nil { 59 | fail(err, code) 60 | } 61 | } 62 | 63 | // fail terminates the process with an error. 64 | func fail(err error, exitCode int) { 65 | fmt.Println(err) 66 | os.Exit(exitCode) 67 | } 68 | 69 | // readConfigFile reads the config file from disk, if present. 70 | // If not present, it returns empty string. 71 | func readConfigFile(location app.File) (string, app.Error) { 72 | contents, rErr := app.ReadFile(location) 73 | if rErr != nil { 74 | if rErr.Code() == app.NO_SUCH_FILE { 75 | return "", nil 76 | } 77 | return "", rErr 78 | } 79 | return contents, nil 80 | } 81 | 82 | // determineKlogConfigFolder returns the location where the klog config folder 83 | // is (or should be) located. 84 | func determineKlogConfigFolder() (app.File, app.Error) { 85 | for _, kf := range app.KLOG_CONFIG_FOLDER { 86 | basePath := os.Getenv(kf.BasePathEnvVar) 87 | if basePath != "" { 88 | return app.NewFile(basePath, kf.Location) 89 | } 90 | } 91 | return nil, app.NewError( 92 | "Cannot determine klog config folder", 93 | "Please set the `KLOG_CONFIG_HOME` environment variable, and make it point to a valid, empty folder.", 94 | nil, 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /klog/service/record_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | gotime "time" 9 | ) 10 | 11 | func TestDoesNotTouchRecordsIfNoOpenRange(t *testing.T) { 12 | r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 13 | r.AddDuration(klog.NewDuration(1, 0), nil) 14 | r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil) 15 | 16 | hasClosedAnyRange, err := CloseOpenRanges(gotime.Now(), r) 17 | require.Nil(t, err) 18 | assert.False(t, hasClosedAnyRange) 19 | assert.Equal(t, klog.NewDuration(2, 0), Total(r)) 20 | } 21 | 22 | func TestClosesOpenRange(t *testing.T) { 23 | r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 24 | r.AddDuration(klog.NewDuration(1, 0), nil) 25 | r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil) 26 | r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil) 27 | 28 | endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-01T05:30:00-0000") 29 | hasClosedAnyRange, err := CloseOpenRanges(endTime, r) 30 | require.Nil(t, err) 31 | assert.True(t, hasClosedAnyRange) 32 | assert.Equal(t, klog.NewDuration(2+2, 30), Total(r)) 33 | } 34 | 35 | func TestClosesOpenRangeAndShiftsTime(t *testing.T) { 36 | r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 37 | r.AddDuration(klog.NewDuration(1, 0), nil) 38 | r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil) 39 | r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil) 40 | 41 | endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-02T05:30:00-0000") 42 | hasClosedAnyRange, err := CloseOpenRanges(endTime, r) 43 | require.Nil(t, err) 44 | assert.True(t, hasClosedAnyRange) 45 | assert.Equal(t, klog.NewDuration(2+24+2, 30), Total(r)) 46 | } 47 | 48 | func TestReturnsErrorIfOpenRangeCannotBeClosedAnymore(t *testing.T) { 49 | r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 50 | r.AddDuration(klog.NewDuration(1, 0), nil) 51 | r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil) 52 | r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil) 53 | 54 | endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-03T05:30:00-0000") 55 | _, err := CloseOpenRanges(endTime, r) 56 | require.Error(t, err) 57 | } 58 | 59 | func TestReturnsErrorIfOpenRangeCannotBeClosedYet(t *testing.T) { 60 | r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1)) 61 | r.AddDuration(klog.NewDuration(1, 0), nil) 62 | r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil) 63 | r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil) 64 | 65 | endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-01T01:30:00-0000") 66 | _, err := CloseOpenRanges(endTime, r) 67 | require.Error(t, err) 68 | } 69 | -------------------------------------------------------------------------------- /klog/app/cli/config.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/jotaen/klog/klog/app" 8 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 9 | "github.com/jotaen/klog/klog/app/cli/util" 10 | ) 11 | 12 | type Config struct { 13 | Location bool `name:"location" help:"Print the location of the config folder."` 14 | util.NoStyleArgs 15 | } 16 | 17 | func (opt *Config) Help() string { 18 | lookupOrder := func() string { 19 | lookups := make([]string, len(app.KLOG_CONFIG_FOLDER)) 20 | for i, f := range app.KLOG_CONFIG_FOLDER { 21 | lookups[i] = filepath.Join(f.EnvVarSymbol(), f.Location) 22 | } 23 | return strings.Join(lookups, " -> ") 24 | }() 25 | 26 | return ` 27 | klog relies on file-based configuration to customise some of its default behaviour and to keep track of its internal state. 28 | 29 | Run 'klog config --location' to print the path of the folder where klog looks for the configuration. 30 | The config folder can contain one or both of the following files: 31 | - '` + app.CONFIG_FILE_NAME + `': you can create this file manually to override some of klog’s default behaviour. You may use the output of the 'klog config' command as template for setting up this file, as its output is valid .ini syntax. 32 | - '` + app.BOOKMARKS_FILE_NAME + `': if you use the bookmarks functionality, then klog uses this file as database. You are not supposed to edit this file by hand! Instead, use the 'klog bookmarks' command to manage your bookmarks. 33 | 34 | You can customise the location of the config folder via environment variables. klog uses the following lookup precedence: 35 | ` + lookupOrder + ` 36 | ` 37 | } 38 | 39 | func (opt *Config) Run(ctx app.Context) app.Error { 40 | opt.NoStyleArgs.Apply(&ctx) 41 | 42 | if opt.Location { 43 | ctx.Print(ctx.KlogConfigFolder().Path() + "\n") 44 | return nil 45 | } 46 | 47 | styler, _ := ctx.Serialise() 48 | for i, e := range app.CONFIG_FILE_ENTRIES { 49 | ctx.Print(styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(util.Reflower.Reflow(e.Help.Summary, []string{"# "}))) 50 | ctx.Print("\n") 51 | ctx.Print(styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(util.Reflower.Reflow("Value: "+e.Help.Value, []string{"# - ", "# "}))) 52 | ctx.Print("\n") 53 | ctx.Print(styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(util.Reflower.Reflow("Default: "+e.Help.Default, []string{"# - ", "# "}))) 54 | ctx.Print("\n") 55 | ctx.Print(styler.Props(tf.StyleProps{Color: tf.RED}).Format(e.Name)) 56 | ctx.Print(" = ") 57 | ctx.Print(styler.Props(tf.StyleProps{Color: tf.YELLOW}).Format(e.Value(ctx.Config()))) 58 | if i < len(app.CONFIG_FILE_ENTRIES)-1 { 59 | ctx.Print("\n\n") 60 | } 61 | } 62 | ctx.Print("\n") 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /.github/benchmark.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog" 6 | "github.com/jotaen/klog/klog/app" 7 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 8 | "github.com/jotaen/klog/klog/parser" 9 | "math/rand" 10 | "os" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | var serialiser = app.NewSerialiser(tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR), false) 16 | 17 | func main() { 18 | // Setup 19 | iterations, err := strconv.Atoi(os.Args[1]) 20 | if err != nil { 21 | panic(err) 22 | } 23 | rand.Seed(int64(time.Now().Nanosecond())) 24 | 25 | // Generate records 26 | date := klog.Ɀ_Date_(0, 1, 1) 27 | for i := 0; i < iterations; i++ { 28 | if date.IsEqualTo(klog.Ɀ_Date_(9999, 12, 31)) { 29 | // Prevent date overflow 30 | date = klog.Ɀ_Date_(0, 1, 1) 31 | } 32 | date = date.PlusDays(1) 33 | r := klog.NewRecord(date) 34 | 35 | // Should total 36 | if i%2 == ri(0, 2) { 37 | r.SetShouldTotal(klog.NewDuration(ri(0, 23), ri(0, 59))) 38 | } 39 | 40 | // Summary 41 | text := rt(0, 5) 42 | if len(text) > 0 { 43 | r.SetSummary(klog.Ɀ_RecordSummary_(text...)) 44 | } 45 | 46 | // Entries 47 | entriesCount := ri(1, 5) 48 | for j := 0; j < entriesCount; j++ { 49 | added := re()(&r) 50 | if !added { 51 | entriesCount++ 52 | } 53 | } 54 | fmt.Println(parser.SerialiseRecords(serialiser, r).ToString()) 55 | } 56 | } 57 | 58 | // ri = random integer 59 | func ri(min int, max int) int { 60 | return rand.Intn(max+1-min) + min 61 | } 62 | 63 | // rt = random texts 64 | func rt(rowsMin int, rowsMax int) []string { 65 | alphabet := "abcdefghijklmnopqrstuvwxyz" 66 | 67 | texts := make([]string, ri(rowsMin, rowsMax)) 68 | for j := 0; j < len(texts); j++ { 69 | bs := make([]byte, ri(1, 50)) 70 | for i := range bs { 71 | bs[i] = alphabet[ri(0, len(alphabet)-1)] 72 | } 73 | texts[j] = string(bs) 74 | } 75 | return texts 76 | } 77 | 78 | // re = random entry 79 | func re() func(r *klog.Record) bool { 80 | text := rt(0, 2) 81 | var entrySummary klog.EntrySummary 82 | if len(text) > 0 { 83 | entrySummary = klog.Ɀ_EntrySummary_(text...) 84 | } 85 | entryAdders := []func(r *klog.Record) bool{ 86 | func(r *klog.Record) bool { 87 | (*r).AddDuration(klog.NewDuration(ri(-2, 23), ri(0, 60)), entrySummary) 88 | return true 89 | }, 90 | func(r *klog.Record) bool { 91 | (*r).AddRange(klog.Ɀ_Range_( 92 | klog.Ɀ_Time_(ri(0, 11), ri(0, 59)), 93 | klog.Ɀ_Time_(ri(12, 23), ri(0, 59)), 94 | ), entrySummary) 95 | return true 96 | }, 97 | func(r *klog.Record) bool { 98 | err := (*r).Start(klog.NewOpenRange(klog.Ɀ_Time_(ri(0, 23), ri(0, 59))), entrySummary) 99 | return err == nil 100 | }, 101 | } 102 | return entryAdders[ri(0, len(entryAdders)-1)] 103 | } 104 | -------------------------------------------------------------------------------- /klog/app/retriever_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | type MockFs map[string]bool 10 | 11 | func (fs MockFs) readFile(source File) (string, Error) { 12 | if fs[source.Path()] { 13 | return source.Path(), nil 14 | } 15 | return "", NewError("", source.Path(), nil) 16 | } 17 | 18 | func TestFileRetrieverResolvesFilesAndBookmarks(t *testing.T) { 19 | bc := NewEmptyBookmarksCollection() 20 | bc.Set(NewBookmark("foo", NewFileOrPanic("/foo.klg"))) 21 | files, err := (&FileRetriever{ 22 | MockFs{"/asdf.klg": true, "/foo.klg": true}.readFile, 23 | bc, 24 | }).Retrieve("/asdf.klg", "@foo") 25 | 26 | require.Nil(t, err) 27 | require.Len(t, files, 2) 28 | assert.Equal(t, "/asdf.klg", files[0].Path()) 29 | assert.Equal(t, "/foo.klg", files[1].Path()) 30 | } 31 | 32 | func TestReturnsErrorIfBookmarksOrFilesAreInvalid(t *testing.T) { 33 | bc := NewEmptyBookmarksCollection() 34 | bc.Set(NewBookmark("foo", NewFileOrPanic("/foo.klg"))) 35 | files, err := (&FileRetriever{ 36 | MockFs{}.readFile, 37 | bc, 38 | }).Retrieve("/asdf.klg", "@foo", "@bar") 39 | 40 | require.Nil(t, files) 41 | require.Error(t, err) 42 | assert.Contains(t, err.Details(), "/asdf.klg") 43 | assert.Contains(t, err.Details(), "/foo.klg") 44 | assert.Contains(t, err.Details(), "@bar") 45 | } 46 | 47 | func TestFallsBackToDefaultBookmark(t *testing.T) { 48 | bc := NewEmptyBookmarksCollection() 49 | bc.Set(NewDefaultBookmark(NewFileOrPanic("/foo.klg"))) 50 | retriever := &FileRetriever{ 51 | MockFs{"/foo.klg": true}.readFile, 52 | bc, 53 | } 54 | for _, f := range []func() ([]FileWithContents, Error){ 55 | func() ([]FileWithContents, Error) { return retriever.Retrieve() }, 56 | func() ([]FileWithContents, Error) { return retriever.Retrieve("") }, 57 | func() ([]FileWithContents, Error) { return retriever.Retrieve("", " ") }, 58 | } { 59 | files, err := f() 60 | require.Nil(t, err) 61 | require.Len(t, files, 1) 62 | assert.Equal(t, "/foo.klg", files[0].Path()) 63 | } 64 | } 65 | 66 | func TestReturnsStdinInput(t *testing.T) { 67 | retriever := &StdinRetriever{ 68 | func() (string, Error) { return "2021-01-01", nil }, 69 | } 70 | for _, f := range []func() ([]FileWithContents, Error){ 71 | func() ([]FileWithContents, Error) { return retriever.Retrieve() }, 72 | func() ([]FileWithContents, Error) { return retriever.Retrieve("") }, 73 | func() ([]FileWithContents, Error) { return retriever.Retrieve("", " ") }, 74 | } { 75 | files, err := f() 76 | require.Nil(t, err) 77 | require.Len(t, files, 1) 78 | require.Equal(t, "", files[0].Path()) 79 | assert.Equal(t, "2021-01-01", files[0].Contents()) 80 | } 81 | } 82 | 83 | func TestBailsOutIfFileArgsGiven(t *testing.T) { 84 | files, err := (&StdinRetriever{ 85 | func() (string, Error) { return "", nil }, 86 | }).Retrieve("foo.klg") 87 | 88 | require.Nil(t, err) 89 | require.Nil(t, files) 90 | } 91 | -------------------------------------------------------------------------------- /klog/app/cli/start.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/app" 6 | "github.com/jotaen/klog/klog/app/cli/util" 7 | "github.com/jotaen/klog/klog/parser/reconciling" 8 | "github.com/jotaen/klog/klog/parser/txt" 9 | "github.com/jotaen/klog/klog/service" 10 | ) 11 | 12 | type Start struct { 13 | util.SummaryArgs 14 | util.AtDateAndTimeArgs 15 | util.NoStyleArgs 16 | util.WarnArgs 17 | util.OutputFileArgs 18 | } 19 | 20 | func (opt *Start) Help() string { 21 | return ` 22 | This appends a new open-ended entry to the record. 23 | 24 | By default, it uses the record at today’s date for the new entry, or creates a new record if there no record at today’s date. 25 | You can otherwise specify a date with '--date'. 26 | 27 | Unless the '--time' flag is specified, it defaults to the current time as start time. 28 | If you prefer your time to be rounded, you can use the '--round' flag. 29 | 30 | You can either assign a summary text for the new entry via the '--summary' flag, or you can use the '--resume' flag to automatically take over the entry summary of the last entry. 31 | Note that '--resume' will fall back to the last record, if the current record doesn’t contain any entries yet. 32 | ` 33 | } 34 | 35 | func (opt *Start) Run(ctx app.Context) app.Error { 36 | opt.NoStyleArgs.Apply(&ctx) 37 | now := ctx.Now() 38 | date := opt.AtDate(now) 39 | time, tErr := opt.AtTime(now, ctx.Config()) 40 | if tErr != nil { 41 | return tErr 42 | } 43 | additionalData := reconciling.AdditionalData{} 44 | ctx.Config().DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) { 45 | additionalData.ShouldTotal = s 46 | }) 47 | 48 | spy := PreviousRecordSpy{} 49 | return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs}, 50 | []reconciling.Creator{ 51 | spy.phonyCreator(date), 52 | reconciling.NewReconcilerAtRecord(date), 53 | reconciling.NewReconcilerForNewRecord(date, opt.DateFormat(ctx.Config()), additionalData), 54 | }, 55 | 56 | func(reconciler *reconciling.Reconciler) error { 57 | summary, sErr := opt.Summary(reconciler.Record, spy.PreviousRecord) 58 | if sErr != nil { 59 | return sErr 60 | } 61 | return reconciler.StartOpenRange(time, opt.TimeFormat(ctx.Config()), summary) 62 | }, 63 | ) 64 | } 65 | 66 | type PreviousRecordSpy struct { 67 | PreviousRecord klog.Record 68 | } 69 | 70 | // phonyCreator is a no-op “pass-through” creator, whose only purpose it is to hook into 71 | // the reconciler-creation mechanism, to get a handle on the records for determining 72 | // the previous record. 73 | func (p *PreviousRecordSpy) phonyCreator(currentDate klog.Date) reconciling.Creator { 74 | return func(records []klog.Record, _ []txt.Block) *reconciling.Reconciler { 75 | for _, r := range service.Sort(records, false) { 76 | if r.Date().IsAfterOrEqual(currentDate) { 77 | continue 78 | } 79 | p.PreviousRecord = r 80 | return nil 81 | } 82 | return nil 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /klog/summary.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | ) 7 | 8 | // RecordSummary contains the summary lines of the overall summary that 9 | // appears underneath the date of a record. 10 | type RecordSummary []string 11 | 12 | // EntrySummary contains the summary line that appears behind the time value 13 | // of an entry. 14 | type EntrySummary []string 15 | 16 | var recordSummaryLinePattern = regexp.MustCompile(`^[\p{Zs}\t]`) 17 | 18 | // NewRecordSummary creates a new RecordSummary from individual lines of text. 19 | // None of the lines can start with blank characters, and none of the lines 20 | // can be empty or blank. 21 | func NewRecordSummary(line ...string) (RecordSummary, error) { 22 | for _, l := range line { 23 | if len(l) == 0 || recordSummaryLinePattern.MatchString(l) { 24 | return nil, errors.New("MALFORMED_SUMMARY") 25 | } 26 | } 27 | return line, nil 28 | } 29 | 30 | var entrySummaryLinePattern = regexp.MustCompile("^[\\p{Zs}\t]*$") 31 | 32 | // NewEntrySummary creates an EntrySummary from individual lines of text. 33 | // Except for the first line, none of the lines can be empty or blank. 34 | func NewEntrySummary(line ...string) (EntrySummary, error) { 35 | for i, l := range line { 36 | if i == 0 { 37 | continue 38 | } 39 | if len(l) == 0 || entrySummaryLinePattern.MatchString(l) { 40 | return nil, errors.New("MALFORMED_SUMMARY") 41 | } 42 | } 43 | return line, nil 44 | } 45 | 46 | func (s RecordSummary) Lines() []string { 47 | return s 48 | } 49 | 50 | func (s EntrySummary) Lines() []string { 51 | return RecordSummary(s).Lines() 52 | } 53 | 54 | func (s RecordSummary) Tags() *TagSet { 55 | tags := NewEmptyTagSet() 56 | for _, l := range s { 57 | for _, m := range HashTagPattern.FindAllStringSubmatch(l, -1) { 58 | tag, _ := NewTagFromString(m[0]) 59 | tags.Put(tag) 60 | } 61 | } 62 | return &tags 63 | } 64 | 65 | // Tags returns the tags that the entry summary contains. 66 | func (s EntrySummary) Tags() *TagSet { 67 | return RecordSummary(s).Tags() 68 | } 69 | 70 | func (s RecordSummary) Equals(summary RecordSummary) bool { 71 | if len(s) != len(summary) { 72 | return false 73 | } 74 | for i, l := range s { 75 | if l != summary[i] { 76 | return false 77 | } 78 | } 79 | return true 80 | } 81 | 82 | func (s EntrySummary) Equals(summary EntrySummary) bool { 83 | if len(s) == 1 && s[0] == "" && summary == nil { 84 | // In the case of entry summary, an empty one matches nil. 85 | return true 86 | } 87 | return RecordSummary(s).Equals(RecordSummary(summary)) 88 | } 89 | 90 | // Append appends a text to an entry summary 91 | func (s EntrySummary) Append(appendableText string) EntrySummary { 92 | if len(s) == 0 { 93 | return []string{appendableText} 94 | } 95 | delimiter := "" 96 | lastLine := s[len(s)-1] 97 | if len(lastLine) > 0 { 98 | delimiter = " " 99 | } 100 | s[len(s)-1] = lastLine + delimiter + appendableText 101 | return s 102 | } 103 | -------------------------------------------------------------------------------- /klog/app/cli/util/prettifier_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "github.com/jotaen/klog/klog/app" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/parser/txt" 8 | "github.com/stretchr/testify/assert" 9 | "testing" 10 | ) 11 | 12 | var styler = tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR) 13 | 14 | func TestFormatParserError(t *testing.T) { 15 | block1, _ := txt.ParseBlock("Good text\nSome malformed text", 37) 16 | block2, _ := txt.ParseBlock("Another issue!", 133) 17 | err := app.NewParserErrors([]txt.Error{ 18 | txt.NewError(block1, 1, 0, 4, "CODE", "Error", "Short explanation."), 19 | txt.NewError(block2, 0, 8, 5, "CODE", "Problem", "More info.").SetOrigin("some-file.klg"), 20 | }) 21 | text := PrettifyParsingError(err, styler).Error() 22 | assert.Equal(t, ` 23 | [SYNTAX ERROR] in line 39 24 | Some malformed text 25 | ^^^^ 26 | Error: Short explanation. 27 | 28 | [SYNTAX ERROR] in line 134 of file some-file.klg 29 | Another issue! 30 | ^^^^^ 31 | Problem: More info. 32 | `, tf.StripAllAnsiSequences(text)) 33 | } 34 | 35 | func TestReflowsLongMessages(t *testing.T) { 36 | block, _ := txt.ParseBlock("Foo bar", 1) 37 | err := app.NewParserErrors([]txt.Error{ 38 | txt.NewError(block, 0, 4, 3, "CODE", "Some Title", "A verbose description with details, potentially spanning multiple lines with a comprehensive text and tremendously helpful information.\nBut\nit\nrespects\nnewlines."), 39 | }) 40 | text := PrettifyParsingError(err, styler).Error() 41 | assert.Equal(t, ` 42 | [SYNTAX ERROR] in line 2 43 | Foo bar 44 | ^^^ 45 | Some Title: A verbose description with details, potentially spanning multiple 46 | lines with a comprehensive text and tremendously helpful information. 47 | But 48 | it 49 | respects 50 | newlines. 51 | `, tf.StripAllAnsiSequences(text)) 52 | } 53 | 54 | func TestConvertsTabToSpaces(t *testing.T) { 55 | block, _ := txt.ParseBlock("\tFoo\tbar", 13) 56 | err := app.NewParserErrors([]txt.Error{ 57 | txt.NewError(block, 0, 0, 8, "CODE", "Error title", "Error details"), 58 | }) 59 | text := PrettifyParsingError(err, styler).Error() 60 | assert.Equal(t, ` 61 | [SYNTAX ERROR] in line 14 62 | Foo bar 63 | ^^^^^^^^ 64 | Error title: Error details 65 | `, tf.StripAllAnsiSequences(text)) 66 | } 67 | 68 | func TestFormatAppError(t *testing.T) { 69 | err := app.NewError("Some message", "A more detailed explanation", nil) 70 | text := PrettifyAppError(err, false).Error() 71 | assert.Equal(t, `Error: Some message 72 | A more detailed explanation`, text) 73 | } 74 | 75 | func TestFormatAppErrorWithDebugFlag(t *testing.T) { 76 | textWithNilErr := PrettifyAppError( 77 | app.NewError("Some message", "A more detailed explanation", nil), 78 | true).Error() 79 | assert.Equal(t, `Error: Some message 80 | A more detailed explanation`, textWithNilErr) 81 | 82 | textWithErr := PrettifyAppError( 83 | app.NewError("Some message", "A more detailed explanation", errors.New("ORIG_ERR")), 84 | true).Error() 85 | assert.Equal(t, `Error: Some message 86 | A more detailed explanation 87 | 88 | Original Error: 89 | ORIG_ERR`, textWithErr) 90 | } 91 | -------------------------------------------------------------------------------- /klog/app/cli/print_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog/app/cli/util" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestPrintOutEmptyInput(t *testing.T) { 11 | { 12 | state, err := NewTestingContext()._SetRecords(``)._Run((&Print{}).Run) 13 | require.Nil(t, err) 14 | assert.Equal(t, "", state.printBuffer) 15 | } 16 | { 17 | state, err := NewTestingContext()._SetRecords(``)._Run((&Print{ 18 | WithTotals: true, 19 | }).Run) 20 | require.Nil(t, err) 21 | assert.Equal(t, "", state.printBuffer) 22 | } 23 | } 24 | 25 | func TestPrintOutRecord(t *testing.T) { 26 | state, err := NewTestingContext()._SetRecords(` 27 | 2018-01-31 28 | Hello #world 29 | 1h 30 | `)._Run((&Print{}).Run) 31 | require.Nil(t, err) 32 | assert.Equal(t, ` 33 | 2018-01-31 34 | Hello #world 35 | 1h 36 | 37 | `, state.printBuffer) 38 | } 39 | 40 | func TestPrintOutRecordInCanonicalFormat(t *testing.T) { 41 | state, err := NewTestingContext()._SetRecords(` 42 | 2018-01-31 43 | Hello #world 44 | 09:00-13:00 45 | 22:00 - 24:00 46 | 60m 47 | 2h0m 48 | 0h 49 | `)._Run((&Print{}).Run) 50 | require.Nil(t, err) 51 | assert.Equal(t, ` 52 | 2018-01-31 53 | Hello #world 54 | 9:00-13:00 55 | 22:00 - 0:00> 56 | 1h 57 | 2h 58 | 0m 59 | 60 | `, state.printBuffer) 61 | } 62 | 63 | func TestPrintOutRecordsInChronologicalOrder(t *testing.T) { 64 | original := "2018-02-01\n\n2018-01-30\n\n2018-01-31" 65 | 66 | stateUnsorted, _ := NewTestingContext(). 67 | _SetRecords(original). 68 | _Run((&Print{}).Run) 69 | assert.Equal(t, "\n"+original+"\n\n", stateUnsorted.printBuffer) 70 | 71 | stateSortedAsc, _ := NewTestingContext(). 72 | _SetRecords(original). 73 | _Run((&Print{SortArgs: util.SortArgs{Sort: "asc"}}).Run) 74 | assert.Equal(t, "\n2018-01-30\n\n2018-01-31\n\n2018-02-01\n\n", stateSortedAsc.printBuffer) 75 | 76 | stateSortedDesc, _ := NewTestingContext(). 77 | _SetRecords(original). 78 | _Run((&Print{SortArgs: util.SortArgs{Sort: "desc"}}).Run) 79 | assert.Equal(t, "\n2018-02-01\n\n2018-01-31\n\n2018-01-30\n\n", stateSortedDesc.printBuffer) 80 | } 81 | 82 | func TestPrintRecordsWithDurations(t *testing.T) { 83 | state, err := NewTestingContext()._SetNow(2018, 02, 07, 19, 00)._SetRecords(` 84 | 2018-01-31 85 | Hello #world 86 | Test test test 87 | 1h 88 | 89 | 2018-02-04 90 | 10:00 - 17:22 91 | +6h 92 | -5m 93 | 94 | 2018-02-07 95 | 35m 96 | Foo 97 | Bar 98 | 18:00 - ? I just 99 | started something 100 | `)._Run((&Print{ 101 | WithTotals: true, 102 | }).Run) 103 | require.Nil(t, err) 104 | assert.Equal(t, ` 105 | 1h | 2018-01-31 106 | | Hello #world 107 | | Test test test 108 | 1h | 1h 109 | 110 | 13h17m | 2018-02-04 111 | 7h22m | 10:00 - 17:22 112 | 6h | +6h 113 | -5m | -5m 114 | 115 | 35m | 2018-02-07 116 | 35m | 35m 117 | | Foo 118 | | Bar 119 | 0m | 18:00 - ? I just 120 | | started something 121 | 122 | `, state.printBuffer) 123 | } 124 | -------------------------------------------------------------------------------- /klog/app/cli/tags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog/app" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/app/cli/util" 8 | "github.com/jotaen/klog/klog/service" 9 | ) 10 | 11 | type Tags struct { 12 | Values bool `name:"values" short:"v" help:"Display breakdown of tag values (if the data contains any; e.g.: '#tag=value')."` 13 | Count bool `name:"count" short:"c" help:"Display the number of matching entries per tag."` 14 | WithUntagged bool `name:"with-untagged" short:"u" help:"Display remainder of any untagged entries"` 15 | util.FilterArgs 16 | util.NowArgs 17 | util.DecimalArgs 18 | util.WarnArgs 19 | util.NoStyleArgs 20 | util.InputFilesArgs 21 | } 22 | 23 | func (opt *Tags) Help() string { 24 | return ` 25 | If a tag appears in the overall record summary, then all of the record’s entries match. 26 | If a tag appears in an entry summary, only that particular entry matches. 27 | If tags are specified redundantly in the data, the respective time is still counted uniquely. 28 | 29 | If you use tags with values (e.g., '#tag=value'), then these also match against the base tag (e.g., '#tag'). 30 | You can use the '--values' flag to display an additional breakdown by tag value. 31 | 32 | Note that tag names are case-insensitive (e.g., '#tag' is the same as '#TAG'), whereas tag values are case-sensitive (so '#tag=value' is different from '#tag=VALUE'). 33 | ` 34 | } 35 | 36 | func (opt *Tags) Run(ctx app.Context) app.Error { 37 | opt.DecimalArgs.Apply(&ctx) 38 | opt.NoStyleArgs.Apply(&ctx) 39 | styler, serialiser := ctx.Serialise() 40 | records, err := ctx.ReadInputs(opt.File...) 41 | if err != nil { 42 | return err 43 | } 44 | now := ctx.Now() 45 | records = opt.ApplyFilter(now, records) 46 | nErr := opt.ApplyNow(now, records...) 47 | if nErr != nil { 48 | return nErr 49 | } 50 | tagStats, untagged := service.AggregateTotalsByTags(records...) 51 | numberOfColumns := 2 52 | if opt.Values { 53 | numberOfColumns++ 54 | } 55 | if opt.Count { 56 | numberOfColumns++ 57 | } 58 | countString := func(c int) string { 59 | return styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(fmt.Sprintf(" (%d)", c)) 60 | } 61 | table := tf.NewTable(numberOfColumns, " ") 62 | for _, t := range tagStats { 63 | totalString := serialiser.Duration(t.Total) 64 | if t.Tag.Value() == "" { 65 | table.CellL("#" + t.Tag.Name()) 66 | table.CellL(totalString) 67 | if opt.Values { 68 | table.Skip(1) 69 | } 70 | if opt.Count { 71 | table.CellL(countString(t.Count)) 72 | } 73 | } else if opt.Values { 74 | table.CellL(" " + styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(t.Tag.Value())) 75 | table.Skip(1) 76 | table.CellL(totalString) 77 | if opt.Count { 78 | table.CellL(countString(t.Count)) 79 | } 80 | } 81 | } 82 | if opt.WithUntagged { 83 | table.CellL("(untagged)") 84 | table.CellL(serialiser.Duration(untagged.Total)) 85 | if opt.Values { 86 | table.Skip(1) 87 | } 88 | if opt.Count { 89 | table.CellL(countString(untagged.Count)) 90 | } 91 | } 92 | table.Collect(ctx.Print) 93 | opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings()) 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /klog/testutil.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | /** 4 | Only use these functions in test code. 5 | (They cannot live in a `_test.go` file 6 | because they need to be imported elsewhere. 7 | They cannot live in a separate package 8 | neither due to circular imports.) 9 | The `Deprecated` markers and the funny naming 10 | are supposed to act as a reminder for this. 11 | */ 12 | 13 | // Deprecated 14 | func Ɀ_Date_(year int, month int, day int) Date { 15 | date, err := NewDate(year, month, day) 16 | if err != nil { 17 | panic("Operation failed!") 18 | } 19 | return date 20 | } 21 | 22 | // Deprecated 23 | func Ɀ_Slashes_(d Date) Date { 24 | df, canCast := d.(*date) 25 | if !canCast { 26 | panic("Operation failed!") 27 | } 28 | df.format.UseDashes = false 29 | return df 30 | } 31 | 32 | // Deprecated 33 | func Ɀ_RecordSummary_(line ...string) RecordSummary { 34 | summary, err := NewRecordSummary(line...) 35 | if err != nil { 36 | panic("Operation failed!") 37 | } 38 | return summary 39 | } 40 | 41 | // Deprecated 42 | func Ɀ_EntrySummary_(line ...string) EntrySummary { 43 | summary, err := NewEntrySummary(line...) 44 | if err != nil { 45 | panic("Operation failed!") 46 | } 47 | return summary 48 | } 49 | 50 | // Deprecated 51 | func Ɀ_ForceSign_(d Duration) Duration { 52 | do, canCast := d.(*duration) 53 | if !canCast { 54 | panic("Operation failed!") 55 | } 56 | do.format.ForcePlus = true 57 | return do 58 | } 59 | 60 | // Deprecated 61 | func Ɀ_Time_(hour int, minute int) Time { 62 | time, err := NewTime(hour, minute) 63 | if err != nil { 64 | panic("Operation failed!") 65 | } 66 | return time 67 | } 68 | 69 | // Deprecated 70 | func Ɀ_IsAmPm_(t Time) Time { 71 | tm, canCast := t.(*time) 72 | if !canCast { 73 | panic("Operation failed!") 74 | } 75 | tm.format.Use24HourClock = false 76 | return tm 77 | } 78 | 79 | // Deprecated 80 | func Ɀ_TimeYesterday_(hour int, minute int) Time { 81 | time, err := NewTimeYesterday(hour, minute) 82 | if err != nil { 83 | panic("Operation failed!") 84 | } 85 | return time 86 | } 87 | 88 | // Deprecated 89 | func Ɀ_TimeTomorrow_(hour int, minute int) Time { 90 | time, err := NewTimeTomorrow(hour, minute) 91 | if err != nil { 92 | panic("Operation failed!") 93 | } 94 | return time 95 | } 96 | 97 | // Deprecated 98 | func Ɀ_Range_(start Time, end Time) Range { 99 | r, err := NewRange(start, end) 100 | if err != nil { 101 | panic("Operation failed!") 102 | } 103 | return r 104 | } 105 | 106 | // Deprecated 107 | func Ɀ_NoSpaces_(r Range) Range { 108 | tr, canCast := r.(*timeRange) 109 | if !canCast { 110 | panic("Operation failed!") 111 | } 112 | tr.format.UseSpacesAroundDash = false 113 | return tr 114 | } 115 | 116 | // Deprecated 117 | func Ɀ_NoSpacesO_(r OpenRange) OpenRange { 118 | or, canCast := r.(*openRange) 119 | if !canCast { 120 | panic("Operation failed!") 121 | } 122 | or.format.UseSpacesAroundDash = false 123 | return or 124 | } 125 | 126 | // Deprecated 127 | func Ɀ_QuestionMarks_(r OpenRange, additionalQuestionMarks int) OpenRange { 128 | or, canCast := r.(*openRange) 129 | if !canCast { 130 | panic("Operation failed!") 131 | } 132 | or.format.AdditionalPlaceholderChars = additionalQuestionMarks 133 | return or 134 | } 135 | -------------------------------------------------------------------------------- /klog/app/error.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jotaen/klog/klog/parser/txt" 6 | ) 7 | 8 | type Code int 9 | 10 | const ( 11 | // GENERAL_ERROR should be used for generic, otherwise unspecified errors. 12 | GENERAL_ERROR Code = iota + 1 13 | 14 | // NO_INPUT_ERROR should be used if no input was specified. 15 | NO_INPUT_ERROR 16 | 17 | // NO_TARGET_FILE should be used if no target file was specified. 18 | NO_TARGET_FILE 19 | 20 | // IO_ERROR should be used for errors during I/O processes. 21 | IO_ERROR 22 | 23 | // CONFIG_ERROR should be used for config-folder-related problems. 24 | CONFIG_ERROR 25 | 26 | // NO_SUCH_BOOKMARK_ERROR should be used if the specified an unknown bookmark name. 27 | NO_SUCH_BOOKMARK_ERROR 28 | 29 | // NO_SUCH_FILE should be used if the specified file does not exit. 30 | NO_SUCH_FILE 31 | 32 | // LOGICAL_ERROR should be used syntax or logical violations. 33 | LOGICAL_ERROR 34 | ) 35 | 36 | // ToInt returns the numeric value of the error. This is typically used as exit code. 37 | func (c Code) ToInt() int { 38 | return int(c) 39 | } 40 | 41 | // Error is a representation of an application error. 42 | type Error interface { 43 | // Error returns the error message. 44 | Error() string 45 | 46 | Is(error) bool 47 | 48 | // Details returns additional details, such as a hint how to solve the problem. 49 | Details() string 50 | 51 | // Original returns the original underlying error, if it exists. 52 | Original() error 53 | 54 | // Code returns the error code. 55 | Code() Code 56 | } 57 | 58 | type AppError struct { 59 | code Code 60 | message string 61 | details string 62 | original error 63 | } 64 | 65 | func NewError(message string, details string, original error) Error { 66 | return NewErrorWithCode(GENERAL_ERROR, message, details, original) 67 | } 68 | 69 | func NewErrorWithCode(code Code, message string, details string, original error) Error { 70 | return AppError{code, message, details, original} 71 | } 72 | 73 | func (e AppError) Error() string { 74 | return e.message 75 | } 76 | 77 | func (e AppError) Is(err error) bool { 78 | _, ok := err.(AppError) 79 | return ok 80 | } 81 | 82 | func (e AppError) Details() string { 83 | return e.details 84 | } 85 | 86 | func (e AppError) Original() error { 87 | return e.original 88 | } 89 | 90 | func (e AppError) Code() Code { 91 | return e.code 92 | } 93 | 94 | type ParserErrors interface { 95 | Error 96 | All() []txt.Error 97 | } 98 | 99 | type parserErrors struct { 100 | errors []txt.Error 101 | } 102 | 103 | func NewParserErrors(errs []txt.Error) ParserErrors { 104 | return parserErrors{errs} 105 | } 106 | 107 | func (pe parserErrors) Error() string { 108 | return fmt.Sprintf("%d parsing error(s)", len(pe.errors)) 109 | } 110 | 111 | func (e parserErrors) Is(err error) bool { 112 | _, ok := err.(parserErrors) 113 | return ok 114 | } 115 | 116 | func (pe parserErrors) Details() string { 117 | return fmt.Sprintf("%d parsing error(s)", len(pe.errors)) 118 | } 119 | 120 | func (pe parserErrors) Original() error { 121 | return nil 122 | } 123 | 124 | func (pe parserErrors) Code() Code { 125 | return LOGICAL_ERROR 126 | } 127 | 128 | func (pe parserErrors) All() []txt.Error { 129 | return pe.errors 130 | } 131 | -------------------------------------------------------------------------------- /klog/range_test.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | // "klog/testutil" REMINDER: can not use `testutil` because of circular import 7 | "testing" 8 | ) 9 | 10 | func TestCreateRange(t *testing.T) { 11 | time1, _ := NewTime(11, 25) 12 | time2, _ := NewTime(17, 10) 13 | tr, err := NewRange(time1, time2) 14 | require.Nil(t, err) 15 | require.NotNil(t, tr) 16 | assert.Equal(t, time1, tr.Start()) 17 | assert.Equal(t, time2, tr.End()) 18 | } 19 | 20 | func TestCreateOpenRange(t *testing.T) { 21 | time1, _ := NewTime(11, 25) 22 | or := NewOpenRange(time1) 23 | require.NotNil(t, or) 24 | assert.Equal(t, time1, or.Start()) 25 | } 26 | 27 | func TestCreateVoidRange(t *testing.T) { 28 | time1, _ := NewTime(12, 00) 29 | time2, _ := NewTime(12, 00) 30 | tr, err := NewRange(time1, time2) 31 | require.Nil(t, err) 32 | require.NotNil(t, tr) 33 | assert.Equal(t, time1, tr.Start()) 34 | assert.Equal(t, time2, tr.End()) 35 | assert.Equal(t, NewDuration(0, 00), tr.Duration()) 36 | } 37 | 38 | func TestCreateOverlappingRangeStartingYesterday(t *testing.T) { 39 | time1, _ := NewTimeYesterday(23, 30) 40 | time2, _ := NewTime(8, 10) 41 | tr, err := NewRange(time1, time2) 42 | require.Nil(t, err) 43 | require.NotNil(t, tr) 44 | assert.Equal(t, time1, tr.Start()) 45 | assert.Equal(t, time2, tr.End()) 46 | assert.Equal(t, NewDuration(8, 40), tr.Duration()) 47 | } 48 | 49 | func TestCreateOverlappingRangeEndingTomorrow(t *testing.T) { 50 | time1, _ := NewTime(18, 15) 51 | time2, _ := NewTimeTomorrow(1, 45) 52 | tr, err := NewRange(time1, time2) 53 | require.Nil(t, err) 54 | require.NotNil(t, tr) 55 | assert.Equal(t, time1, tr.Start()) 56 | assert.Equal(t, time2, tr.End()) 57 | assert.Equal(t, NewDuration(7, 30), tr.Duration()) 58 | } 59 | 60 | func TestCreationFailsIfStartIsBeforeEnd(t *testing.T) { 61 | for _, p := range []func() (Range, error){ 62 | func() (Range, error) { 63 | start, _ := NewTime(15, 00) 64 | end, _ := NewTime(14, 00) 65 | return NewRange(start, end) 66 | }, 67 | func() (Range, error) { 68 | start, _ := NewTime(14, 00) 69 | end, _ := NewTimeYesterday(15, 00) 70 | return NewRange(start, end) 71 | }, 72 | func() (Range, error) { 73 | start, _ := NewTimeTomorrow(14, 00) 74 | end, _ := NewTime(15, 00) 75 | return NewRange(start, end) 76 | }, 77 | } { 78 | tr, err := p() 79 | assert.Nil(t, tr) 80 | assert.EqualError(t, err, "ILLEGAL_RANGE") 81 | } 82 | } 83 | 84 | func TestDefaultFormatting(t *testing.T) { 85 | time1, _ := NewTime(11, 25) 86 | time2, _ := NewTime(17, 10) 87 | tr, _ := NewRange(time1, time2) 88 | assert.Equal(t, "11:25 - 17:10", tr.ToString()) 89 | 90 | or := NewOpenRange(time1) 91 | assert.Equal(t, "11:25 - ?", or.ToString()) 92 | } 93 | 94 | func TestCustomFormatting(t *testing.T) { 95 | time1, _ := NewTime(11, 25) 96 | time2, _ := NewTime(17, 10) 97 | tr, _ := NewRangeWithFormat(time1, time2, RangeFormat{UseSpacesAroundDash: false}) 98 | assert.Equal(t, "11:25-17:10", tr.ToString()) 99 | 100 | or := NewOpenRangeWithFormat(time1, OpenRangeFormat{ 101 | UseSpacesAroundDash: false, 102 | AdditionalPlaceholderChars: 4, 103 | }) 104 | assert.Equal(t, "11:25-?????", or.ToString()) 105 | } 106 | -------------------------------------------------------------------------------- /klog/service/period/quarter_test.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestQuarterPeriod(t *testing.T) { 11 | for _, x := range []struct { 12 | actual Period 13 | expected Period 14 | }{ 15 | // Q1 16 | {NewQuarterFromDate(klog.Ɀ_Date_(1999, 1, 19)).Period(), NewPeriod(klog.Ɀ_Date_(1999, 1, 1), klog.Ɀ_Date_(1999, 3, 31))}, 17 | 18 | // Q2 19 | {NewQuarterFromDate(klog.Ɀ_Date_(2005, 5, 19)).Period(), NewPeriod(klog.Ɀ_Date_(2005, 4, 1), klog.Ɀ_Date_(2005, 6, 30))}, 20 | 21 | // Q3 22 | {NewQuarterFromDate(klog.Ɀ_Date_(1589, 8, 3)).Period(), NewPeriod(klog.Ɀ_Date_(1589, 7, 1), klog.Ɀ_Date_(1589, 9, 30))}, 23 | 24 | // Q4 25 | {NewQuarterFromDate(klog.Ɀ_Date_(2134, 12, 30)).Period(), NewPeriod(klog.Ɀ_Date_(2134, 10, 1), klog.Ɀ_Date_(2134, 12, 31))}, 26 | 27 | // Since is same as original date 28 | {NewQuarterFromDate(klog.Ɀ_Date_(1998, 4, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 4, 1), klog.Ɀ_Date_(1998, 6, 30))}, 29 | 30 | // Until is same as original date 31 | {NewQuarterFromDate(klog.Ɀ_Date_(1998, 9, 30)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 7, 1), klog.Ɀ_Date_(1998, 9, 30))}, 32 | } { 33 | assert.Equal(t, x.expected, x.actual) 34 | } 35 | } 36 | 37 | func TestParseValidQuarter(t *testing.T) { 38 | for _, x := range []struct { 39 | text string 40 | expect Period 41 | }{ 42 | {"0000-Q1", NewPeriod(klog.Ɀ_Date_(0, 1, 1), klog.Ɀ_Date_(0, 3, 31))}, 43 | {"0475-Q2", NewPeriod(klog.Ɀ_Date_(475, 4, 1), klog.Ɀ_Date_(475, 6, 30))}, 44 | {"2008-Q3", NewPeriod(klog.Ɀ_Date_(2008, 7, 1), klog.Ɀ_Date_(2008, 9, 30))}, 45 | {"8641-Q4", NewPeriod(klog.Ɀ_Date_(8641, 10, 1), klog.Ɀ_Date_(8641, 12, 31))}, 46 | } { 47 | quarter, err := NewQuarterFromString(x.text) 48 | require.Nil(t, err) 49 | assert.True(t, x.expect.Since().IsEqualTo(quarter.Period().Since())) 50 | assert.True(t, x.expect.Until().IsEqualTo(quarter.Period().Until())) 51 | } 52 | } 53 | 54 | func TestParseRejectsInvalidQuarter(t *testing.T) { 55 | for _, x := range []string{ 56 | "2000-Q5", 57 | "2000-Q0", 58 | "2000-Q-1", 59 | "2000-Q", 60 | "2000-q2", 61 | "2000-asdf", 62 | "2000-Q01", 63 | "2000-", 64 | "273888-Q2", 65 | "Q3", 66 | } { 67 | _, err := NewQuarterFromString(x) 68 | require.Error(t, err) 69 | } 70 | } 71 | 72 | func TestQuarterPreviousQuarter(t *testing.T) { 73 | for _, x := range []struct { 74 | initial Quarter 75 | expected Period 76 | }{ 77 | // In same year 78 | {NewQuarterFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 1, 1), klog.Ɀ_Date_(1987, 3, 31))}, 79 | {NewQuarterFromDate(klog.Ɀ_Date_(1987, 4, 19)), NewPeriod(klog.Ɀ_Date_(1987, 1, 1), klog.Ɀ_Date_(1987, 3, 31))}, 80 | {NewQuarterFromDate(klog.Ɀ_Date_(1444, 8, 13)), NewPeriod(klog.Ɀ_Date_(1444, 4, 1), klog.Ɀ_Date_(1444, 6, 30))}, 81 | {NewQuarterFromDate(klog.Ɀ_Date_(2009, 12, 31)), NewPeriod(klog.Ɀ_Date_(2009, 7, 1), klog.Ɀ_Date_(2009, 9, 30))}, 82 | {NewQuarterFromDate(klog.Ɀ_Date_(2009, 10, 1)), NewPeriod(klog.Ɀ_Date_(2009, 7, 1), klog.Ɀ_Date_(2009, 9, 30))}, 83 | 84 | // In last year 85 | {NewQuarterFromDate(klog.Ɀ_Date_(1987, 1, 1)), NewPeriod(klog.Ɀ_Date_(1986, 10, 1), klog.Ɀ_Date_(1986, 12, 31))}, 86 | {NewQuarterFromDate(klog.Ɀ_Date_(2400, 2, 27)), NewPeriod(klog.Ɀ_Date_(2399, 10, 1), klog.Ɀ_Date_(2399, 12, 31))}, 87 | } { 88 | previous := x.initial.Previous().Period() 89 | assert.Equal(t, x.expected, previous) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /klog/app/retriever.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // Retriever is the function interface for retrieving the input data from 9 | // various kinds of sources. 10 | type Retriever func(fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) 11 | 12 | type FileRetriever struct { 13 | readFile func(File) (string, Error) 14 | bookmarks BookmarksCollection 15 | } 16 | 17 | // Retrieve retrieves the contents from files or bookmarks. If no arguments were 18 | // specified, it tries to read from the default bookmark. 19 | func (retriever *FileRetriever) Retrieve(fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) { 20 | fileArgs = removeBlankEntries(fileArgs...) 21 | if len(fileArgs) == 0 { 22 | defaultBookmark := retriever.bookmarks.Default() 23 | if defaultBookmark != nil { 24 | fileArgs = []FileOrBookmarkName{ 25 | FileOrBookmarkName(defaultBookmark.Target().Path()), 26 | } 27 | } 28 | } 29 | var results []FileWithContents 30 | var errs []string 31 | for _, arg := range fileArgs { 32 | argValue := string(arg) 33 | path, pathErr := (func() (string, error) { 34 | if IsValidBookmarkName(argValue) { 35 | b := retriever.bookmarks.Get(NewName(argValue)) 36 | if b == nil { 37 | return argValue, errors.New("No such bookmark") 38 | } 39 | return b.Target().Path(), nil 40 | } 41 | return argValue, nil 42 | })() 43 | if pathErr != nil { 44 | errs = append(errs, pathErr.Error()+": "+argValue) 45 | continue 46 | } 47 | file, fErr := NewFile(path) 48 | if fErr != nil { 49 | errs = append(errs, "Invalid file path: "+path) 50 | } 51 | content, readErr := retriever.readFile(file) 52 | if readErr != nil { 53 | errs = append(errs, readErr.Error()+": "+file.Path()) 54 | continue 55 | } 56 | results = append(results, &fileWithContents{file, content}) 57 | } 58 | if len(errs) > 0 { 59 | return nil, NewErrorWithCode( 60 | IO_ERROR, 61 | "Cannot retrieve files", 62 | strings.Join(errs, "\n"), 63 | nil, 64 | ) 65 | } 66 | return results, nil 67 | } 68 | 69 | type StdinRetriever struct { 70 | readStdin func() (string, Error) 71 | } 72 | 73 | // Retrieve retrieves the content from stdin. It only returns something if no 74 | // file or bookmark names were specified explicitly. 75 | func (retriever *StdinRetriever) Retrieve(fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) { 76 | fileArgs = removeBlankEntries(fileArgs...) 77 | if len(fileArgs) > 0 { 78 | return nil, nil 79 | } 80 | stdin, err := retriever.readStdin() 81 | if err != nil { 82 | return nil, err 83 | } 84 | if stdin == "" { 85 | return nil, nil 86 | } 87 | return []FileWithContents{&fileWithContents{ 88 | File: &fileWithPath{""}, 89 | contents: stdin, 90 | }}, nil 91 | } 92 | 93 | func removeBlankEntries(fileArgs ...FileOrBookmarkName) []FileOrBookmarkName { 94 | var result []FileOrBookmarkName 95 | for _, f := range fileArgs { 96 | if strings.TrimLeft(string(f), " ") == "" { 97 | continue 98 | } 99 | result = append(result, f) 100 | } 101 | return result 102 | } 103 | 104 | // retrieveFirst returns the result from the first Retriever that yields something. 105 | func retrieveFirst(rs []Retriever, fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) { 106 | for _, r := range rs { 107 | files, err := r(fileArgs...) 108 | if err != nil { 109 | return nil, err 110 | } 111 | if len(files) > 0 { 112 | return files, nil 113 | } 114 | } 115 | return nil, nil 116 | } 117 | -------------------------------------------------------------------------------- /klog/app/cli/print.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/app" 6 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 7 | "github.com/jotaen/klog/klog/app/cli/util" 8 | "github.com/jotaen/klog/klog/parser" 9 | "github.com/jotaen/klog/klog/service" 10 | "strings" 11 | ) 12 | 13 | type Print struct { 14 | WithTotals bool `name:"with-totals" help:"Amend output with evaluated total times."` 15 | util.FilterArgs 16 | util.SortArgs 17 | util.WarnArgs 18 | util.NoStyleArgs 19 | util.InputFilesArgs 20 | } 21 | 22 | func (opt *Print) Help() string { 23 | return ` 24 | Outputs data on the terminal, by default with syntax-highlighting turned on. 25 | Note that the output doesn’t resemble the file byte by byte, but the command may apply some minor clean-ups of the formatting. 26 | 27 | If run with filter flags, it only outputs those entries that match the filter clauses. 28 | You can optionally also sort the records, or print out the total times for each record and entry. 29 | ` 30 | } 31 | 32 | func (opt *Print) Run(ctx app.Context) app.Error { 33 | opt.NoStyleArgs.Apply(&ctx) 34 | styler, serialser := ctx.Serialise() 35 | records, err := ctx.ReadInputs(opt.File...) 36 | if err != nil { 37 | return err 38 | } 39 | now := ctx.Now() 40 | records = opt.ApplyFilter(now, records) 41 | if len(records) == 0 { 42 | return nil 43 | } 44 | records = opt.ApplySort(records) 45 | serialisedRecords := parser.SerialiseRecords(serialser, records...) 46 | output := func() string { 47 | if opt.WithTotals { 48 | return printWithDurations(styler, serialisedRecords) 49 | } 50 | return "\n" + serialisedRecords.ToString() 51 | }() 52 | ctx.Print(output + "\n") 53 | 54 | opt.WarnArgs.PrintWarnings(ctx, records, nil) 55 | return nil 56 | } 57 | 58 | func printWithDurations(styler tf.Styler, ls parser.Lines) string { 59 | type Prefix struct { 60 | d klog.Duration 61 | isSub bool 62 | } 63 | var prefixes []*Prefix 64 | maxColumnLength := 0 65 | var previousRecord klog.Record 66 | previousEntry := -1 67 | for _, l := range ls { 68 | prefix := func() *Prefix { 69 | if l.Record == nil { 70 | previousRecord = nil 71 | previousEntry = -1 72 | return nil 73 | } 74 | if previousRecord == nil { 75 | previousRecord = l.Record 76 | return &Prefix{service.Total(l.Record), false} 77 | } 78 | if l.EntryI != -1 && l.EntryI != previousEntry { 79 | previousEntry = l.EntryI 80 | return &Prefix{l.Record.Entries()[l.EntryI].Duration(), true} 81 | } else { 82 | return nil 83 | } 84 | }() 85 | prefixes = append(prefixes, prefix) 86 | if prefix != nil && len(prefix.d.ToString()) > maxColumnLength { 87 | maxColumnLength = len(prefix.d.ToString()) 88 | } 89 | } 90 | 91 | result := "\n" 92 | for i, l := range ls { 93 | p := prefixes[i] 94 | if l.Record == nil { 95 | result += "\n" 96 | continue 97 | } 98 | result += func() string { 99 | if p == nil { 100 | return strings.Repeat(" ", maxColumnLength+1) 101 | } 102 | length := len(p.d.ToString()) 103 | value := "" 104 | if p.isSub { 105 | value += styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(p.d.ToString()) 106 | } else { 107 | value += styler.Props(tf.StyleProps{IsUnderlined: true}).Format(p.d.ToString()) 108 | } 109 | return strings.Repeat(" ", maxColumnLength-length+1) + value 110 | }() 111 | result += " | " 112 | result += l.Text 113 | result += "\n" 114 | } 115 | return result 116 | } 117 | -------------------------------------------------------------------------------- /klog/parser/error.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "github.com/jotaen/klog/klog/parser/txt" 4 | 5 | type HumanError struct { 6 | code string 7 | title string 8 | details string 9 | } 10 | 11 | func (e HumanError) New(b txt.Block, line int, start int, length int) txt.Error { 12 | return txt.NewError(b, line, start, length, e.code, e.title, e.details) 13 | } 14 | 15 | func ErrorInvalidDate() HumanError { 16 | return HumanError{ 17 | "ErrorInvalidDate", 18 | "Invalid date", 19 | "Please make sure that the date format is either YYYY-MM-DD or YYYY/MM/DD, " + 20 | "and that its value represents a valid day in the calendar.", 21 | } 22 | } 23 | 24 | func ErrorIllegalIndentation() HumanError { 25 | return HumanError{ 26 | "ErrorIllegalIndentation", 27 | "Unexpected indentation", 28 | "Please correct the indentation of this line. Indentation must be 2-4 spaces or one tab. " + 29 | "You cannot mix different indentation styles within the same record.", 30 | } 31 | } 32 | 33 | func ErrorMalformedShouldTotal() HumanError { 34 | return HumanError{ 35 | "ErrorMalformedShouldTotal", 36 | "Malformed should-total time", 37 | "Please review the syntax of the should-total time. " + 38 | "Valid examples for it would be: (8h!) or (4h30m!) or (45m!)", 39 | } 40 | } 41 | 42 | func ErrorUnrecognisedProperty() HumanError { 43 | return HumanError{ 44 | "ErrorUnrecognisedProperty", 45 | "Unrecognised should-total value", 46 | "The highlighted value is not recognised. " + 47 | "The should-total must be a time duration suffixed with an " + 48 | "exclamation mark, e.g. 5h15m! or 8h!", 49 | } 50 | } 51 | 52 | func ErrorMalformedPropertiesSyntax() HumanError { 53 | return HumanError{ 54 | "ErrorMalformedPropertiesSyntax", 55 | "Malformed should-total time", 56 | "The should-total cannot be empty and it must be " + 57 | "surrounded by parenthesis on both sides", 58 | } 59 | } 60 | 61 | func ErrorUnrecognisedTextInHeadline() HumanError { 62 | return HumanError{ 63 | "ErrorUnrecognisedTextInHeadline", 64 | "Malformed headline", 65 | "The highlighted text in the headline is not recognised. " + 66 | "Please make sure to surround the should-total with parentheses, e.g.: (5h!) " + 67 | "You generally cannot put arbitrary text into the headline.", 68 | } 69 | } 70 | 71 | func ErrorMalformedSummary() HumanError { 72 | return HumanError{ 73 | "ErrorMalformedSummary", 74 | "Malformed summary", 75 | "Summary lines cannot start with blank characters, such as non-breaking spaces.", 76 | } 77 | } 78 | 79 | func ErrorMalformedEntry() HumanError { 80 | return HumanError{ 81 | "ErrorMalformedEntry", 82 | "Malformed entry", 83 | "Please review the syntax of the entry. " + 84 | "It must start with a duration or a time range. " + 85 | "Valid examples would be: 3h20m or 8:00-10:00 or 8:00-? " + 86 | "or <23:00-6:00 or 18:00-0:30>", 87 | } 88 | } 89 | 90 | func ErrorDuplicateOpenRange() HumanError { 91 | return HumanError{ 92 | "ErrorDuplicateOpenRange", 93 | "Duplicate entry", 94 | "Please make sure that there is only " + 95 | "one open (unclosed) time range in this record.", 96 | } 97 | } 98 | 99 | func ErrorIllegalRange() HumanError { 100 | return HumanError{ 101 | "ErrorIllegalRange", 102 | "Invalid date range", 103 | "Please make sure that both time values appear in chronological order. " + 104 | "If you want a time to be associated with an adjacent day you can use angle brackets " + 105 | "to shift the time by one day: <23:00-6:00 or 18:00-0:30>", 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /klog/range.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // Range represents the period of time between two points of time. 9 | type Range interface { 10 | Start() Time 11 | End() Time 12 | Duration() Duration 13 | 14 | // ToString serialises the range, e.g. `13:15 - 17:23`. 15 | ToString() string 16 | 17 | // Format returns the current formatting. 18 | Format() RangeFormat 19 | } 20 | 21 | // OpenRange represents a range that has not ended yet. 22 | type OpenRange interface { 23 | Start() Time 24 | 25 | // ToString serialises the open range, e.g. `9:00 - ?`. 26 | ToString() string 27 | 28 | // Format returns the current formatting. 29 | Format() OpenRangeFormat 30 | } 31 | 32 | // RangeFormat contains the formatting options for a Range. 33 | type RangeFormat struct { 34 | UseSpacesAroundDash bool 35 | } 36 | 37 | // DefaultRangeFormat returns the canonical time range format, as recommended by the spec. 38 | func DefaultRangeFormat() RangeFormat { 39 | return RangeFormat{ 40 | UseSpacesAroundDash: true, 41 | } 42 | } 43 | 44 | // OpenRangeFormat contains the formatting options for an OpenRange. 45 | type OpenRangeFormat struct { 46 | UseSpacesAroundDash bool 47 | AdditionalPlaceholderChars int 48 | } 49 | 50 | // DefaultOpenRangeFormat returns the canonical open range format, as recommended by the spec. 51 | func DefaultOpenRangeFormat() OpenRangeFormat { 52 | return OpenRangeFormat{ 53 | UseSpacesAroundDash: DefaultRangeFormat().UseSpacesAroundDash, 54 | AdditionalPlaceholderChars: 0, 55 | } 56 | } 57 | 58 | func NewRange(start Time, end Time) (Range, error) { 59 | return NewRangeWithFormat(start, end, DefaultRangeFormat()) 60 | } 61 | 62 | func NewRangeWithFormat(start Time, end Time, format RangeFormat) (Range, error) { 63 | if !end.IsAfterOrEqual(start) { 64 | return nil, errors.New("ILLEGAL_RANGE") 65 | } 66 | return &timeRange{ 67 | start: start, 68 | end: end, 69 | format: format, 70 | }, nil 71 | } 72 | 73 | func NewOpenRange(start Time) OpenRange { 74 | return NewOpenRangeWithFormat(start, DefaultOpenRangeFormat()) 75 | } 76 | 77 | func NewOpenRangeWithFormat(start Time, format OpenRangeFormat) OpenRange { 78 | return &openRange{start: start, format: format} 79 | } 80 | 81 | type timeRange struct { 82 | start Time 83 | end Time 84 | format RangeFormat 85 | } 86 | 87 | type openRange struct { 88 | start Time 89 | format OpenRangeFormat 90 | } 91 | 92 | func (tr *timeRange) Start() Time { 93 | return tr.start 94 | } 95 | 96 | func (tr *timeRange) End() Time { 97 | return tr.end 98 | } 99 | 100 | func (tr *timeRange) Duration() Duration { 101 | start := tr.Start().MidnightOffset().InMinutes() 102 | end := tr.End().MidnightOffset().InMinutes() 103 | return NewDuration(0, end-start) 104 | } 105 | 106 | func (tr *timeRange) ToString() string { 107 | space := " " 108 | if !tr.format.UseSpacesAroundDash { 109 | space = "" 110 | } 111 | return tr.Start().ToString() + space + "-" + space + tr.End().ToString() 112 | } 113 | 114 | func (tr *timeRange) Format() RangeFormat { 115 | return tr.format 116 | } 117 | 118 | func (or *openRange) Start() Time { 119 | return or.start 120 | } 121 | 122 | func (or *openRange) ToString() string { 123 | space := " " 124 | if !or.format.UseSpacesAroundDash { 125 | space = "" 126 | } 127 | return or.Start().ToString() + space + "-" + space + strings.Repeat("?", 1+or.format.AdditionalPlaceholderChars) 128 | } 129 | 130 | func (or *openRange) Format() OpenRangeFormat { 131 | return or.format 132 | } 133 | -------------------------------------------------------------------------------- /klog/parser/txt/block.go: -------------------------------------------------------------------------------- 1 | package txt 2 | 3 | import "unicode/utf8" 4 | 5 | // Block is multiple consecutive lines with text, with no blank lines 6 | // in between, but possibly one or more blank lines before or after. 7 | // It’s basically like a paragraph of text, with surrounding whitespace. 8 | // The Block is guaranteed to contain exactly a single sequence of 9 | // significant lines, i.e. lines that contain text. 10 | type Block interface { 11 | // Lines returns all lines. 12 | Lines() []Line 13 | 14 | // SignificantLines returns the lines that are not blank. The two integers 15 | // are the number of insignificant lines at the beginning and the end. 16 | SignificantLines() (significant []Line, headCount int, tailCount int) 17 | 18 | // OverallLineIndex returns the overall line index, taking into 19 | // account the context of all preceding blocks. 20 | OverallLineIndex(int) int 21 | 22 | // SetPrecedingLineCount adjusts the overall line count. 23 | SetPrecedingLineCount(int) 24 | } 25 | 26 | type block struct { 27 | precedingLineCount int 28 | lines []Line 29 | } 30 | 31 | // ParseBlock parses a block from the beginning of a text. It returns 32 | // the parsed block, along with the number of bytes consumed from the 33 | // string. If the text doesn’t contain significant lines, it returns nil. 34 | func ParseBlock(text string, precedingLineCount int) (Block, int) { 35 | const ( 36 | MODE_PRECEDING_BLANK_LINES = iota 37 | MODE_SIGNIFICANT_LINES 38 | MODE_TRAILING_BLANK_LINES 39 | ) 40 | 41 | var lines []Line 42 | bytesConsumed := 0 43 | currentLineStart := 0 44 | currentMode := MODE_PRECEDING_BLANK_LINES 45 | _, lastRuneSize := utf8.DecodeLastRuneInString(text) 46 | 47 | // Parse text line-wise. 48 | parsingLoop: 49 | for i, char := range text { // Note: char is a UTF-8 rune 50 | if char != '\n' && i+lastRuneSize != len(text) { 51 | continue 52 | } 53 | 54 | // Process line. 55 | nextChar := i + len(string(char)) 56 | currentLine := text[currentLineStart:nextChar] 57 | line := NewLineFromString(currentLine) 58 | 59 | switch currentMode { 60 | case MODE_PRECEDING_BLANK_LINES: 61 | if !line.IsBlank() { 62 | currentMode = MODE_SIGNIFICANT_LINES 63 | } 64 | case MODE_SIGNIFICANT_LINES: 65 | if line.IsBlank() { 66 | currentMode = MODE_TRAILING_BLANK_LINES 67 | } 68 | case MODE_TRAILING_BLANK_LINES: 69 | if !line.IsBlank() { 70 | break parsingLoop 71 | } 72 | } 73 | lines = append(lines, line) 74 | bytesConsumed += len(currentLine) 75 | currentLineStart = nextChar 76 | } 77 | hasSignificantLines := currentMode != MODE_PRECEDING_BLANK_LINES 78 | if !hasSignificantLines { 79 | return nil, bytesConsumed 80 | } 81 | return &block{precedingLineCount, lines}, bytesConsumed 82 | } 83 | 84 | func (b *block) OverallLineIndex(lineIndex int) int { 85 | return b.precedingLineCount + lineIndex 86 | } 87 | 88 | func (b *block) SetPrecedingLineCount(count int) { 89 | b.precedingLineCount = count 90 | } 91 | 92 | func (b *block) Lines() []Line { 93 | return b.lines 94 | } 95 | 96 | func (b *block) SignificantLines() (significant []Line, headCount int, tailCount int) { 97 | first, last := 0, len(b.lines) 98 | hasSeenSignificant := false 99 | for i, l := range b.lines { 100 | if !hasSeenSignificant && !l.IsBlank() { 101 | first = i 102 | hasSeenSignificant = true 103 | continue 104 | } 105 | if hasSeenSignificant && l.IsBlank() { 106 | last = i 107 | break 108 | } 109 | } 110 | significant = b.lines[first:last] 111 | return significant, first, len(b.lines) - last 112 | } 113 | -------------------------------------------------------------------------------- /klog/record.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package klog is the implementation of the domain logic of klog. 3 | It is essentially the code representation of the concepts as they are defined 4 | in the file format specification. 5 | */ 6 | package klog 7 | 8 | import ( 9 | "errors" 10 | ) 11 | 12 | // SPEC_VERSION contains the version number of the file format 13 | // specification which this implementation is based on. 14 | const SPEC_VERSION = "1.4" 15 | 16 | // Record is a self-contained data container that holds the time tracking 17 | // information associated with a certain date. 18 | type Record interface { 19 | Date() Date 20 | 21 | ShouldTotal() ShouldTotal 22 | SetShouldTotal(Duration) 23 | 24 | Summary() RecordSummary 25 | SetSummary(RecordSummary) 26 | 27 | // Entries returns a list of all entries that are associated with this record. 28 | Entries() []Entry 29 | 30 | // SetEntries associates new entries with the record. 31 | SetEntries([]Entry) 32 | AddDuration(Duration, EntrySummary) 33 | AddRange(Range, EntrySummary) 34 | 35 | // OpenRange returns the open time range, or `nil` if there is none. 36 | OpenRange() OpenRange 37 | 38 | // Start starts a new open time range. It returns an error if there is 39 | // already an open time range present. (There can only be one per record.) 40 | Start(OpenRange, EntrySummary) error 41 | 42 | // EndOpenRange ends the open time range. It returns an error if there is 43 | // no open time range present, or if start and end time cannot be converted 44 | // into a valid time range. 45 | EndOpenRange(Time) error 46 | } 47 | 48 | func NewRecord(date Date) Record { 49 | return &record{ 50 | date: date, 51 | } 52 | } 53 | 54 | type record struct { 55 | date Date 56 | shouldTotal ShouldTotal 57 | summary RecordSummary 58 | entries []Entry 59 | } 60 | 61 | func (r *record) Date() Date { 62 | return r.date 63 | } 64 | 65 | func (r *record) ShouldTotal() ShouldTotal { 66 | if r.shouldTotal == nil { 67 | return NewDuration(0, 0) 68 | } 69 | return r.shouldTotal 70 | } 71 | 72 | func (r *record) SetShouldTotal(t Duration) { 73 | r.shouldTotal = NewShouldTotal(0, t.InMinutes()) 74 | } 75 | 76 | func (r *record) Summary() RecordSummary { 77 | return r.summary 78 | } 79 | 80 | func (r *record) SetSummary(summary RecordSummary) { 81 | r.summary = summary 82 | } 83 | 84 | func (r *record) Entries() []Entry { 85 | return r.entries 86 | } 87 | 88 | func (r *record) SetEntries(es []Entry) { 89 | r.entries = es 90 | } 91 | 92 | func (r *record) AddDuration(d Duration, s EntrySummary) { 93 | r.entries = append(r.entries, NewEntryFromDuration(d, s)) 94 | } 95 | 96 | func (r *record) AddRange(tr Range, s EntrySummary) { 97 | r.entries = append(r.entries, NewEntryFromRange(tr, s)) 98 | } 99 | 100 | func (r *record) OpenRange() OpenRange { 101 | for _, e := range r.entries { 102 | t, isOpenRange := e.value.(*openRange) 103 | if isOpenRange { 104 | return t 105 | } 106 | } 107 | return nil 108 | } 109 | 110 | func (r *record) Start(or OpenRange, s EntrySummary) error { 111 | if r.OpenRange() != nil { 112 | return errors.New("DUPLICATE_OPEN_RANGE") 113 | } 114 | r.entries = append(r.entries, NewEntryFromOpenRange(or, s)) 115 | return nil 116 | } 117 | 118 | func (r *record) EndOpenRange(end Time) error { 119 | for i, e := range r.entries { 120 | t, isOpenRange := e.value.(*openRange) 121 | if isOpenRange { 122 | tr, err := NewRange(t.Start(), end) 123 | if err != nil { 124 | return err 125 | } 126 | r.entries[i] = NewEntryFromRange(tr, e.summary) 127 | return nil 128 | } 129 | } 130 | return errors.New("NO_OPEN_RANGE") 131 | } 132 | -------------------------------------------------------------------------------- /klog/tag.go: -------------------------------------------------------------------------------- 1 | package klog 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var HashTagPattern = regexp.MustCompile(`#([\p{L}\d_-]+)(=(("[^"]*")|('[^']*')|([\p{L}\d_-]*)))?`) 10 | var unquotedValuePattern = regexp.MustCompile(`^[\p{L}\d_-]+$`) 11 | 12 | type Tag struct { 13 | name string 14 | value string 15 | } 16 | 17 | func NewTagFromString(tag string) (Tag, error) { 18 | if !strings.HasPrefix(tag, "#") { 19 | tag = "#" + tag 20 | } 21 | match := HashTagPattern.FindStringSubmatch(tag) 22 | if match == nil { 23 | // The tag pattern didn’t match at all. 24 | return Tag{}, errors.New("INVALID_TAG") 25 | } 26 | name := match[1] 27 | value := func() string { 28 | v := match[3] 29 | if strings.HasPrefix(v, `"`) { 30 | return strings.Trim(v, `"`) 31 | } 32 | if strings.HasPrefix(v, `'`) { 33 | return strings.Trim(v, `'`) 34 | } 35 | return v 36 | }() 37 | if len(match[0]) != len(tag) { 38 | // The original tag contains more/other characters. 39 | return Tag{}, errors.New("INVALID_TAG") 40 | } 41 | return NewTagOrPanic(name, value), nil 42 | } 43 | 44 | // NewTagOrPanic constructs a new tag but will panic if the 45 | // parameters don’t yield a valid tag. 46 | func NewTagOrPanic(name string, value string) Tag { 47 | if strings.Contains(value, "\"") && strings.Contains(value, "'") { 48 | // A tag value can never contain both ' and " at the same time. 49 | panic("Invalid tag") 50 | } 51 | return Tag{strings.ToLower(name), value} 52 | } 53 | 54 | func (t Tag) Name() string { 55 | return t.name 56 | } 57 | 58 | func (t Tag) Value() string { 59 | return t.value 60 | } 61 | 62 | func (t Tag) ToString() string { 63 | result := "#" + t.name 64 | if t.value != "" { 65 | result += "=" 66 | quotation := "" 67 | if !unquotedValuePattern.MatchString(t.value) { 68 | if strings.Contains(t.value, `"`) { 69 | quotation = `'` 70 | } else { 71 | quotation = "\"" 72 | } 73 | } 74 | result += quotation + t.value + quotation 75 | } 76 | return result 77 | } 78 | 79 | type TagSet struct { 80 | lookup map[Tag]bool 81 | original []Tag 82 | } 83 | 84 | func NewEmptyTagSet() TagSet { 85 | return TagSet{ 86 | lookup: make(map[Tag]bool), 87 | original: []Tag{}, 88 | } 89 | } 90 | 91 | // Put inserts the tag into the TagSet. 92 | func (ts *TagSet) Put(tag Tag) { 93 | ts.lookup[tag] = true 94 | ts.lookup[NewTagOrPanic(tag.Name(), "")] = true 95 | ts.original = append(ts.original, tag) 96 | } 97 | 98 | // Contains checks whether the TagSet contains the given tag. 99 | // Note that if the TagSet contains a tag with value, then this 100 | // will always yield a match against the base tag (without value). 101 | func (ts *TagSet) Contains(tag Tag) bool { 102 | return ts.lookup[tag] 103 | } 104 | 105 | // IsEmpty checks whether the TagSet contains something or not. 106 | func (ts *TagSet) IsEmpty() bool { 107 | return len(ts.lookup) == 0 108 | } 109 | 110 | // ForLookup returns a denormalised and unordered representation 111 | // of the TagSet. 112 | func (ts *TagSet) ForLookup() map[Tag]bool { 113 | return ts.lookup 114 | } 115 | 116 | // ToStrings returns the tags as string, in their original order 117 | // and without deduplication or normalisation. 118 | func (ts *TagSet) ToStrings() []string { 119 | tags := make([]string, len(ts.original)) 120 | for i, t := range ts.original { 121 | tags[i] = t.ToString() 122 | } 123 | return tags 124 | } 125 | 126 | // Merge combines multiple tag sets into a new one. 127 | func Merge(tagSets ...*TagSet) TagSet { 128 | result := NewEmptyTagSet() 129 | for _, ts := range tagSets { 130 | for t := range ts.lookup { 131 | result.Put(t) 132 | } 133 | } 134 | return result 135 | } 136 | -------------------------------------------------------------------------------- /klog/parser/json/serialiser.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package json contains the logic of serialising Record’s as JSON. 3 | */ 4 | package json 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "github.com/jotaen/klog/klog" 10 | "github.com/jotaen/klog/klog/parser" 11 | "github.com/jotaen/klog/klog/parser/txt" 12 | "github.com/jotaen/klog/klog/service" 13 | "sort" 14 | "strings" 15 | ) 16 | 17 | // ToJson serialises records into their JSON representation. The output 18 | // structure is RecordView at the top level. 19 | func ToJson(rs []klog.Record, errs []txt.Error, prettyPrint bool) string { 20 | envelop := func() Envelop { 21 | if errs == nil { 22 | return Envelop{ 23 | Records: toRecordViews(rs), 24 | Errors: nil, 25 | } 26 | } else { 27 | return Envelop{ 28 | Records: nil, 29 | Errors: toErrorViews(errs), 30 | } 31 | } 32 | }() 33 | buffer := new(bytes.Buffer) 34 | enc := json.NewEncoder(buffer) 35 | if prettyPrint { 36 | enc.SetIndent("", " ") 37 | } 38 | enc.SetEscapeHTML(false) 39 | err := enc.Encode(&envelop) 40 | if err != nil { 41 | panic(err) // This should never happen 42 | } 43 | return strings.TrimRight(buffer.String(), "\n") 44 | } 45 | 46 | func toRecordViews(rs []klog.Record) []RecordView { 47 | result := []RecordView{} 48 | for _, r := range rs { 49 | total := service.Total(r) 50 | should := r.ShouldTotal() 51 | diff := service.Diff(should, total) 52 | v := RecordView{ 53 | Date: r.Date().ToString(), 54 | Summary: parser.SummaryText(r.Summary()).ToString(), 55 | Total: total.ToString(), 56 | TotalMins: total.InMinutes(), 57 | ShouldTotal: should.ToString(), 58 | ShouldTotalMins: should.InMinutes(), 59 | Diff: diff.ToStringWithSign(), 60 | DiffMins: diff.InMinutes(), 61 | Tags: toTagViews(r.Summary().Tags()), 62 | Entries: toEntryViews(r.Entries()), 63 | } 64 | result = append(result, v) 65 | } 66 | return result 67 | } 68 | 69 | func toTagViews(ts *klog.TagSet) []string { 70 | result := ts.ToStrings() 71 | if result == nil { 72 | return []string{} 73 | } 74 | sort.Slice(result, func(i, j int) bool { 75 | return result[i] < result[j] 76 | }) 77 | return result 78 | } 79 | 80 | func toEntryViews(es []klog.Entry) []any { 81 | views := []any{} 82 | for _, e := range es { 83 | base := EntryView{ 84 | Summary: parser.SummaryText(e.Summary()).ToString(), 85 | Tags: toTagViews(e.Summary().Tags()), 86 | Total: e.Duration().ToString(), 87 | TotalMins: e.Duration().InMinutes(), 88 | } 89 | view := klog.Unbox(&e, func(r klog.Range) any { 90 | base.Type = "range" 91 | return RangeView{ 92 | OpenRangeView: OpenRangeView{ 93 | EntryView: base, 94 | Start: r.Start().ToString(), 95 | StartMins: r.Start().MidnightOffset().InMinutes(), 96 | }, 97 | End: r.End().ToString(), 98 | EndMins: r.End().MidnightOffset().InMinutes(), 99 | } 100 | }, func(d klog.Duration) any { 101 | base.Type = "duration" 102 | return base 103 | }, func(o klog.OpenRange) any { 104 | base.Type = "open_range" 105 | return OpenRangeView{ 106 | EntryView: base, 107 | Start: o.Start().ToString(), 108 | StartMins: o.Start().MidnightOffset().InMinutes(), 109 | } 110 | }) 111 | views = append(views, view) 112 | } 113 | return views 114 | } 115 | 116 | func toErrorViews(errs []txt.Error) []ErrorView { 117 | var result []ErrorView 118 | for _, e := range errs { 119 | result = append(result, ErrorView{ 120 | Line: e.LineNumber(), 121 | Column: e.Column(), 122 | Length: e.Length(), 123 | Title: e.Title(), 124 | Details: e.Details(), 125 | File: e.Origin(), 126 | }) 127 | } 128 | return result 129 | } 130 | -------------------------------------------------------------------------------- /klog/service/query.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | gosort "sort" 6 | ) 7 | 8 | type EntryType string 9 | 10 | const ( 11 | ENTRY_TYPE_DURATION = EntryType("DURATION") 12 | ENTRY_TYPE_POSITIVE_DURATION = EntryType("DURATION_POSITIVE") 13 | ENTRY_TYPE_NEGATIVE_DURATION = EntryType("DURATION_NEGATIVE") 14 | ENTRY_TYPE_RANGE = EntryType("RANGE") 15 | ENTRY_TYPE_OPEN_RANGE = EntryType("OPEN_RANGE") 16 | ) 17 | 18 | // FilterQry represents the filter clauses of a query. 19 | type FilterQry struct { 20 | Tags []klog.Tag 21 | BeforeOrEqual klog.Date 22 | AfterOrEqual klog.Date 23 | AtDate klog.Date 24 | EntryType EntryType 25 | } 26 | 27 | // Filter returns all records the matches the query. 28 | // A matching record must satisfy *all* query clauses. 29 | func Filter(rs []klog.Record, o FilterQry) []klog.Record { 30 | var records []klog.Record 31 | for _, r := range rs { 32 | if o.AtDate != nil && !o.AtDate.IsEqualTo(r.Date()) { 33 | continue 34 | } 35 | if o.BeforeOrEqual != nil && !o.BeforeOrEqual.IsAfterOrEqual(r.Date()) { 36 | continue 37 | } 38 | if o.AfterOrEqual != nil && !r.Date().IsAfterOrEqual(o.AfterOrEqual) { 39 | continue 40 | } 41 | if len(o.Tags) > 0 { 42 | reducedR, hasMatched := reduceRecordToMatchingTags(o.Tags, r) 43 | if !hasMatched { 44 | continue 45 | } 46 | r = reducedR 47 | } 48 | if o.EntryType != "" { 49 | reducedR, hasMatched := reduceRecordToMatchingEntryTypes(o.EntryType, r) 50 | if !hasMatched { 51 | continue 52 | } 53 | r = reducedR 54 | } 55 | records = append(records, r) 56 | } 57 | return records 58 | } 59 | 60 | // Sort orders the records by date. 61 | func Sort(rs []klog.Record, startWithOldest bool) []klog.Record { 62 | sorted := append([]klog.Record(nil), rs...) 63 | gosort.Slice(sorted, func(i, j int) bool { 64 | isLess := sorted[j].Date().IsAfterOrEqual(sorted[i].Date()) 65 | if !startWithOldest { 66 | return !isLess 67 | } 68 | return isLess 69 | }) 70 | return sorted 71 | } 72 | 73 | func reduceRecordToMatchingTags(queriedTags []klog.Tag, r klog.Record) (klog.Record, bool) { 74 | if isSubsetOf(queriedTags, r.Summary().Tags()) { 75 | return r, true 76 | } 77 | var matchingEntries []klog.Entry 78 | for _, e := range r.Entries() { 79 | allTags := klog.Merge(r.Summary().Tags(), e.Summary().Tags()) 80 | if isSubsetOf(queriedTags, &allTags) { 81 | matchingEntries = append(matchingEntries, e) 82 | } 83 | } 84 | if len(matchingEntries) == 0 { 85 | return nil, false 86 | } 87 | r.SetEntries(matchingEntries) 88 | return r, true 89 | } 90 | 91 | func reduceRecordToMatchingEntryTypes(t EntryType, r klog.Record) (klog.Record, bool) { 92 | var matchingEntries []klog.Entry 93 | for _, e := range r.Entries() { 94 | isMatch := klog.Unbox(&e, func(r klog.Range) bool { 95 | return t == ENTRY_TYPE_RANGE 96 | }, func(duration klog.Duration) bool { 97 | if t == ENTRY_TYPE_DURATION { 98 | return true 99 | } else if t == ENTRY_TYPE_POSITIVE_DURATION && e.Duration().InMinutes() >= 0 { 100 | return true 101 | } else if t == ENTRY_TYPE_NEGATIVE_DURATION && e.Duration().InMinutes() < 0 { 102 | return true 103 | } 104 | return false 105 | }, func(openRange klog.OpenRange) bool { 106 | return t == ENTRY_TYPE_OPEN_RANGE 107 | }) 108 | if isMatch { 109 | matchingEntries = append(matchingEntries, e) 110 | } 111 | } 112 | if len(matchingEntries) == 0 { 113 | return nil, false 114 | } 115 | r.SetEntries(matchingEntries) 116 | return r, true 117 | } 118 | 119 | func isSubsetOf(queriedTags []klog.Tag, allTags *klog.TagSet) bool { 120 | for _, t := range queriedTags { 121 | if !allTags.Contains(t) { 122 | return false 123 | } 124 | } 125 | return true 126 | } 127 | -------------------------------------------------------------------------------- /klog/parser/json/serialiser_test.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/parser" 6 | "github.com/jotaen/klog/klog/parser/txt" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | ) 10 | 11 | func TestSerialiseEmptyRecords(t *testing.T) { 12 | json := ToJson([]klog.Record{}, nil, false) 13 | assert.Equal(t, `{"records":[],"errors":null}`, json) 14 | } 15 | 16 | func TestSerialiseEmptyArrayIfNoErrors(t *testing.T) { 17 | json := ToJson(nil, nil, false) 18 | assert.Equal(t, `{"records":[],"errors":null}`, json) 19 | } 20 | 21 | func TestSerialisePrettyPrinted(t *testing.T) { 22 | json := ToJson(nil, nil, true) 23 | assert.Equal(t, `{ 24 | "records": [], 25 | "errors": null 26 | }`, json) 27 | } 28 | 29 | func TestSerialiseMinimalRecord(t *testing.T) { 30 | json := ToJson(func() []klog.Record { 31 | r := klog.NewRecord(klog.Ɀ_Date_(2000, 12, 31)) 32 | return []klog.Record{r} 33 | }(), nil, false) 34 | assert.Equal(t, `{"records":[{`+ 35 | `"date":"2000-12-31",`+ 36 | `"summary":"",`+ 37 | `"total":"0m",`+ 38 | `"total_mins":0,`+ 39 | `"should_total":"0m",`+ 40 | `"should_total_mins":0,`+ 41 | `"diff":"0m",`+ 42 | `"diff_mins":0,`+ 43 | `"tags":[],`+ 44 | `"entries":[]`+ 45 | `}],"errors":null}`, json) 46 | } 47 | 48 | func TestSerialiseFullBlownRecord(t *testing.T) { 49 | json := ToJson(func() []klog.Record { 50 | r := klog.NewRecord(klog.Ɀ_Date_(2000, 12, 31)) 51 | r.SetSummary(klog.Ɀ_RecordSummary_("Hello #World", "What’s up?")) 52 | r.SetShouldTotal(klog.NewDuration(7, 30)) 53 | r.AddDuration(klog.NewDuration(2, 3), klog.Ɀ_EntrySummary_("#some #thing")) 54 | r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 44), klog.Ɀ_Time_(5, 23)), nil) 55 | r.Start(klog.NewOpenRange(klog.Ɀ_TimeTomorrow_(0, 28)), klog.Ɀ_EntrySummary_("Started #todo=nr4", "still on #it")) 56 | return []klog.Record{r} 57 | }(), nil, false) 58 | assert.Equal(t, `{"records":[{`+ 59 | `"date":"2000-12-31",`+ 60 | `"summary":"Hello #World\nWhat’s up?",`+ 61 | `"total":"7h42m",`+ 62 | `"total_mins":462,`+ 63 | `"should_total":"7h30m!",`+ 64 | `"should_total_mins":450,`+ 65 | `"diff":"+12m",`+ 66 | `"diff_mins":12,`+ 67 | `"tags":["#world"],`+ 68 | `"entries":[{`+ 69 | `"type":"duration",`+ 70 | `"summary":"#some #thing",`+ 71 | `"tags":["#some","#thing"],`+ 72 | `"total":"2h3m",`+ 73 | `"total_mins":123`+ 74 | `},{`+ 75 | `"type":"range",`+ 76 | `"summary":"",`+ 77 | `"tags":[],`+ 78 | `"total":"5h39m",`+ 79 | `"total_mins":339,`+ 80 | `"start":"<23:44",`+ 81 | `"start_mins":-16,`+ 82 | `"end":"5:23",`+ 83 | `"end_mins":323`+ 84 | `},{`+ 85 | `"type":"open_range",`+ 86 | `"summary":"Started #todo=nr4\nstill on #it",`+ 87 | `"tags":["#it","#todo=nr4"],`+ 88 | `"total":"0m",`+ 89 | `"total_mins":0,`+ 90 | `"start":"0:28>",`+ 91 | `"start_mins":1468`+ 92 | `}]`+ 93 | `}],"errors":null}`, json) 94 | } 95 | 96 | func TestSerialiseParserErrors(t *testing.T) { 97 | block, _ := txt.ParseBlock("2018-99-99\n asdf", 6) 98 | json := ToJson(nil, []txt.Error{ 99 | parser.ErrorInvalidDate().New(block, 0, 0, 10), 100 | parser.ErrorMalformedSummary().New(block, 1, 3, 5).SetOrigin("/a/b/c/file.klg"), 101 | }, false) 102 | assert.Equal(t, `{"records":null,"errors":[{`+ 103 | `"line":7,`+ 104 | `"column":1,`+ 105 | `"length":10,`+ 106 | `"title":"Invalid date",`+ 107 | `"details":"Please make sure that the date format is either YYYY-MM-DD or YYYY/MM/DD, and that its value represents a valid day in the calendar.",`+ 108 | `"file":""`+ 109 | `},{`+ 110 | `"line":8,`+ 111 | `"column":4,`+ 112 | `"length":5,`+ 113 | `"title":"Malformed summary",`+ 114 | `"details":"Summary lines cannot start with blank characters, such as non-breaking spaces.",`+ 115 | `"file":"/a/b/c/file.klg"`+ 116 | `}]}`, json) 117 | } 118 | -------------------------------------------------------------------------------- /klog/service/period/week_test.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestWeekPeriod(t *testing.T) { 11 | for _, x := range []struct { 12 | actual Period 13 | expected Period 14 | }{ 15 | // Range in same month 16 | {NewWeekFromDate(klog.Ɀ_Date_(1987, 5, 19)).Period(), NewPeriod(klog.Ɀ_Date_(1987, 5, 18), klog.Ɀ_Date_(1987, 5, 24))}, 17 | {NewWeekFromDate(klog.Ɀ_Date_(2004, 12, 16)).Period(), NewPeriod(klog.Ɀ_Date_(2004, 12, 13), klog.Ɀ_Date_(2004, 12, 19))}, 18 | 19 | // Range across months 20 | {NewWeekFromDate(klog.Ɀ_Date_(1983, 6, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1983, 5, 30), klog.Ɀ_Date_(1983, 6, 5))}, 21 | {NewWeekFromDate(klog.Ɀ_Date_(1998, 10, 27)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 26), klog.Ɀ_Date_(1998, 11, 1))}, 22 | 23 | // Range across years 24 | {NewWeekFromDate(klog.Ɀ_Date_(2009, 1, 2)).Period(), NewPeriod(klog.Ɀ_Date_(2008, 12, 29), klog.Ɀ_Date_(2009, 1, 4))}, 25 | {NewWeekFromDate(klog.Ɀ_Date_(2009, 12, 30)).Period(), NewPeriod(klog.Ɀ_Date_(2009, 12, 28), klog.Ɀ_Date_(2010, 1, 3))}, 26 | 27 | // Since is same as original date 28 | {NewWeekFromDate(klog.Ɀ_Date_(1998, 10, 26)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 26), klog.Ɀ_Date_(1998, 11, 1))}, 29 | 30 | // Until is same as original date 31 | {NewWeekFromDate(klog.Ɀ_Date_(1998, 11, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 26), klog.Ɀ_Date_(1998, 11, 1))}, 32 | } { 33 | assert.Equal(t, x.expected, x.actual) 34 | } 35 | } 36 | 37 | func TestParseValidWeek(t *testing.T) { 38 | for _, x := range []struct { 39 | text string 40 | expect Period 41 | }{ 42 | {"2022-W01", NewPeriod(klog.Ɀ_Date_(2022, 1, 3), klog.Ɀ_Date_(2022, 1, 9))}, 43 | {"2022-W1", NewPeriod(klog.Ɀ_Date_(2022, 1, 3), klog.Ɀ_Date_(2022, 1, 9))}, 44 | {"2017-W26", NewPeriod(klog.Ɀ_Date_(2017, 6, 26), klog.Ɀ_Date_(2017, 7, 2))}, 45 | {"2017-W27", NewPeriod(klog.Ɀ_Date_(2017, 7, 3), klog.Ɀ_Date_(2017, 7, 9))}, 46 | {"2012-W09", NewPeriod(klog.Ɀ_Date_(2012, 2, 27), klog.Ɀ_Date_(2012, 3, 4))}, 47 | {"2022-W02", NewPeriod(klog.Ɀ_Date_(2022, 1, 10), klog.Ɀ_Date_(2022, 1, 16))}, 48 | {"2022-W52", NewPeriod(klog.Ɀ_Date_(2022, 12, 26), klog.Ɀ_Date_(2023, 1, 1))}, 49 | {"2025-W01", NewPeriod(klog.Ɀ_Date_(2024, 12, 30), klog.Ɀ_Date_(2025, 1, 5))}, 50 | } { 51 | week, err := NewWeekFromString(x.text) 52 | require.Nil(t, err) 53 | assert.True(t, x.expect.Since().IsEqualTo(week.Period().Since()), x.text) 54 | assert.True(t, x.expect.Until().IsEqualTo(week.Period().Until())) 55 | } 56 | } 57 | 58 | func TestParseRejectsInvalidWeekString(t *testing.T) { 59 | for _, x := range []string{ 60 | "2000-W00", 61 | "2000-W-1", 62 | "2000-W001", 63 | "2000-W54", 64 | "2000-W", 65 | "2000-w14", 66 | "2000-w14", 67 | "2000-asdf", 68 | "12873612-W02", 69 | } { 70 | _, err := NewWeekFromString(x) 71 | require.Error(t, err) 72 | } 73 | } 74 | 75 | func TestWeekPreviousWeek(t *testing.T) { 76 | for _, x := range []struct { 77 | initial Week 78 | expected Period 79 | }{ 80 | // Same month 81 | {NewWeekFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 5, 11), klog.Ɀ_Date_(1987, 5, 17))}, 82 | 83 | // `Since` in other month 84 | {NewWeekFromDate(klog.Ɀ_Date_(2014, 8, 6)), NewPeriod(klog.Ɀ_Date_(2014, 7, 28), klog.Ɀ_Date_(2014, 8, 3))}, 85 | 86 | // `Since`&`Until` in other month 87 | {NewWeekFromDate(klog.Ɀ_Date_(2014, 8, 2)), NewPeriod(klog.Ɀ_Date_(2014, 7, 21), klog.Ɀ_Date_(2014, 7, 27))}, 88 | 89 | // `Since` in other year 90 | {NewWeekFromDate(klog.Ɀ_Date_(2014, 1, 9)), NewPeriod(klog.Ɀ_Date_(2013, 12, 30), klog.Ɀ_Date_(2014, 1, 5))}, 91 | 92 | // `Since`&`Until` in other year 93 | {NewWeekFromDate(klog.Ɀ_Date_(2029, 1, 2)), NewPeriod(klog.Ɀ_Date_(2028, 12, 25), klog.Ɀ_Date_(2028, 12, 31))}, 94 | } { 95 | previous := x.initial.Previous().Period() 96 | assert.Equal(t, x.expected, previous) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= 2 | cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= 3 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 4 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= 6 | github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 7 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 13 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 14 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 15 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 16 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 17 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 18 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 19 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 20 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 21 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 22 | github.com/jotaen/genie v0.0.2 h1:arXxp5faTubDwRbKr6ZHHMaHSmo5cdMSfTIctbwF50s= 23 | github.com/jotaen/genie v0.0.2/go.mod h1:5v0pWbZ+yHWL8QIfTq1PSuUuoGQ1enQZ4XTtV/PnJos= 24 | github.com/jotaen/kong-completion v0.0.7 h1:l2UrG51q0gWD8Ph0CykBvGOb1YlXDc789AQnWkq+U9M= 25 | github.com/jotaen/kong-completion v0.0.7/go.mod h1:dtitX9zCkffI5AON0IKsqHOFEEaL/S2AudgzJfCVFA4= 26 | github.com/jotaen/safemath v0.0.2 h1:jH9Bx0c9XmZylZD51Rs0Ojcs0jMcN636E2RgtTCUzOU= 27 | github.com/jotaen/safemath v0.0.2/go.mod h1:6DmgN+FzJxAYVAdKLR0KZl8i+gpj+7yD/bPdpi7qxfU= 28 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 29 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= 33 | github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 34 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= 35 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 38 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 39 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | -------------------------------------------------------------------------------- /klog/parser/reconciling/creator.go: -------------------------------------------------------------------------------- 1 | package reconciling 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/jotaen/klog/klog/parser/txt" 6 | ) 7 | 8 | // Creator is a function interface for creating a new reconciler. 9 | type Creator func([]klog.Record, []txt.Block) *Reconciler 10 | 11 | type AdditionalData struct { 12 | ShouldTotal klog.ShouldTotal 13 | Summary klog.RecordSummary 14 | } 15 | 16 | // NewReconcilerForNewRecord is a reconciler creator for a new record at a given date and 17 | // with the given parameters. 18 | func NewReconcilerForNewRecord(atDate klog.Date, format ReformatDirective[klog.DateFormat], ad AdditionalData) Creator { 19 | return func(rs []klog.Record, bs []txt.Block) *Reconciler { 20 | record := klog.NewRecord(atDate) 21 | if ad.ShouldTotal != nil { 22 | record.SetShouldTotal(ad.ShouldTotal) 23 | } 24 | if ad.Summary != nil { 25 | record.SetSummary(ad.Summary) 26 | } 27 | reconciler := &Reconciler{ 28 | Record: record, 29 | recordPointer: -1, 30 | lastLinePointer: -1, 31 | style: elect(*defaultStyle(), rs, bs), 32 | lines: flatten(bs), 33 | } 34 | dateValue := atDate.ToString() 35 | format.apply(reconciler.style.dateFormat(), func(f klog.DateFormat) { 36 | dateValue = atDate.ToStringWithFormat(f) 37 | }) 38 | recordText := func() []insertableText { 39 | result := dateValue 40 | if ad.ShouldTotal != nil { 41 | result += " (" + ad.ShouldTotal.ToString() + ")" 42 | } 43 | return []insertableText{{result, 0}} 44 | }() 45 | for _, s := range ad.Summary { 46 | recordText = append(recordText, insertableText{s, 0}) 47 | } 48 | newRecordLines, insertPointer, lastLineOffset, newRecordIndex := func() ([]insertableText, int, int, int) { 49 | if len(rs) == 0 { 50 | return recordText, 0, 1, 0 51 | } 52 | i := 0 53 | for _, r := range rs { 54 | if i == 0 && !atDate.IsAfterOrEqual(r.Date()) { 55 | // The new record is dated prior to the first one, so we have to append a blank line. 56 | recordText = append(recordText, blankLine) 57 | return recordText, 0, 1, 0 58 | } 59 | if len(rs)-1 == i || (atDate.IsAfterOrEqual(r.Date()) && !atDate.IsAfterOrEqual(rs[i+1].Date())) { 60 | // The record is in between. 61 | break 62 | } 63 | i++ 64 | } 65 | // The new record is dated after the last one, so we have to prepend a blank line. 66 | recordText = append([]insertableText{blankLine}, recordText...) 67 | return recordText, indexOfLastSignificantLine(bs[i]), 2, i + 1 68 | }() 69 | 70 | // Insert record and adjust pointers accordingly. 71 | reconciler.insert(insertPointer, newRecordLines) 72 | reconciler.lastLinePointer = insertPointer + lastLineOffset 73 | reconciler.recordPointer = newRecordIndex 74 | return reconciler 75 | } 76 | } 77 | 78 | // NewReconcilerAtRecord is a reconciler creator for an existing record at a given date. 79 | func NewReconcilerAtRecord(atDate klog.Date) Creator { 80 | return func(rs []klog.Record, bs []txt.Block) *Reconciler { 81 | index := -1 82 | for i, r := range rs { 83 | if r.Date().IsEqualTo(atDate) { 84 | index = i 85 | break 86 | } 87 | } 88 | if index == -1 { 89 | return nil 90 | } 91 | style := determine(rs[index], bs[index]) 92 | return &Reconciler{ 93 | Record: rs[index], 94 | style: elect(*style, rs, bs), 95 | lastLinePointer: indexOfLastSignificantLine(bs[index]), 96 | recordPointer: index, 97 | lines: flatten(bs), 98 | } 99 | } 100 | } 101 | 102 | func flatten(bs []txt.Block) []txt.Line { 103 | var result []txt.Line 104 | for _, b := range bs { 105 | result = append(result, b.Lines()...) 106 | } 107 | return result 108 | } 109 | 110 | func indexOfLastSignificantLine(block txt.Block) int { 111 | significantLines, precedingInsignificantLineCount, _ := block.SignificantLines() 112 | return block.OverallLineIndex(precedingInsignificantLineCount + len(significantLines)) 113 | } 114 | -------------------------------------------------------------------------------- /klog/service/period/month_test.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "github.com/jotaen/klog/klog" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestParseValidMonth(t *testing.T) { 11 | for _, x := range []struct { 12 | text string 13 | expect Period 14 | }{ 15 | {"0000-01", NewPeriod(klog.Ɀ_Date_(0, 1, 1), klog.Ɀ_Date_(0, 01, 31))}, 16 | {"0000-12", NewPeriod(klog.Ɀ_Date_(0, 12, 1), klog.Ɀ_Date_(0, 12, 31))}, 17 | {"0475-05", NewPeriod(klog.Ɀ_Date_(475, 5, 1), klog.Ɀ_Date_(475, 5, 31))}, 18 | {"2008-11", NewPeriod(klog.Ɀ_Date_(2008, 11, 1), klog.Ɀ_Date_(2008, 11, 30))}, 19 | {"8641-04", NewPeriod(klog.Ɀ_Date_(8641, 4, 1), klog.Ɀ_Date_(8641, 4, 30))}, 20 | {"9999-12", NewPeriod(klog.Ɀ_Date_(9999, 12, 1), klog.Ɀ_Date_(9999, 12, 31))}, 21 | } { 22 | month, err := NewMonthFromString(x.text) 23 | require.Nil(t, err) 24 | assert.True(t, x.expect.Since().IsEqualTo(month.Period().Since())) 25 | assert.True(t, x.expect.Until().IsEqualTo(month.Period().Until())) 26 | } 27 | } 28 | 29 | func TestMonthEnds(t *testing.T) { 30 | for _, x := range []struct { 31 | text string 32 | month int 33 | lastDay int 34 | }{ 35 | {"2018-01", 1, 31}, 36 | {"2018-02", 2, 28}, 37 | {"2018-03", 3, 31}, 38 | {"2018-04", 4, 30}, 39 | {"2018-05", 5, 31}, 40 | {"2018-06", 6, 30}, 41 | {"2018-07", 7, 31}, 42 | {"2018-08", 8, 31}, 43 | {"2018-09", 9, 30}, 44 | {"2018-10", 10, 31}, 45 | {"2018-11", 11, 30}, 46 | {"2018-12", 12, 31}, 47 | } { 48 | m, err := NewMonthFromString(x.text) 49 | require.Nil(t, err) 50 | p := m.Period() 51 | assert.Equal(t, p.Since(), klog.Ɀ_Date_(2018, x.month, 1)) 52 | assert.Equal(t, p.Until(), klog.Ɀ_Date_(2018, x.month, x.lastDay)) 53 | } 54 | } 55 | 56 | func TestParseMonthInLeapYear(t *testing.T) { 57 | m, _ := NewMonthFromString("2016-02") 58 | assert.Equal(t, m.Period().Until(), klog.Ɀ_Date_(2016, 2, 29)) 59 | } 60 | 61 | func TestRejectsInvalidMonth(t *testing.T) { 62 | for _, x := range []string{ 63 | "4000-00", 64 | "4000-13", 65 | "1833716-01", 66 | "2008-1", 67 | } { 68 | _, err := NewMonthFromString(x) 69 | require.Error(t, err) 70 | } 71 | } 72 | 73 | func TestRejectsMalformedMonth(t *testing.T) { 74 | for _, x := range []string{ 75 | "", 76 | "asdf", 77 | "2005", 78 | "2005_12", 79 | "2005--12", 80 | } { 81 | _, err := NewMonthFromString(x) 82 | require.Error(t, err) 83 | } 84 | } 85 | 86 | func TestMonthPeriod(t *testing.T) { 87 | for _, x := range []struct { 88 | actual Period 89 | expected Period 90 | }{ 91 | // Range in same year 92 | {NewMonthFromDate(klog.Ɀ_Date_(1987, 5, 19)).Period(), NewPeriod(klog.Ɀ_Date_(1987, 5, 1), klog.Ɀ_Date_(1987, 5, 31))}, 93 | {NewMonthFromDate(klog.Ɀ_Date_(2004, 11, 16)).Period(), NewPeriod(klog.Ɀ_Date_(2004, 11, 1), klog.Ɀ_Date_(2004, 11, 30))}, 94 | 95 | // Since is same as original date 96 | {NewMonthFromDate(klog.Ɀ_Date_(1998, 10, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 1), klog.Ɀ_Date_(1998, 10, 31))}, 97 | 98 | // Until is same as original date 99 | {NewMonthFromDate(klog.Ɀ_Date_(1998, 2, 28)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 2, 1), klog.Ɀ_Date_(1998, 2, 28))}, 100 | 101 | // Leap year 102 | {NewMonthFromDate(klog.Ɀ_Date_(2000, 2, 4)).Period(), NewPeriod(klog.Ɀ_Date_(2000, 2, 1), klog.Ɀ_Date_(2000, 2, 29))}, 103 | } { 104 | assert.Equal(t, x.expected, x.actual) 105 | } 106 | } 107 | 108 | func TestMonthPreviousMonth(t *testing.T) { 109 | for _, x := range []struct { 110 | initial Month 111 | expected Period 112 | }{ 113 | // In same year 114 | {NewMonthFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 4, 1), klog.Ɀ_Date_(1987, 4, 30))}, 115 | {NewMonthFromDate(klog.Ɀ_Date_(1987, 3, 31)), NewPeriod(klog.Ɀ_Date_(1987, 2, 1), klog.Ɀ_Date_(1987, 2, 28))}, 116 | {NewMonthFromDate(klog.Ɀ_Date_(1987, 3, 1)), NewPeriod(klog.Ɀ_Date_(1987, 2, 1), klog.Ɀ_Date_(1987, 2, 28))}, 117 | 118 | // In last year 119 | {NewMonthFromDate(klog.Ɀ_Date_(1987, 1, 19)), NewPeriod(klog.Ɀ_Date_(1986, 12, 1), klog.Ɀ_Date_(1986, 12, 31))}, 120 | } { 121 | previous := x.initial.Previous().Period() 122 | assert.Equal(t, x.expected, previous) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /klog/app/cli/util/prettifier.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/jotaen/klog/klog/app" 7 | tf "github.com/jotaen/klog/klog/app/cli/terminalformat" 8 | "github.com/jotaen/klog/klog/service" 9 | "strings" 10 | ) 11 | 12 | var Reflower = tf.NewReflower(80, "\n") 13 | 14 | // PrettifyAppError prints app errors including details. 15 | func PrettifyAppError(err app.Error, isDebug bool) error { 16 | message := "Error: " + err.Error() + "\n" 17 | message += Reflower.Reflow(err.Details(), nil) 18 | if isDebug && err.Original() != nil { 19 | message += "\n\nOriginal Error:\n" + err.Original().Error() 20 | } 21 | return errors.New(message) 22 | } 23 | 24 | // PrettifyParsingError turns a parsing error into a coloured and well-structured form. 25 | func PrettifyParsingError(err app.ParserErrors, styler tf.Styler) error { 26 | message := "" 27 | INDENT := " " 28 | for _, e := range err.All() { 29 | message += "\n" 30 | message += fmt.Sprintf( 31 | styler.Props(tf.StyleProps{Background: tf.RED, Color: tf.RED}).Format("[")+ 32 | styler.Props(tf.StyleProps{Background: tf.RED, Color: tf.TEXT_INVERSE}).Format("SYNTAX ERROR")+ 33 | styler.Props(tf.StyleProps{Background: tf.RED, Color: tf.RED}).Format("]")+ 34 | styler.Props(tf.StyleProps{Color: tf.RED}).Format(" in line %d"), 35 | e.LineNumber(), 36 | ) 37 | if e.Origin() != "" { 38 | message += fmt.Sprintf( 39 | styler.Props(tf.StyleProps{Color: tf.RED}).Format(" of file %s"), 40 | e.Origin(), 41 | ) 42 | } 43 | message += "\n" 44 | message += fmt.Sprintf( 45 | styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(INDENT+"%s"), 46 | // Replace all tabs with one space each, otherwise the carets might 47 | // not be in line with the text anymore (since we can’t know how wide 48 | // a tab is). 49 | strings.Replace(e.LineText(), "\t", " ", -1), 50 | ) + "\n" 51 | message += fmt.Sprintf( 52 | styler.Props(tf.StyleProps{Color: tf.RED}).Format(INDENT+"%s%s"), 53 | strings.Repeat(" ", e.Position()), strings.Repeat("^", e.Length()), 54 | ) + "\n" 55 | message += fmt.Sprintf( 56 | styler.Props(tf.StyleProps{Color: tf.YELLOW}).Format("%s"), 57 | Reflower.Reflow(e.Message(), []string{INDENT}), 58 | ) + "\n" 59 | } 60 | return errors.New(message) 61 | } 62 | 63 | // PrettifyWarning formats a warning about a record. 64 | func PrettifyWarning(w service.Warning, styler tf.Styler) string { 65 | return PrettifyGeneralWarning(w.Date().ToString()+": "+w.Warning(), styler) 66 | } 67 | 68 | // PrettifyGeneralWarning formats a general warning message. 69 | func PrettifyGeneralWarning(message string, styler tf.Styler) string { 70 | result := "" 71 | result += styler.Props(tf.StyleProps{Background: tf.YELLOW, Color: tf.YELLOW}).Format("[") 72 | result += styler.Props(tf.StyleProps{Background: tf.YELLOW, Color: tf.TEXT_INVERSE}).Format("WARNING") 73 | result += styler.Props(tf.StyleProps{Background: tf.YELLOW, Color: tf.YELLOW}).Format("]") 74 | result += " " 75 | result += styler.Props(tf.StyleProps{Color: tf.YELLOW}).Format(message) 76 | result += "\n" 77 | return result 78 | } 79 | 80 | // PrettyMonth returns the full english name of a month. 81 | func PrettyMonth(m int) string { 82 | switch m { 83 | case 1: 84 | return "January" 85 | case 2: 86 | return "February" 87 | case 3: 88 | return "March" 89 | case 4: 90 | return "April" 91 | case 5: 92 | return "May" 93 | case 6: 94 | return "June" 95 | case 7: 96 | return "July" 97 | case 8: 98 | return "August" 99 | case 9: 100 | return "September" 101 | case 10: 102 | return "October" 103 | case 11: 104 | return "November" 105 | case 12: 106 | return "December" 107 | } 108 | panic("Illegal month") // this can/should never happen 109 | } 110 | 111 | // PrettyDay returns the full english name of a weekday. 112 | func PrettyDay(d int) string { 113 | switch d { 114 | case 1: 115 | return "Monday" 116 | case 2: 117 | return "Tuesday" 118 | case 3: 119 | return "Wednesday" 120 | case 4: 121 | return "Thursday" 122 | case 5: 123 | return "Friday" 124 | case 6: 125 | return "Saturday" 126 | case 7: 127 | return "Sunday" 128 | } 129 | panic("Illegal weekday") // this can/should never happen 130 | } 131 | --------------------------------------------------------------------------------