├── .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 |
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 |
--------------------------------------------------------------------------------