File klog-6.6.obscpio of Package klog
07070100000000000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001100000000klog-6.6/.github07070100000001000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000002000000000klog-6.6/.github/ISSUE_TEMPLATE07070100000002000081A40000000000000000000000016863F92F00000273000000000000000000000000000000000000002700000000klog-6.6/.github/ISSUE_TEMPLATE/bug.md---
name: Bug
about: Report something that’s not working properly
title: ''
labels: BUG
assignees: ''
---
<!--
Dear bug reporter,
please describe your problem and include helpful information such as:
- Technical details, e.g. what klog version or operating system you use
- Sample data that demonstrates and reproduces the problem
- The command and input arguments that you used
- Other relevant configuration
PLEASE NOTE: For feature ideas, feedback, or questions, please create
a discussion at https://github.com/jotaen/klog/discussions
-->
07070100000003000081A40000000000000000000000016863F92F000000C7000000000000000000000000000000000000002B00000000klog-6.6/.github/ISSUE_TEMPLATE/config.ymlblank_issues_enabled: false
contact_links:
- name: Feature ideas, feedback, or questions
url: https://github.com/jotaen/klog/discussions
about: For all other things, please use Discussions
07070100000004000081A40000000000000000000000016863F92F00000915000000000000000000000000000000000000001E00000000klog-6.6/.github/benchmark.gopackage main
import (
"fmt"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser"
"math/rand"
"os"
"strconv"
"time"
)
var serialiser = app.NewSerialiser(tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR), false)
func main() {
// Setup
iterations, err := strconv.Atoi(os.Args[1])
if err != nil {
panic(err)
}
rand.Seed(int64(time.Now().Nanosecond()))
// Generate records
date := klog.Ɀ_Date_(0, 1, 1)
for i := 0; i < iterations; i++ {
if date.IsEqualTo(klog.Ɀ_Date_(9999, 12, 31)) {
// Prevent date overflow
date = klog.Ɀ_Date_(0, 1, 1)
}
date = date.PlusDays(1)
r := klog.NewRecord(date)
// Should total
if i%2 == ri(0, 2) {
r.SetShouldTotal(klog.NewDuration(ri(0, 23), ri(0, 59)))
}
// Summary
text := rt(0, 5)
if len(text) > 0 {
r.SetSummary(klog.Ɀ_RecordSummary_(text...))
}
// Entries
entriesCount := ri(1, 5)
for j := 0; j < entriesCount; j++ {
added := re()(&r)
if !added {
entriesCount++
}
}
fmt.Println(parser.SerialiseRecords(serialiser, r).ToString())
}
}
// ri = random integer
func ri(min int, max int) int {
return rand.Intn(max+1-min) + min
}
// rt = random texts
func rt(rowsMin int, rowsMax int) []string {
alphabet := "abcdefghijklmnopqrstuvwxyz"
texts := make([]string, ri(rowsMin, rowsMax))
for j := 0; j < len(texts); j++ {
bs := make([]byte, ri(1, 50))
for i := range bs {
bs[i] = alphabet[ri(0, len(alphabet)-1)]
}
texts[j] = string(bs)
}
return texts
}
// re = random entry
func re() func(r *klog.Record) bool {
text := rt(0, 2)
var entrySummary klog.EntrySummary
if len(text) > 0 {
entrySummary = klog.Ɀ_EntrySummary_(text...)
}
entryAdders := []func(r *klog.Record) bool{
func(r *klog.Record) bool {
(*r).AddDuration(klog.NewDuration(ri(-2, 23), ri(0, 60)), entrySummary)
return true
},
func(r *klog.Record) bool {
(*r).AddRange(klog.Ɀ_Range_(
klog.Ɀ_Time_(ri(0, 11), ri(0, 59)),
klog.Ɀ_Time_(ri(12, 23), ri(0, 59)),
), entrySummary)
return true
},
func(r *klog.Record) bool {
err := (*r).Start(klog.NewOpenRange(klog.Ɀ_Time_(ri(0, 23), ri(0, 59))), entrySummary)
return err == nil
},
}
return entryAdders[ri(0, len(entryAdders)-1)]
}
07070100000005000081ED0000000000000000000000016863F92F0000021F000000000000000000000000000000000000001E00000000klog-6.6/.github/benchmark.sh#!/bin/bash
set -e
declare -a samples=(1 10 100 1000 10000 100000)
ITERATIONS=3
# Ensure binary is all set.
klog > /dev/null
# Run benchmark.
TIMEFORMAT=%R
for size in "${samples[@]}"; do
printf "%8d: " $size
for _ in $(seq $ITERATIONS); do
# Generate new test data.
file="$(mktemp)"
go run benchmark.go "${size}" > "${file}"
# Disable warnings, as the generated data will trigger lots of them.
runtime=$( { time klog total --no-warn "${file}" > /dev/null; } 2>&1 )
printf "%ss " $runtime
done
echo
done
07070100000006000081A40000000000000000000000016863F92F0000038E000000000000000000000000000000000000002300000000klog-6.6/.github/install_darwin.md# Install klog
In order to install the downloaded klog binary on your system, follow these steps:
1. Make [MacOS “Gatekeeper”](https://support.apple.com/en-us/HT202491) trust the executable:
- Either right-click on the binary in the Finder, and select “Open“
- Or remove the “quarantine” flag from the binary via the CLI:
`xattr -d com.apple.quarantine klog`
2. Copy the executable to a location that’s included in your `$PATH` environment variable, e.g.
`mv klog /usr/local/bin/klog` (might require `sudo`)
For other install options, see [the documentation website](https://klog.jotaen.net/#get-klog).
## Check for updates
In order to not miss any updates you can either subscribe to the release
notifications on [Github](https://github.com/jotaen/klog) (at the top right:
“Watch” → “Custom” → “Releases”), or you occasionally check by running
`klog version`.
07070100000007000081A40000000000000000000000016863F92F0000024C000000000000000000000000000000000000002200000000klog-6.6/.github/install_linux.md# Install klog
In order to install the downloaded klog binary on your system, copy it
to a location that’s included in your `$PATH` environment variable, e.g.
`mv klog /usr/local/bin/klog` (might require `sudo`).
For other install options, see [the documentation website](https://klog.jotaen.net/#get-klog).
## Check for updates
In order to not miss any updates you can either subscribe to the release
notifications on [Github](https://github.com/jotaen/klog) (at the top right:
“Watch” → “Custom” → “Releases”), or you occasionally check by running
`klog version`.
07070100000008000081A40000000000000000000000016863F92F0000024D000000000000000000000000000000000000002400000000klog-6.6/.github/install_windows.md# Install klog
In order to install the downloaded klog binary on your system, copy it
to a location that’s included in your `PATH` environment variable, e.g.
`C:\Windows\System32` (might require admin privileges).
For other install options, see [the documentation website](https://klog.jotaen.net/#get-klog).
## Check for updates
In order to not miss any updates you can either subscribe to the release
notifications on [Github](https://github.com/jotaen/klog) (at the top right:
“Watch” → “Custom” → “Releases”), or you occasionally check by running
`klog version`.
07070100000009000081ED0000000000000000000000016863F92F00000429000000000000000000000000000000000000001F00000000klog-6.6/.github/smoke-test.sh#!/bin/bash
# Performs a test of the built binary. That includes:
# - Doing a brief smoke test to check that the binary can be invoked
# - Checking that all build-time information got compiled in correctly
set -e
echo 'Print help text...'
klog --help 1>/dev/null
echo 'Create sample file...'
FILE='time.klg'
echo '
2020-01-15
Did #something
1h this
13:00-14:00 that
' > "${FILE}"
echo 'Evaluate sample file...'
klog total "${FILE}" 1>/dev/null
echo 'Check version...'
ACTUAL_VERSION="$(klog version --no-check --quiet)"
[[ "${ACTUAL_VERSION}" == "${EXPECTED_VERSION}" ]] || exit 1
echo 'Check build hash...'
ACTUAL_BUILD_HASH="$(klog version --no-check | grep -oE '\[[abcdef0123456789]{7}]')"
[[ "${ACTUAL_BUILD_HASH}" == "[${EXPECTED_BUILD_HASH::7}]" ]] || exit 1
echo 'Check embedded spec file...'
ACTUAL_SPEC="$(klog info spec)"
[[ "${ACTUAL_SPEC}" == "$(cat "${EXPECTED_SPEC_PATH}")" ]] || exit 1
echo 'Check embedded license file...'
ACTUAL_LICENSE="$(klog info license)"
[[ "${ACTUAL_LICENSE}" == "$(cat "${EXPECTED_LICENSE_PATH}")" ]] || exit 1
0707010000000A000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001B00000000klog-6.6/.github/workflows0707010000000B000081A40000000000000000000000016863F92F00000812000000000000000000000000000000000000002200000000klog-6.6/.github/workflows/ci.ymlname: CI
on: [push, pull_request]
env:
GO_VERSION: '1.24'
STATIC_CHECK_VERSION: '2025.1'
COUNT_LOC_DOCKER_IMAGE: 'aldanial/cloc:2.02'
jobs:
statistics:
name: Statistics
runs-on: ubuntu-latest
env:
TARGET: klog/
TEST_FILE_PATTERN: .*_test\.go
steps:
- uses: actions/checkout@v2
- name: Prepare tooling
run: docker pull "${COUNT_LOC_DOCKER_IMAGE}"
- name: LOC of source files
run: docker run --rm -v $(pwd):/wdir:ro -w /wdir "${COUNT_LOC_DOCKER_IMAGE}" --not-match-f="${TEST_FILE_PATTERN}" "${TARGET}"
- name: LOC of test files
run: docker run --rm -v $(pwd):/wdir:ro -w /wdir "${COUNT_LOC_DOCKER_IMAGE}" --match-f="${TEST_FILE_PATTERN}" "${TARGET}"
benchmark:
name: Benchmark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- name: Build
run: |
source ./run.sh && run::build
mv out/klog /usr/local/bin/klog
- name: Run benchmark
run: cd .github/ && ./benchmark.sh
format:
name: Static analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- name: Check format
run: |
source ./run.sh
dirty_files="$(run::format)"
if [[ "${dirty_files}" != "" ]]; then $(exit 1); fi
- name: Run linters
run: |
go install "honnef.co/go/tools/cmd/staticcheck@${STATIC_CHECK_VERSION}"
source ./run.sh
run::lint
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- name: Print info about environment
run: go version
- name: Install dependencies
run: source ./run.sh && run::install
- name: Run unit tests
run: source ./run.sh && run::test
0707010000000C000081A40000000000000000000000016863F92F00000F62000000000000000000000000000000000000002700000000klog-6.6/.github/workflows/release.ymlname: Prepare Release
on:
workflow_dispatch:
inputs:
release_id:
description: 'Release id (tag name)'
required: true
env:
GO_VERSION: '1.24'
jobs:
create_release:
name: Create release draft
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- name: Create release
id: create_release
uses: actions/create-release@v1 # https://github.com/actions/create-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatically provided
with:
tag_name: ${{ github.event.inputs.release_id }}
body: |
Download the klog binary here:
- [**MacOS** Intel](https://github.com/jotaen/klog/releases/download/${{ github.event.inputs.release_id }}/klog-mac-intel.zip)
- [**MacOS** M1 (ARM)](https://github.com/jotaen/klog/releases/download/${{ github.event.inputs.release_id }}/klog-mac-arm.zip)
- [**Linux**](https://github.com/jotaen/klog/releases/download/${{ github.event.inputs.release_id }}/klog-linux.zip)
- [**Windows**](https://github.com/jotaen/klog/releases/download/${{ github.event.inputs.release_id }}/klog-windows.zip)
Consult the [changelog](https://github.com/jotaen/klog/blob/main/CHANGELOG.md) to learn what’s new.
See the [documentation website](https://klog.jotaen.net#get-klog) for install instructions, or to explore other install options.
In order to not miss any updates you can either subscribe to the release notifications on Github (at the top right: “Watch”→“Custom”→“Releases”), or check occasionally by running `klog version`.
release_name: ${{ github.event.inputs.release_id }}
prerelease: true
draft: true
build:
name: Build
needs: create_release
strategy:
matrix:
include:
- name: linux
go_os: linux
go_arch: amd64
binary_name: klog
- name: mac-intel
go_os: darwin
go_arch: amd64
binary_name: klog
- name: mac-arm
go_os: darwin
go_arch: arm64
binary_name: klog
- name: windows
go_os: windows
go_arch: amd64
binary_name: klog.exe
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- name: Build binary
env:
GOOS: ${{ matrix.go_os }}
GOARCH: ${{ matrix.go_arch }}
run: |
source ./run.sh
run::build ${{ github.event.inputs.release_id }} ${{ github.sha }}
- name: Smoke test
if: ${{ matrix.name == 'linux' }}
env:
EXPECTED_VERSION: ${{ github.event.inputs.release_id }}
EXPECTED_BUILD_HASH: ${{ github.sha }}
EXPECTED_SPEC_PATH: Specification.md
EXPECTED_LICENSE_PATH: LICENSE.txt
run: |
sudo cp out/klog /usr/bin/klog
./.github/smoke-test.sh
- name: Bundle
run: |
if [[ "${{ matrix.binary_name }}" != "klog" ]]; then
mv ./out/klog ./out/${{ matrix.binary_name }}
fi
cp ./.github/install_${{ matrix.go_os }}.md ./INSTALL.md
zip -j klog-${{ matrix.name }}.zip ./out/${{ matrix.binary_name }} ./INSTALL.md ./LICENSE.txt
- name: Upload binaries
uses: actions/upload-release-asset@v1 # https://github.com/actions/upload-release-asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatically provided
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: ./klog-${{ matrix.name }}.zip
asset_name: klog-${{ matrix.name }}.zip
asset_content_type: application/zip
0707010000000D000081A40000000000000000000000016863F92F0000000D000000000000000000000000000000000000001400000000klog-6.6/.gitignoreout/
tmp.klg
0707010000000E000081A40000000000000000000000016863F92F000039A9000000000000000000000000000000000000001600000000klog-6.6/CHANGELOG.md# Changelog
**Summary of changes of the command line tool**
## v6.6 (2025-07-01)
- **[ FEATURE ]** Add `--chart` (`-c`) flag to `klog report` command, which
includes bar chart renderings in the output, to allow for convenient visual
comparison at a glance. (See also `--chart-res` for the chart resolution.)
- **[ FEATURE ]** Add `--with-untagged` (`-u`) flag to `klog tags` command,
which takes into account the remainder of any untagged entries.
- **[ FIX ]** Implement internal protection mechanism against integer overflow.
(This, however, is only relevant when dealing with a few trillion years worth of
time tracking data.)
## v6.5 (2024-11-28)
- **[ FEATURE ]** Introduce `basic` colour scheme based on the basic 8-bit ANSI
colours – see `colour_scheme` entry in `config.ini` file. (Run `klog config` to
learn more.)
- **[ FEATURE ]** Provide new `no_warnings` setting in `config.ini` file to permanently
suppress certain warnings. (Run `klog config` to learn more.)
- **[ FEATURE ]** Make `--resume` and `--resume-nth` flags available on `klog switch`.
- **[ FIX ]** Fix alignment of output in `klog tags`, if there are tags with
multibyte Unicode characters.
## v6.4 (2024-07-16)
- **[ FEATURE ]** Add new `--create` flag to `klog bookmarks set`, which creates
a new empty target file alongside the bookmark.
- **[ FEATURE ]** For `klog start`, add `--resume-nth`/`-N` flag to specify the
entry to resume.
- **[ FIX ]** Fix output coloring of total values in `klog print --with-totals`.
## v6.3 (2024-03-03)
- **[ BREAKING ]** For `klog pause`, you cannot use the `--extend` and `--summary`
flags together anymore.
- **[ FEATURE / BREAKING ]** The `klog pause` command now automatically takes
over all tags from the open range of the record (if applicable), and appends them to
the summary of the pause entry. (You can opt out of this via the `--no-tags` flag.)
- **[ FEATURE / INFO ]** Extend and clarify `--help` output and built-in documentation
for all subcommands and flags.
- **[ FEATURE ]** There is a new setting for the klog `config.ini` file,
which allows to specify the colour theme of the terminal, so that klog
can optimise its output colouring. The available options are: `dark` (the
default), `light`, and `no_colour`. Run `klog config` to learn more.
- **[ FEATURE ]** Add `--entry-type` filter flag for filtering by entry type,
e.g.: `--entry-type open-range` or `--entry-type duration`.
- **[ FEATURE ]** Add two new rounding options for the `--round` flag: `12m` and `20m`.
(e.g., when doing `klog start --round`.)
- **[ FEATURE ]** Print file names in the error output when a file contains
syntax errors, to ease troubleshooting when evaluating multiple files.
- **[ FIX ]** Fix minor formatting bug of `klog print --with-totals` when a file
contains multiline record summaries.
## v6.2 (2023-10-17)
- **[ FEATURE ]** Add new command `klog switch`, that stops a previously
ongoing activity (open time range), and starts a new one.
- **[ FEATURE ]** `klog start --resume` now falls back to the previous
record for determining the last entry summary.
## v6.1 (2023-05-01)
- **[ FEATURE ]** Add new flag `klog start --resume`, which takes over the
summary of the last entry for the new open-ended entry.
## v6.0 (2023-03-06)
- **[ BREAKING ]** The default location of the klog config folder has moved!
So far, that folder only contains the bookmark database, so if you don’t
use bookmarks, you can ignore this change. In order to see or configure the
location of the klog config folder, please run `klog info config-folder`
(on the new release). The previous location was `~/.klog` on all systems,
so you might have to manually move over the contents of that previous folder,
and potentially adjust your dotfile management (if you have that).
- **[ FEATURE ]** Introduce optional, file-based configuration for general
preferences such as the preferred date or time format, or default values for
certain CLI flags. See `klog config` / `klog config --help` to learn more.
- **[ FEATURE ]** Display warning when using `--now` without there being any
open-ended time range in the data.
## v5.4 (2022-11-23)
- **[ BREAKING ]** For `klog edit`, support if the `$EDITOR` variable
contains additional flags, e.g. `vi -R` or `subl -w`. (If your editor
path contains spaces, you now have to wrap it in quotes.)
- **[ BREAKING ]** Simplify logic of `klog pause` command; add `--extend`
flag for extending a previous pause.
- **[ FIX ]** For `klog pause`, recover correctly after computer had
been asleep.
- **[ FEATURE ]** For `klog pause`, display current record while pausing.
- **[ FEATURE ]** For `klog tags`, optionally display how many entries
there are per tag via `--count`.
- **[ FEATURE ]** For `klog json`, provide `--now` flag.
- **[ FEATURE ]** For file manipulation commands (e.g. `klog track`),
improve automatic detection of style preferences in the file.
- **[ INFO ]** Significantly improve parsing performance for large
data inputs (i.e., for files with 1000+ records).
## v5.3 (2022-10-31)
- **[ FEATURE ]** Optionally amend `klog print` output with total
values via the `--with-totals` flag.
- **[ FIX ]** Fix unhandled error edge-case in `klog report` command.
## v5.2 (2022-08-19)
- **[ FEATURE ]** Provide tab completion functionality for bash, zsh
and fish shell. Run `klog completion` for setup instructions.
- **[ FIX ]** `klog edit` handles when the `$EDITOR` variable contains
spaces, and it also fails when `$EDITOR` is invalid.
## v5.1 (2022-07-20)
- **[ FEATURE ]** Optionally print out totals as decimal values (in minutes)
via the `--decimal` flag; e.g. `150` instead of `2h30m`.
- **[ FEATURE ]** Support `--now` on `klog tags` as well.
- **[ FEATURE ]** Allow setting a record summary via the `--summary` flag
when using `klog create`.
## v5.0 (2022-04-13)
- **[ META ]** Release the klog file format specification into the public domain
(under the CC0/OWFa dual license).
Read it here: https://github.com/jotaen/klog/blob/main/Specification.md
- **[ FEATURE ]** Allow tags to (optionally) have values assigned to them,
e.g. `#ticket=1764` or `#type=work`. The values can be quoted if
they contain special characters: `#project="22/48.3"`.
- **[ FEATURE / BREAKING ]** Allow hyphens (`-`) to appear in tags, e.g. `#home-office`.
- **[ FEATURE ]** For the `--period` flag, additionally allow filtering
by quarter (`YYYY-Qq`, e.g. `2022-Q1`) and week (`YYYY-Www`, e.g. `YYYY-W34`).
## v4.0 (2022-03-21)
- **[ FEATURE ]** Allow summaries behind entries to be continued on
the next line (with increased indentation level), e.g.:
```
2020-01-01
Both of the following is fine:
15:00-16:00 This is a very long text, so
it can be continued on the next line.
16:00-17:00
Or, you can just start the entry summary
on the next line, if you like.
```
The CLI also handles this automatically when it encounters
line breaks (`\n`), e.g. in the `--summary` flag value.
- **[ FEATURE ]** Add new command `klog pause` that “pauses”
open-ended time ranges by adding a subsequent pause entry.
- **[ FEATURE ]** Provide rounding option for `klog start` and
`klog stop`, which rounds times to the nearest multiple of
5m, 10m, 15m, 30m, or 60m. E.g. for `--round=15m`: `8:03` -> `8:00`.
- **[ FEATURE ]** Add more shortcut filters, e.g. `--this-week`,
`--last-month`, etc.
- **[ FEATURE ]** Embed the most recent part of the changelog for
convenience, via `klog --changelog`.
- **[ BREAKING ]** Remove embedded macOS systray widget
## v3.3 (2022-01-30)
- **[ FEATURE ]** Allow times to be `24:00`, e.g. `22:00 - 24:00`.
- **[ FEATURE ]** Add `klog goto` command for opening the file explorer
at the location of a file or bookmark.
- **[ FEATURE ]** Add `klog bookmark info` command.
- **[ FEATURE ]** When using the manipulation commands (`klog track`, etc.),
conform to style preferences encountered in the file.
- **[ FEATURE ]** Add `--tomorrow` as shorthand flag for the next day’s date.
- **[ FEATURE ]** Improve warnings (which are shown for potential data problems).
- **[ FIX ]** Fix bug in week-based aggregation of `klog report --aggregate week`.
## v3.2 (2021-11-30)
- **[ BREAKING ]** Don’t allow mixing the indentation style within a
record. (It might still differ *between* records, though.) For example: if
the first entry is indented with a tab, then all further entries of that
particular record have to be indented with a tab as well. In order to check
that your existing files conform, you can parse all your `.klg` files at once
via a wildcard lookup, in order to see whether any indentation-related
errors are reported. On Linux, e.g.: `klog total ~/**/*.klg`.
- **[ FEATURE ]** Allow version check via `klog -v` (in addition
to `klog --version` or `klog version`)
- **[ FEATURE ]** Embed specification and license in the binary
(via `klog --spec` and `klog --license`)
- **[ FEATURE ]** Provide binaries for M1 Macs (ARM) for download.
- **[ FIX ]** Fix default sort order of `--sort` flag to be `asc`
- **[ INFO ]** Deprecate the embedded native widget (for MacOS). It will be
removed in one of the next releases.
## v3.1 (2021-10-20)
- **[ FIX ]** Fix stdin processing on Windows
## v3.0 (2021-10-07)
- **[ FEATURE ]** Support multiple (named) bookmarks to quickly
reference often-used files, e.g. `klog total @work`
- **[ FEATURE ]** Add additional evaluation options for `klog report`
to aggregate the data by day, week, month, quarter or year
- **[ FEATURE ]** Add `klog edit` command for opening a file in an editor
(Based on the `$EDITOR` variable.)
- **[ FEATURE ]** Allow value of `--sort` flag to be uppercase
or lowercase (`ASC`/`asc` or `DESC`/`desc`)
- **[ FEATURE ]** Support `klog --version` in addition to `klog version`
- **[ FIX ]** Windows: don’t require admin privileges for setting bookmarks
## v2.6 (2021-07-25)
- **[ INFO ]** Release first version of the file format
specification (v1.0)
- **[ FIX ]** If a duration only contains a minute part,
allow the value to be greater than 59, e.g. `120m`.
## v2.5 (2021-05-17)
- **[ BREAKING ]** Rename `klog now` to `klog today`; restructure the
output, especially when using the `--diff`/`--now` flag
- **[ FEATURE ]** Use distinct exit codes for different error cases
- **[ FEATURE ]** Introduce `--quiet` flag to retrieve raw output
- **[ FEATURE ]** Extend help texts, improve error messages
- **[ FIX ]** Fix formatting issues of error output
## v2.4 (2021-05-05)
- **[ FEATURE ]** Automatically create a new record when doing
`klog start` or `klog track` if there is no record yet
- **[ FEATURE ]** Allow wildcard searching in tags by appending `...`,
e.g. `--tag=foo...` would match the tag `#foobar`
- **[ FIX ]** `klog stop` now also looks for open ranges of the
previous day and closes them with a shifted end time
- **[ FIX ]** `klog stop --summary=""` doesn’t fail if the existing
entry summary was empty
## v2.3 (2021-04-28)
- **[ FEATURE ]** Add `--summary`/`-s` flag for `start` and
`stop` subcommands
- **[ FEATURE ]** If `KLOG_DEBUG` environment variable is set,
print more verbose error output
- **[ FIX ]** Ensure that reading from stdin works on Windows
- **[ FIX ]** Display a more helpful error message on Windows
to explain the quirks with `bookmark set`
## v2.2 (2021-04-03)
- **[ FEATURE ]** Provide `--no-style` option to disable output
formatting (i.e. no colours, underlined, bold, etc.)
- **[ FIX ]** Make sure that output formatting works on Windows
across all Terminals.
## v2.1 (2021-03-19)
- **[ FEATURE ]** Provide native Windows binary
## v2.0 (2021-03-16)
- **[ BREAKING ]** Make `--after` and `--before` filters exclusive
- **[ FEATURE ]** Add commands for manipulating files:
- `create` for creating a new record
- `track` for adding an entry to a record
- `start` to track an open-ended time range
- `stop` to close an open-ended time range
- **[ FEATURE ]** Add `--since` and `--until` filters (inclusive)
- **[ FEATURE ]** Add `--period` filter (e.g. `--period=2015` for
all in 2015, or `--period=2015-04` for all in April 2015).
## v1.6 (2021-03-06)
- **[ FEATURE ]** Add `json` subcommand that allows users to build
programmatic extensions
- **[ FEATURE ]** Support Windows line endings (`\r\n`)
- **[ FEATURE ]** Add `bookmark unset` command for clearing current selection
- **[ FEATURE ]** Check stdin for input (to allow shell piping)
## v1.5 (2021-02-16)
- **[ FIX ]** Fix the ongoing time counter in `klog now --follow`
## v1.4 (2021-02-16)
- **[ FIX ]** Fix the ongoing time counter in the MacOS widget
## v1.3 (2021-02-14)
- **[ BREAKING ]** Change structure of the bookmark subcommand
(This is in order to account for the increasing number of operations)
- **[ FEATURE ]** Add subcommand `now` for displaying an ongoing total
that takes open ranges into account (based on the time of execution)
- **[ FEATURE ]** Add `--now` flag to `total` and `report` to take
open ranges into account optionally
- **[ FEATURE ]** Add subcommand `bookmark edit` for opening a bookmarked
file in your $EDITOR
- **[ FEATURE ]** Allow to sort results in both directions
(`--sort ASC` or `--sort DESC`)
- **[ FEATURE ]** Print warning when unclosed open ranges are detected
in records before yesterday. (It’s probably always a mistake, if that occurs.)
You can disable this check with the `--no-warn` flag.
- **[ FEATURE ]** Support filtering for `tags` and `reports`
- **[ FEATURE ]** Define shorthand flags, e.g. for `--now`, `--diff`
- **[ FIX ]** Don’t demand `.klg` file extensions for bookmarks
## v1.2 (2021-02-07)
- **[ INFO ]** Provided more helpful error messages
- **[ FIX ]** Fix unhandled error with experimental `template` subcommand
(introduced in v1.1)
## v1.1 (2021-02-07)
- **[ INFO ]** Introduced hidden and experimental `template` subcommand,
see https://github.com/jotaen/klog/pull/12
- **[ FIX ]** If a duration consists hours and minutes,
the minutes cannot be greater than `59m`, e.g. `1h59m`
- **[ FIX ]** Ensure there is a final blank line when `print`-ing
- **[ FIX ]** Improve error messages regarding the bookmark subcommand
## v1.0 (2021-02-06)
- **[ BREAKING ]** Renamed subcommand `eval` to `total`.
(This wording is more inline with the documentation and
therefore more intuitive.)
- **[ FEATURE ]** Added subcommand `report` that generates a
calendar overview
- **[ FEATURE ]** Added subcommand `tags` that shows the total
times aggregated by tags
- **[ FEATURE ]** Added subcommand `bookmark` (a file that
is used by default when no input files are specified)
0707010000000F000081A40000000000000000000000016863F92F00000442000000000000000000000000000000000000001500000000klog-6.6/LICENSE.txtMIT License
Copyright 2020 Jan Heuermann (https://www.jotaen.net)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.07070100000010000081A40000000000000000000000016863F92F00000610000000000000000000000000000000000000001300000000klog-6.6/README.md<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://klog.jotaen.net/logo/klog-white.svg">
<source media="(prefers-color-scheme: light)" srcset="https://klog.jotaen.net/logo/klog-black.svg">
<img alt="klog logo" src="https://klog.jotaen.net/logo/klog-black.svg" height="80">
</picture>
# klog
klog is a plain-text file format and a command line tool for time tracking.
📕 [**Documentation**](https://klog.jotaen.net) – **Learn what klog is and how to use it**
📥 [Install](https://klog.jotaen.net#get-klog) – Get the latest version
📢 [Changelog](https://github.com/jotaen/klog/blob/main/CHANGELOG.md) – See what’s new
💡 [Specification](Specification.md) – Study the file format
## Contribute
If you have questions, feature ideas, or just want to bounce off some feedback,
feel invited to [start a discussion](https://github.com/jotaen/klog/discussions).
In case you run into a bug, please [file an issue](https://github.com/jotaen/klog/issues).
(When in doubt, just go for an issue.)
This repository contains the sources of the klog command line tool as well as
the [specification document](Specification.md) of the klog file format.
Note that both are technically independent of each other, which implies that
they also have different version numbers.
## About
klog was created by [Jan Heuermann](https://www.jotaen.net).
You are free to use it under the following terms:
- Command line tool: [MIT license](LICENSE.txt)
- File specification: [public domain (CC0/OWFa)](Specification.md#License)
07070100000011000081A40000000000000000000000016863F92F00003662000000000000000000000000000000000000001A00000000klog-6.6/Specification.md# klog – File Format Specification
**Version 1.4**
klog is a file format for tracking time.
## License
Per [Creative Commons CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/),
to the extent possible under law, the editors have waived all copyright and related or
neighbouring rights to this work.
In addition, as of March 2022, the editors have made this specification available under the
[Open Web Foundation Agreement 1.0](https://www.openwebfoundation.org/the-agreements/the-owf-1-0-agreements-granted-claims/owfa-1-0).
## Preface
The keywords “MUST”, “MUST NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED” and “MAY”
in this document are to be interpreted as described in [RFC 2119](https://tools.ietf.org/html/rfc2119).
Whenever a word has special meaning in klog, it is formatted in *italics*.
Other technical terms are surrounded by “quotes”. These are defined in the appendix.
Character sequences that are wrapped in `backticks` are meant to be read exactly (character by character).
## I. Record
A *record* is a self-contained data structure that contains time-tracking information.
Each *record* MUST appear as one consecutive block in the file,
without any “blank lines” appearing within.
The first line of a *record* MUST start with a *date*.
On the same line there MAY follow a *should-total*,
which MUST be separated by one “space” from the *date*
(additional “spaces” MAY appear).
A *record summary* MAY appear on the subsequent lines.
Any amount of *entries* MAY appear afterwards,
where each MAY have an *entry summary*.
In order to indent a line, it MUST start with one of the following sequences:
- Four “spaces” (RECOMMENDED)
- Two or three “spaces”
- One “tab”
To signify the second level of indentation,
the indentation sequence MUST appear twice.
The indentation style MUST be uniform within *records*.
(It MAY differ between *records*, though.)
[^indst]
### Date
A *date* is a day in the calendar.
> Examples: `2020-01-01`, `1984-08-30`, `2004/12/24`
*Dates* MUST contain 4 “digits” that denote the year,
2 “digits” that denote the month,
and 2 “digits” that denote the day.
The parts MUST be separated by either a `-` (RECOMMENDED)
or a `/`.
The year part MUST be written first, then the month, then the day.
The combination of year, month and day MUST be representable by the Gregorian calendar.
### Should-Total
A *should-total* denotes the targeted total time of a *record*.
> Examples: `(8h!)`, `(5h15m!)`, `(-3h30m!)`
A *should-total* MUST be a *duration* value
followed by a `!`
and wrapped in “parentheses”.
### Summary
A *summary* is user-provided text for capturing arbitrary information
about a *record* or an *entry*. *Summaries* are optional.
#### Record Summary
The *record summary* is considered to be associated with the entire *record*.
It MUST appear underneath the *date*,
and it MAY span multiple lines.
Each of its lines MUST NOT start with “blank characters”.
[^resui]
#### Entry Summary
The *entry summary* is considered to be referring to one particular *entry*.
It MUST either start on the same line as the *entry*,
separated from it by one “space”;
or it MUST start on the subsequent line.
The *entry summary* MAY span multiple lines.
All lines following the *entry* line MUST be indented twice;
they also MUST NOT only consist of “blank characters”.
[^iwses]
#### Tag
The purpose of *tags* is to help categorise *records* and *entries*.
> Examples: `#gym`, `#home-office`, `#読む`, `#ticket=891`, `#project="22/48.3"`
Any amount of *tags* MAY appear anywhere within *summaries*.
A *tag* MUST consist of a *tag name*,
which MUST be preceded by a single `#` character.
The *tag name* MAY be followed by a `=` character
and a *tag value*.
The *tag name* MUST only contain
“letters”, “digits”, or the characters `_` or `-`.
It MUST be interpreted as if it was all lower-case.
[^csitn]
The *tag value* MAY be surrounded by a pair of matching quotes,
which MUST either be `"` (RECOMMENDED) or `'`.
- If the *tag value* is quoted, it MAY contain any character
except for the respective quote character itself,
or a “newline”.
In case no matching closing quote appears on the same line,
the *tag value* MUST be treated as absent.
[^qutvl]
- If the *tag value* is not quoted, it MUST only contain
“letters”, “digits”, or the characters `_` or `-`.
An empty *tag value* (e.g. `#tag=` or `#tag=""`)
MUST be treated the same as an absent *tag value* (e.g. `#tag`).
### Entry
*Entry* is an abstract term for time-related data.
*Durations*, *ranges* and *open ranges* are instances of *entries*.
> Examples (indentation omitted): `2h30m`, `-1h Lunch break`, `11:00 - 14:15`, `8:00am - 2:00pm Long day at #school`
Each *entry* MUST appear on its own line and
MUST be indented once.
A *summary* MAY be associated with an *entry* (see section Summary).
### Time
A *time* is a value that represents a point in time throughout a day
as it would be displayed by a wall clock (which divides a day into
24 hours and every hour into 60 minutes).
> Examples: `14:18`, `6:30am`, `01:00>`, `<23:00am`
*Time* values MUST contain an hour part and a minute part,
separated by a `:` in between.
The hour part MUST be written first.
As default, *times* are to be interpreted as 24-hour clock values.
An `am` or `pm` suffix MAY be used to denote that the value is
to be interpreted as 12-hour clock value.
The minute part MUST be between 0-59 (inclusive).
Single-figure minute parts MUST be padded with a `0`.
The hour part MUST either be between 0-24 (inclusive) when using the 24-hour clock,
or between 1-12 (inclusive) when using the 12-hour clock.
Single-figure hour parts MAY be padded with a `0`.
When using the 24-hour clock, if the hour part is `24`,
then the minute part MUST be `00`;
`<24:00` MUST be interpreted as `0:00`,
`24:00` MUST be interpreted as `0:00>`,
`24:00>` MUST NOT appear.
*Time* values MAY be *shifted* to the next or to the previous day:
- To associate the *time* with the day before the *record’s* *date*,
a `<` prefix MUST be used,
e.g. `<23:00`.
- To associate the *time* with the day after the *record’s* *date*,
a `>` suffix MUST be used,
e.g. `1:30>`.
### Range
A *range* is an *entry* that represents the time span between two points in time.
> Examples: `8:00 - 9:00`, `11:00am - 1:00pm`, `<23:40 - 3:12`, `0:30> - 4:00>`
*Ranges* MUST contain two *time* values that denote the start and the end.
Start *time* and end *time* MUST be written in chronological order.
They MAY be equal.
There MUST be a `-` between the two values.
There MAY appear “spaces” on either side of the `-`,
in which case it is RECOMMENDED to use exactly one “space” on both sides of the dash.
### Open range
An *open range* is an *entry*
that can be used to track the start *time* of an activity,
i.e. the end *time* is not determined yet.
> Examples: `05:17 - ?`, `4:00pm - ?`
*Open ranges* are formatted in the same way as *ranges*,
except that the end *time* MUST be replaced by a placeholder.
The placeholder MUST be denoted by the character `?`,
e.g. `9:00 - ?`.
The `?` MAY be repeated, e.g. `9:00 - ???`.
[^plrep]
The placeholder MUST NOT be *shifted*.
*Open ranges* MUST NOT appear more than once per *record*.
[^oasor]
### Duration
A *duration* is an *entry* that represents a period of time.
> Examples: `1h`, `5m`, `4h12m`, `-8h30m`
*Durations* MUST contain an amount of hours and/or an amount of minutes.
(So they MUST either contain one of these two or both.)
The hour part MUST be written first.
The hour part MUST be an “integer”
which MUST be followed by the character `h`.
It MAY be `0h`.
It MAY be greater than `24h`,
e.g. `50h`.
If the hour part is missing, a value of `0h` is assumed.
The minute part MUST be an “integer”
which MUST be followed by the character `m`.
It MAY be `0m`.
When the hour part is present,
the minute part MUST NOT be greater than `59m`,
e.g. `1h59m`;
otherwise it MAY be greater than `59m`,
e.g. `119m`
(it is RECOMMENDED to break this up, though).
If the minute part is missing, a value of `0m` is assumed.
The *duration* as a whole is a signed value:
That means it is either positive (i.e. adding to the *total time*)
or negative (i.e. deducting from the *total time*).
By default, a *duration* is positive,
which MAY be indicated by a leading `+` character,
e.g. `+4h12m`.
If the *duration* is supposed to be negative, it MUST be preceded by a `-` character.
## II. Organising records in files
A file MAY hold any amount of *records*.
There MUST appear one “blank line” between subsequent *records*;
additional “blank lines” MAY appear.
*Records* MAY appear in any order in the file.
There MAY exist multiple *records* with the same *date*.
A file MUST NOT contain anything but what is allowed by this specification.
Otherwise, it SHOULD NOT be evaluated.
[^fcocr]
The file extension SHOULD be `.klg`, e.g. `times.klg`.
The file encoding MUST be UTF-8.
“Newlines” MUST be encoded with either the
linefeed character (LF, escape sequence `\n`),
or carriage return and linefeed character (CRLF, escape sequences `\r\n`).
These two styles SHOULD NOT be mixed within the same file.
There SHOULD be a “newline” at the end of the file.
## III. Evaluating data
### Total time
The resulting *total time* of a *record* MUST be computed by summing up its *entries*:
positive values add to the *total time*,
negative values deduct from it.
The resulting *total time* MAY be 0;
it MAY be negative;
it MAY be greater than 24 hours.
Overlapping *ranges* MUST each be counted fully.
E.g., the two *entries* `12:00 - 13:00` and `12:30 - 13:30` result in a *total time* of `2h`.
*Ranges* with *shifted times* MUST be fully counted towards
the *date* at which they appear in the *record*.
They MUST NOT be implicitly split across the two adjacent *dates*.
*Open ranges* MUST NOT be counted by default;
they MAY be factored in upon explicit request, though.
Multiple *records* with the same *date* MUST be treated as distinct
and MUST NOT be combined into a single *record*.
## IV. Appendix
### Glossary of technical terms
- “space”: The character ` ` (U+0020)
- “tab”: The tab character (U+0009, escape sequence `\t`)
- “blank character”: A “tab”, or a character as defined by the Unicode Space Separator category (Zs)
- “blank line”: A line that only contains “blank characters”
- “parenthesis”: The opening and closing parentheses `(` and `)` (U+0028 and U+0029)
- “letter”: A character as defined by the Unicode Letter category (L)
- “digit”: Any of 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
- “integer”: An unsigned number without fractional component
- “newline”: Either a linefeed (U+0010, escape sequence `\n`), or a carriage return and linefeed (U+0013 and U+0010, escape sequence `\r\n`)
### Changelog
#### Version 1.4
- Release the specification document under the CC0/OWFa license.
- Support for tags to (optionally) have values assigned to them.
- Allow hyphens (`-`) to appear in tags.
- Add footnotes to make context information explicit.
#### Version 1.3
- Specify additional rules for multiline entry summaries.
#### Version 1.2
- Allow times to be `24:00`.
- Some minor restructurings for enhanced clarity.
#### Version 1.1
- Add a constraint regarding the indentation that requires the indentation style
to be uniform within a record.
- Remove technical term “whitespace”, since its meaning is ambiguous and the definition lacked clarity.
Replace it with “blank character” and base the definition on the Unicode category.
### Footnotes
The following footnotes are purely informational,
to make contextual background information explicit.
[^indst]: The indentation must be uniform, otherwise the levels can’t be determined
unambiguously. E.g., if 4 spaces are encountered at the beginning of the line, it would
be unclear whether that is 2 * 2 spaces or 1 * 4 spaces.
[^resui]: Lines in the record summary can’t start with blank characters, to avoid that they
might be visually confused with the (indented) entries. There is no strict technical
reason for this, though.
[^iwses]: In contrast to record summaries, lines in entry summaries can start with blank
characters. That is for allowing the user to vertically align the summary text on all
entry lines. A by-effect of this rule is that there can never be a third indentation level.
[^csitn]: The character set that a tag is allowed to consist of is deliberately limited,
so that tags can appear as natural words in the flow of a sentence. E.g.:
`#Office day (#coding, #meetings)`. That’s also why tag names are to be interpreted
as case-insensitive. (Tag values, on the other hand, are always to be interpreted literally.)
[^qutvl]: The main use-case for quoted tag values is for literal references, such as a project id,
or a name: `#project="2022/7.2"` or `#call="Liz Jones"`. That’s also why tag values
are always to be interpreted as case-sensitive (in contrast to tag names).
[^plrep]: The `?` placeholder in open ranges can be repeated, to allow users to visually
align it with other entries. E.g. `8:00-?????` has the same width as `8:00-9:00`.
[^oasor]: Open ranges only being allowed to appear once per record has a mere practical motivation:
it’s important for making interactions with tools easier. Otherwise, when stopping activities
via a tool, it might be ambiguous which of the open ranges is meant.
[^fcocr]: By allowing a file to only contain records and nothing else, a klog file can effectively
be perceived as a text-based database. That makes it easy to process files programmatically,
because every record is a self-contained and strictly structured unit of data.
07070100000012000081A40000000000000000000000016863F92F000002B9000000000000000000000000000000000000001000000000klog-6.6/go.modmodule github.com/jotaen/klog
go 1.24
require (
cloud.google.com/go v0.121.3
github.com/alecthomas/kong v1.12.0
github.com/jotaen/genie v0.0.1
github.com/jotaen/kong-completion v0.0.6
github.com/jotaen/safemath v0.0.1
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/posener/complete v1.2.3
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
07070100000013000081A40000000000000000000000016863F92F00000F84000000000000000000000000000000000000001000000000klog-6.6/go.sumcloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.12.0 h1:oKd/0fHSdajj5PfGDd3ScvEvpVJf9mT2mb5r9xYadYM=
github.com/alecthomas/kong v1.12.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jotaen/genie v0.0.1 h1:gURxhYIpVEJ7SKjjNRDLV5OrgMxCbkAdWhjD86ad9P8=
github.com/jotaen/genie v0.0.1/go.mod h1:bu+PbJDEJ9915yp4xml7OXoM4iBsSDfgtGVwv5Ag0Gg=
github.com/jotaen/kong-completion v0.0.6 h1:VP1KGvXPeB7MytYR+zZQoWw1gf/HIV1/EvWC38BHZN4=
github.com/jotaen/kong-completion v0.0.6/go.mod h1:fuWw9snL6joY5mXbI0Dd5FWEZODaWXAeqaRxo6dAvLk=
github.com/jotaen/safemath v0.0.1 h1:YcUhSIUtwQY1rUUT3AeP+alzTHUAsM4Pap8ZMn3GOlc=
github.com/jotaen/safemath v0.0.1/go.mod h1:KlKBnI3qvGcr3+iuvp3vABBZNFRjRcwRUVQa/jM38xQ=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
07070100000014000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000000E00000000klog-6.6/klog07070100000015000081A40000000000000000000000016863F92F00000948000000000000000000000000000000000000001100000000klog-6.6/klog.gopackage main
import (
_ "embed"
"fmt"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/app/main"
"os"
"runtime"
)
//go:embed Specification.md
var specification string
//go:embed LICENSE.txt
var license string
var BinaryVersion string // Set via build flag
var BinaryBuildHash string // Set via build flag
func main() {
if len(BinaryBuildHash) > 7 {
BinaryBuildHash = BinaryBuildHash[:7]
}
klogFolder := func() app.File {
f, err := determineKlogConfigFolder()
if err != nil {
fail(util.PrettifyAppError(err, false), app.CONFIG_ERROR.ToInt())
}
return f
}()
configFile := func() string {
c, err := readConfigFile(app.Join(klogFolder, app.CONFIG_FILE_NAME))
if err != nil {
fail(util.PrettifyAppError(err, false), app.CONFIG_ERROR.ToInt())
}
return c
}()
config := func() app.Config {
c, err := app.NewConfig(
app.FromDeterminedValues{NumCpus: runtime.NumCPU()},
app.FromEnvVars{GetVar: os.Getenv},
app.FromConfigFile{FileContents: configFile},
)
if err != nil {
fail(util.PrettifyAppError(err, false), app.CONFIG_ERROR.ToInt())
}
return c
}()
code, err := klog.Run(klogFolder, app.Meta{
Specification: specification,
License: license,
Version: BinaryVersion,
SrcHash: BinaryBuildHash,
}, config, os.Args[1:])
if err != nil {
fail(err, code)
}
}
// fail terminates the process with an error.
func fail(err error, exitCode int) {
fmt.Println(err)
os.Exit(exitCode)
}
// readConfigFile reads the config file from disk, if present.
// If not present, it returns empty string.
func readConfigFile(location app.File) (string, app.Error) {
contents, rErr := app.ReadFile(location)
if rErr != nil {
if rErr.Code() == app.NO_SUCH_FILE {
return "", nil
}
return "", rErr
}
return contents, nil
}
// determineKlogConfigFolder returns the location where the klog config folder
// is (or should be) located.
func determineKlogConfigFolder() (app.File, app.Error) {
for _, kf := range app.KLOG_CONFIG_FOLDER {
basePath := os.Getenv(kf.BasePathEnvVar)
if basePath != "" {
return app.NewFile(basePath, kf.Location)
}
}
return nil, app.NewError(
"Cannot determine klog config folder",
"Please set the `KLOG_CONFIG_HOME` environment variable, and make it point to a valid, empty folder.",
nil,
)
}
07070100000016000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001200000000klog-6.6/klog/app07070100000017000081A40000000000000000000000016863F92F0000130E000000000000000000000000000000000000001E00000000klog-6.6/klog/app/bookmark.gopackage app
import (
"bytes"
"encoding/json"
"sort"
"strings"
)
// Name is the bookmark alias.
type Name string
const (
BOOKMARK_DEFAULT_NAME = "default"
BOOKMARK_PREFIX = "@"
)
func NewName(name string) Name {
value := strings.TrimLeft(name, BOOKMARK_PREFIX)
if value == "" {
value = BOOKMARK_DEFAULT_NAME
}
return Name(value)
}
// Value returns the name of the bookmark without prefix.
func (n Name) Value() string {
return string(n)
}
// ValuePretty returns the name of the bookmark with prefix.
func (n Name) ValuePretty() string {
return BOOKMARK_PREFIX + n.Value()
}
// IsValidBookmarkName checks whether `value` is a valid bookmark name (including the prefix).
func IsValidBookmarkName(value string) bool {
return strings.HasPrefix(value, BOOKMARK_PREFIX)
}
// Bookmark is a way to reference often used files via a short alias (the name).
type Bookmark interface {
// Name is the alias of the bookmark.
Name() Name
// Target is the file that the bookmark references.
Target() File
// IsDefault returns whether the bookmark is the default one.
// In this case, the bookmark name is `default`.
IsDefault() bool
}
// BookmarksCollection is the collection of all bookmarks.
type BookmarksCollection interface {
// Get looks up a bookmark by its name.
Get(Name) Bookmark
// All returns all bookmarks in the collection.
All() []Bookmark
// Default returns the default bookmark of the collection.
Default() Bookmark
// Set adds a new bookmark to the collection.
Set(Bookmark)
// Remove deletes a bookmark from the collection.
Remove(Name) bool
// Clear deletes all bookmarks of the collection.
Clear()
// ToJson returns a JSON-representation of the bookmark collection.
ToJson() string
// Count returns the number of bookmarks in the collection.
Count() int
}
func NewBookmark(name string, target File) Bookmark {
return &bookmark{NewName(name), target}
}
func NewDefaultBookmark(target File) Bookmark {
return NewBookmark(BOOKMARK_DEFAULT_NAME, target)
}
type bookmark struct {
name Name
target File
}
func (b *bookmark) Name() Name {
return b.name
}
func (b *bookmark) Target() File {
return b.target
}
func (b *bookmark) IsDefault() bool {
return b.name.Value() == BOOKMARK_DEFAULT_NAME
}
type bookmarksCollection struct {
bookmarks map[Name]Bookmark
}
func (bc *bookmarksCollection) Default() Bookmark {
return bc.bookmarks[Name(BOOKMARK_DEFAULT_NAME)]
}
type bookmarkJson struct {
Name *string `json:"name"`
Path *string `json:"path"`
}
func NewEmptyBookmarksCollection() BookmarksCollection {
return &bookmarksCollection{make(map[Name]Bookmark)}
}
// NewBookmarksCollectionFromJson deserialises JSON data. It returns an error
// if the syntax is malformed.
func NewBookmarksCollectionFromJson(jsonText string) (BookmarksCollection, Error) {
newMalformedJsonError := func(err error) Error {
return NewErrorWithCode(
CONFIG_ERROR,
"Invalid JSON",
"The JSON in your bookmarks file is malformed",
err,
)
}
bc := NewEmptyBookmarksCollection()
if jsonText == "" {
return bc, nil
}
var rawBookmarkInfo []bookmarkJson
err := json.Unmarshal([]byte(jsonText), &rawBookmarkInfo)
if err != nil {
return nil, newMalformedJsonError(err)
}
for _, b := range rawBookmarkInfo {
if b.Name == nil || b.Path == nil {
return nil, newMalformedJsonError(nil)
}
if !IsAbs(*b.Path) {
return nil, newMalformedJsonError(nil)
}
file, fErr := NewFile(*b.Path)
if fErr != nil {
return nil, fErr
}
bc.Set(NewBookmark(*b.Name, file))
}
return bc, nil
}
func (bc *bookmarksCollection) Get(n Name) Bookmark {
return bc.bookmarks[n]
}
func (bc *bookmarksCollection) All() []Bookmark {
sortedBookmarks := make([]Bookmark, 0, len(bc.bookmarks))
for _, b := range bc.bookmarks {
sortedBookmarks = append(sortedBookmarks, b)
}
sort.Slice(sortedBookmarks, func(i, j int) bool {
return sortedBookmarks[i].Name() < sortedBookmarks[j].Name()
})
return sortedBookmarks
}
func (bc *bookmarksCollection) Set(b Bookmark) {
bc.bookmarks[b.Name()] = b
}
func (bc *bookmarksCollection) Remove(n Name) bool {
if bc.bookmarks[n] == nil {
return false
}
delete(bc.bookmarks, n)
return true
}
func (bc *bookmarksCollection) Clear() {
bc.bookmarks = make(map[Name]Bookmark)
}
func (bc *bookmarksCollection) Count() int {
return len(bc.bookmarks)
}
func (bc *bookmarksCollection) ToJson() string {
var bookmarksAsJson []bookmarkJson
for _, b := range bc.All() {
name := b.Name().Value()
path := b.Target().Path()
bookmarksAsJson = append(bookmarksAsJson, bookmarkJson{
&name, &path,
})
}
if len(bookmarksAsJson) == 0 {
return ""
}
buffer := new(bytes.Buffer)
enc := json.NewEncoder(buffer)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
err := enc.Encode(&bookmarksAsJson)
if err != nil {
panic(err)
}
return buffer.String()
}
07070100000018000081A40000000000000000000000016863F92F00000ECA000000000000000000000000000000000000002300000000klog-6.6/klog/app/bookmark_test.gopackage app
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestCreatesNewBookmark(t *testing.T) {
b := NewBookmark("foo", NewFileOrPanic("/asdf/foo.klg"))
assert.Equal(t, "foo", b.Name().Value())
assert.Equal(t, "/asdf/foo.klg", b.Target().Path())
}
func TestNormalizesBookmarkName(t *testing.T) {
b := NewBookmark("@foo", NewFileOrPanic("/asdf/foo.klg"))
assert.Equal(t, "foo", b.Name().Value())
assert.Equal(t, "foo", NewName("foo").Value())
assert.Equal(t, "foo", NewName("@foo").Value())
assert.Equal(t, "foo", NewName("@@foo").Value())
assert.Equal(t, "default", NewName("default").Value())
assert.Equal(t, "@foo", NewName("foo").ValuePretty())
}
func TestGetsBookmarks(t *testing.T) {
bc := NewEmptyBookmarksCollection()
foo := NewBookmark("foo", NewFileOrPanic("/foo.klg"))
bc.Set(foo)
asdf := NewBookmark("asdf", NewFileOrPanic("/asdf.klg"))
bc.Set(asdf)
bar := NewBookmark("bar", NewFileOrPanic("/bar.klg"))
bc.Set(bar)
assert.Equal(t, foo, bc.Get("foo"))
assert.Equal(t, bar, bc.Get("bar"))
assert.Equal(t, asdf, bc.Get("asdf"))
assert.Equal(t, []Bookmark{asdf, bar, foo}, bc.All())
}
func TestCanAddAndRemoveBookmarks(t *testing.T) {
bc := NewEmptyBookmarksCollection()
bc.Set(NewDefaultBookmark(NewFileOrPanic("/old.klg")))
assert.Equal(t, "default", bc.Default().Name().Value())
assert.Equal(t, "/old.klg", bc.Default().Target().Path())
assert.Equal(t, 1, bc.Count())
// Overwrites existing bookmark
bc.Set(NewDefaultBookmark(NewFileOrPanic("/new.klg")))
assert.Equal(t, "/new.klg", bc.Default().Target().Path())
assert.Equal(t, 1, bc.Count())
// Add another bookmark
foo := NewName("foo")
bc.Set(NewBookmark(foo.Value(), NewFileOrPanic("/qwer.klg")))
assert.Equal(t, foo, bc.Get(foo).Name())
assert.Equal(t, 2, bc.Count())
// Remove
hasRemoved := bc.Remove(foo)
assert.True(t, hasRemoved)
assert.Nil(t, bc.Get(foo))
assert.Equal(t, 1, bc.Count())
// Removing again is no-op
hasRemovedAgain := bc.Remove(foo)
assert.False(t, hasRemovedAgain)
// Clear all
bc.Clear()
assert.Nil(t, bc.Default())
assert.Equal(t, 0, bc.Count())
bc.Clear() // Idempotent operation
assert.Nil(t, bc.Default())
}
func TestParseBookmarksCollectionFromString(t *testing.T) {
bc, err := NewBookmarksCollectionFromJson(`[{
"name": "default",
"path": "/asdf/foo.klg"
}]`)
require.Nil(t, err)
def := bc.Default()
require.NotNil(t, def)
assert.Equal(t, "default", def.Name().Value())
assert.Equal(t, "/asdf/foo.klg", def.Target().Path())
}
func TestParseEmptyBookmarksCollectionFromString(t *testing.T) {
for _, jsonText := range []string{
``,
`[]`,
} {
bc, err := NewBookmarksCollectionFromJson(jsonText)
require.Nil(t, err)
require.NotNil(t, bc)
assert.Nil(t, bc.Default())
}
}
func TestParsingFailsForMalformedJson(t *testing.T) {
for _, json := range []string{
`[{"name": "defau`, // Invalid JSON
`{"name": "default", "path": "/asdf/foo.klg"}`, // No array
`[{"name": "default"}]`, // Missing field
`[{"name": "default", "path": true}]`, // Wrong type
`[{"name": "default", "path": "foo.klg"}]`, // Relative path
} {
bc, err := NewBookmarksCollectionFromJson(json)
require.Nil(t, bc)
assert.Error(t, err)
assert.Equal(t, CONFIG_ERROR, err.Code())
}
}
func TestSerializeCollectionToJson(t *testing.T) {
jsonText := `[
{
"name": "default",
"path": "/asdf.klg"
},
{
"name": "foo",
"path": "/home/foo.klg"
}
]
`
bc, _ := NewBookmarksCollectionFromJson(jsonText)
assert.Equal(t, jsonText, bc.ToJson())
}
func TestSerializeEmptyCollectionToJson(t *testing.T) {
jsonText := ``
bc, _ := NewBookmarksCollectionFromJson(jsonText)
assert.Equal(t, jsonText, bc.ToJson())
}
07070100000019000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001600000000klog-6.6/klog/app/cli0707010000001A000081A40000000000000000000000016863F92F0000175D000000000000000000000000000000000000002300000000klog-6.6/klog/app/cli/bookmarks.gopackage cli
import (
"strings"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
)
type Bookmarks struct {
List BookmarksList `cmd:"" help:"Display all bookmarks."`
Ls BookmarksList `cmd:"" hidden:"" help:"Alias for 'list'."`
Set BookmarksSet `cmd:"" help:"Define a bookmark (or overwrite an existing one)."`
New BookmarksSet `cmd:"" hidden:"" help:"Alias for 'set'."`
Unset BookmarksUnset `cmd:"" help:"Remove a bookmark from the collection. (This only removes the bookmark, not the target file.)"`
Rm BookmarksUnset `cmd:"" hidden:"" help:"Alias for 'unset'."`
Clear BookmarksClear `cmd:"" help:"Clear entire bookmark collection. (This only removes the bookmarks, not the target files.)"`
Info BookmarksInfo `cmd:"" help:"Print file information for a bookmark."`
}
func (opt *Bookmarks) Help() string {
return `
Bookmarks allow you to interact with often-used files via an alias, independent of your current working directory.
You can think of a bookmark as some sort of klog-specific symlink, that’s always available when you invoke klog, and that resolves to the designated target file.
Use the subcommands below to set up and manage your bookmarks.
A bookmark name is denoted by the prefix '@'. For example, if you have a bookmark named '@work', that points to a .klg file, you can use klog like this:
klog total @work
klog start --summary 'Started new project' @work
klog edit @work
You can specify as many bookmarks as you want. There can also be one “unnamed” default bookmark (which internally is identified by the name '@default').
This is useful in case you only have one main file at a time, and allows you to use klog without any input arguments at all. E.g.:
klog total
klog start --summary 'Started new project'
When creating a bookmark, you can also create the respective target file by using the '--create' flag.
`
}
type BookmarksList struct{}
func (opt *BookmarksList) Run(ctx app.Context) app.Error {
bc, err := ctx.ReadBookmarks()
if err != nil {
return err
}
if bc.Count() == 0 {
ctx.Print("There are no bookmarks defined yet.\n")
return nil
}
for _, b := range bc.All() {
ctx.Print(b.Name().ValuePretty() + " -> " + b.Target().Path() + "\n")
}
return nil
}
type BookmarksInfo struct {
Dir bool `name:"dir" type:"string" help:"Display the directory"`
File bool `name:"file" type:"string" help:"Display the file name"`
Name string `arg:"" name:"bookmark" type:"string" predictor:"bookmark" help:"The path of the bookmark"`
}
func (opt *BookmarksInfo) Run(ctx app.Context) error {
bc, err := ctx.ReadBookmarks()
if err != nil {
return err
}
bookmark := bc.Get(app.NewName(opt.Name))
if bookmark == nil {
return app.NewErrorWithCode(
app.NO_SUCH_BOOKMARK_ERROR,
"No such bookmark",
"There is no bookmark with that alias",
nil,
)
}
if opt.Dir {
ctx.Print(bookmark.Target().Location() + "\n")
} else if opt.File {
ctx.Print(bookmark.Target().Name() + "\n")
} else {
ctx.Print(bookmark.Target().Path() + "\n")
}
return nil
}
type BookmarksSet struct {
File string `arg:"" type:"string" predictor:"file" help:".klg target file"`
Name string `arg:"" name:"bookmark" type:"string" optional:"1" help:"The name of the bookmark."`
Create bool `name:"create" short:"c" help:"Create the target file"`
Force bool `name:"force" help:"Force to set, even if target file does not exist or is invalid"`
util.QuietArgs
}
func (opt *BookmarksSet) Run(ctx app.Context) error {
file, err := app.NewFile(opt.File)
if err != nil {
return err
}
if opt.Create {
cErr := app.CreateEmptyFile(file)
if cErr != nil {
return cErr
}
}
if !opt.Force {
_, rErr := ctx.ReadInputs(app.FileOrBookmarkName(file.Path()))
if rErr != nil {
return app.NewErrorWithCode(
app.GENERAL_ERROR,
"Invalid bookmark target",
"Please check that the file exists and is valid",
rErr,
)
}
}
bookmark := (func() app.Bookmark {
if opt.Name == "" {
return app.NewDefaultBookmark(file)
}
return app.NewBookmark(opt.Name, file)
})()
didBookmarkAlreadyExist := false
mErr := ctx.ManipulateBookmarks(func(bc app.BookmarksCollection) app.Error {
didBookmarkAlreadyExist = bc.Get(bookmark.Name()) != nil
bc.Set(bookmark)
return nil
})
if mErr != nil {
return mErr
}
if !opt.Quiet {
if didBookmarkAlreadyExist {
ctx.Print("Changed bookmark")
} else {
ctx.Print("Created new bookmark")
}
if opt.Create {
ctx.Print(" and created target file")
}
ctx.Print(":\n")
}
ctx.Print(bookmark.Name().ValuePretty() + " -> " + bookmark.Target().Path() + "\n")
return nil
}
type BookmarksUnset struct {
// The name is not optional here, to avoid accidental invocations
Name string `arg:"" name:"bookmark" type:"string" predictor:"bookmark" help:"The name of the bookmark"`
util.QuietArgs
}
func (opt *BookmarksUnset) Run(ctx app.Context) error {
name := app.NewName(opt.Name)
err := ctx.ManipulateBookmarks(func(bc app.BookmarksCollection) app.Error {
hasRemoved := bc.Remove(name)
if !hasRemoved {
return app.NewErrorWithCode(
app.NO_SUCH_BOOKMARK_ERROR,
"No such bookmark",
"Name: "+name.ValuePretty(),
nil,
)
}
return nil
})
if err != nil {
return err
}
if !opt.Quiet {
ctx.Print("Removed bookmark " + name.ValuePretty() + "\n")
}
return nil
}
type BookmarksClear struct {
Yes bool `name:"yes" short:"y" help:"Skip confirmation"`
util.QuietArgs
}
func (opt *BookmarksClear) Run(ctx app.Context) error {
if !opt.Yes {
ctx.Print("Do you want to clear all bookmarks? [y/N] ")
confirmation, err := ctx.ReadLine()
if err != nil {
return err
}
if strings.ToLower(confirmation) != "y" {
return nil
}
}
err := ctx.ManipulateBookmarks(func(bc app.BookmarksCollection) app.Error {
bc.Clear()
return nil
})
if err != nil {
return err
}
if !opt.Quiet {
ctx.Print("Cleared all bookmarks\n")
}
return nil
}
0707010000001B000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/command0707010000001C000081A40000000000000000000000016863F92F000001D4000000000000000000000000000000000000002900000000klog-6.6/klog/app/cli/command/command.gopackage command
import (
"errors"
"github.com/kballard/go-shellquote"
)
type Command struct {
Bin string
Args []string
}
func NewFromString(command string) (Command, error) {
words, err := shellquote.Split(command)
if err != nil {
return Command{}, err
}
if len(words) == 0 {
return Command{}, errors.New("Empty command")
}
return New(words[0], words[1:]), nil
}
func New(bin string, args []string) Command {
return Command{Bin: bin, Args: args}
}
0707010000001D000081A40000000000000000000000016863F92F00000671000000000000000000000000000000000000002000000000klog-6.6/klog/app/cli/config.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
)
type Config struct {
util.NoStyleArgs
}
func (opt *Config) Help() string {
return `
You are able to configure some of klog’s behaviour by providing a configuration file.
If you run 'klog config', you can learn about the supported properties in the file, and which of those you have set.
You may use the output of that command as template for setting up your config file, as its format is valid syntax.
The configuration file is named 'config.ini' and resides in your klog config folder.
Run 'klog info config-folder' to learn where your klog config folder is located.
`
}
func (opt *Config) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
styler, _ := ctx.Serialise()
for i, e := range app.CONFIG_FILE_ENTRIES {
ctx.Print(styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(util.Reflower.Reflow(e.Help.Summary, []string{"# "})))
ctx.Print("\n")
ctx.Print(styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(util.Reflower.Reflow("Value: "+e.Help.Value, []string{"# - ", "# "})))
ctx.Print("\n")
ctx.Print(styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(util.Reflower.Reflow("Default: "+e.Help.Default, []string{"# - ", "# "})))
ctx.Print("\n")
ctx.Print(styler.Props(tf.StyleProps{Color: tf.RED}).Format(e.Name))
ctx.Print(" = ")
ctx.Print(styler.Props(tf.StyleProps{Color: tf.YELLOW}).Format(e.Value(ctx.Config())))
if i < len(app.CONFIG_FILE_ENTRIES)-1 {
ctx.Print("\n\n")
}
}
ctx.Print("\n")
return nil
}
0707010000001E000081A40000000000000000000000016863F92F00000687000000000000000000000000000000000000002000000000klog-6.6/klog/app/cli/create.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser/reconciling"
)
type Create struct {
ShouldTotal klog.ShouldTotal `name:"should" placeholder:"DURATION" help:"The should-total of the record."`
ShouldTotalAlias klog.ShouldTotal `name:"should-total" placeholder:"DURATION" hidden:""` // Alias for “canonical” term
Summary klog.RecordSummary `name:"summary" short:"s" placeholder:"TEXT" help:"Summary text for the new record."`
util.AtDateArgs
util.NoStyleArgs
util.WarnArgs
util.OutputFileArgs
}
func (opt *Create) Help() string {
return `
You can set a should-total value via '--should' and a record summary via '--summary'.
The new record is inserted into the file at the chronologically correct position.
(Assuming that the records are sorted from oldest to latest.)
`
}
func (opt *Create) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
date := opt.AtDate(ctx.Now())
additionalData := reconciling.AdditionalData{ShouldTotal: opt.GetShouldTotal(), Summary: opt.Summary}
if additionalData.ShouldTotal == nil {
ctx.Config().DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) {
additionalData.ShouldTotal = s
})
}
return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs},
[]reconciling.Creator{
reconciling.NewReconcilerForNewRecord(date, opt.DateFormat(ctx.Config()), additionalData),
},
)
}
func (opt *Create) GetShouldTotal() klog.ShouldTotal {
if opt.ShouldTotal != nil {
return opt.ShouldTotal
}
return opt.ShouldTotalAlias
}
0707010000001F000081A40000000000000000000000016863F92F00000EA0000000000000000000000000000000000000002500000000klog-6.6/klog/app/cli/create_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestCreate(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-01
4h33m
1920-02-02
9:00-12:00
`)._SetNow(1920, 2, 3, 15, 24)._Run((&Create{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-01
4h33m
1920-02-02
9:00-12:00
1920-02-03
`, state.writtenFileContents)
}
func TestCreateWithValues(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1975-12-31
1h
2h
1976-01-01
1h
`)._Run((&Create{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1976, 1, 2)},
ShouldTotal: klog.NewShouldTotal(5, 55),
Summary: klog.Ɀ_RecordSummary_("This is a", "new record!"),
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1975-12-31
1h
2h
1976-01-01
1h
1976-01-02 (5h55m!)
This is a
new record!
`, state.writtenFileContents)
}
func TestCreateWithShouldTotalAlias(t *testing.T) {
state, err := NewTestingContext()._SetRecords(``).
_SetNow(1976, 1, 1, 2, 2)._Run((&Create{
ShouldTotalAlias: klog.NewShouldTotal(0, 30),
}).Run)
require.Nil(t, err)
assert.Equal(t, "1976-01-01 (30m!)\n", state.writtenFileContents)
}
func TestCreateWithStyle(t *testing.T) {
t.Run("For empty file and no preferences, use recommended default.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(``).
_SetNow(1920, 2, 3, 15, 24)._Run((&Create{}).Run)
require.Nil(t, err)
assert.Equal(t, "1920-02-03\n", state.writtenFileContents)
})
t.Run("Without any preference, detect from file.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/01
4h33m
1920/02/02
9:00-12:00
`)._SetNow(1920, 2, 3, 15, 24)._Run((&Create{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/01
4h33m
1920/02/02
9:00-12:00
1920/02/03
`, state.writtenFileContents)
})
t.Run("Use preference from config file, if given.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/01
4h33m
1920/02/02
9:00-12:00
`)._SetFileConfig(`
date_format = YYYY-MM-DD
`)._SetNow(1920, 2, 3, 15, 24)._Run((&Create{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/01
4h33m
1920/02/02
9:00-12:00
1920-02-03
`, state.writtenFileContents)
})
t.Run("If explicit flag was provided, that takes ultimate precedence.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/01
4h33m
1920/02/02
9:00-12:00
`)._SetFileConfig(`
date_format = YYYY/MM/DD
`)._SetNow(1920, 2, 3, 15, 24)._Run((&Create{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 3)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/01
4h33m
1920/02/02
9:00-12:00
1920-02-03
`, state.writtenFileContents)
})
}
func TestCreateWithShouldTotalConfig(t *testing.T) {
t.Run("With should-total from config file", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-01
4h33m
1920-02-02
9:00-12:00
`)._SetFileConfig(`
default_should_total = 30m!
`)._SetNow(1920, 2, 3, 15, 24)._Run((&Create{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-01
4h33m
1920-02-02
9:00-12:00
1920-02-03 (30m!)
`, state.writtenFileContents)
})
t.Run("--should-total flag trumps should-total from config file", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-01
4h33m
1920-02-02
9:00-12:00
`)._SetFileConfig(`
default_should_total = 30m!
`)._SetNow(1920, 2, 3, 15, 24)._Run((&Create{
ShouldTotal: klog.NewShouldTotal(5, 55),
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-01
4h33m
1920-02-02
9:00-12:00
1920-02-03 (5h55m!)
`, state.writtenFileContents)
})
}
07070100000020000081A40000000000000000000000016863F92F000005A5000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/edit.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/command"
"github.com/jotaen/klog/klog/app/cli/util"
)
type Edit struct {
util.QuietArgs
util.OutputFileArgs
}
const hint = "You can specify your preferred editor via the $EDITOR environment variable, or via the klog config file."
func (opt *Edit) Help() string {
return hint
}
func (opt *Edit) Run(ctx app.Context) app.Error {
target, err := ctx.RetrieveTargetFile(opt.File)
if err != nil {
return err
}
explicitEditor, autoEditors := ctx.Editors()
if explicitEditor != "" {
c, cErr := command.NewFromString(explicitEditor)
if cErr != nil {
return app.NewError(
"Invalid editor setting",
"Please check the value for invalid syntax: "+explicitEditor,
cErr,
)
}
c.Args = append(c.Args, target.Path())
rErr := ctx.Execute(c)
if rErr != nil {
return app.NewError(
"Cannot open preferred editor",
"Editor command was: "+explicitEditor+"\nNote that if your editor path contains spaces, you have to quote it.",
nil,
)
}
} else {
hasSucceeded := false
for _, c := range autoEditors {
c.Args = append(c.Args, target.Path())
rErr := ctx.Execute(c)
if rErr == nil {
hasSucceeded = true
break
}
}
if !hasSucceeded {
return app.NewError(
"Cannot open any editor",
hint,
nil,
)
}
if !opt.Quiet {
ctx.Print(hint + "\n")
}
}
return nil
}
07070100000021000081A40000000000000000000000016863F92F00001329000000000000000000000000000000000000002300000000klog-6.6/klog/app/cli/edit_test.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/command"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"strings"
"testing"
)
func TestEditWithAutoEditor(t *testing.T) {
spy := newCommandSpy(nil)
state, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, "")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 1, spy.Count)
assert.Equal(t, "editor", spy.LastCmd.Bin)
assert.Equal(t, []string{"--file", "/tmp/file.klg"}, spy.LastCmd.Args)
// Hint was printed:
assert.Equal(t, hint, strings.Trim(state.printBuffer, "\n"))
}
func TestFirstAutoEditorSucceeds(t *testing.T) {
spy := newCommandSpy(func(c command.Command) app.Error {
if c.Bin == "editor2" {
return nil
}
return app.NewError("Error", "Error", nil)
})
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor1", []string{"--file"}),
command.New("editor2", []string{"--file"}),
command.New("editor3", []string{"--file"}),
}, "")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 2, spy.Count)
assert.Equal(t, "editor2", spy.LastCmd.Bin)
assert.Equal(t, []string{"--file", "/tmp/file.klg"}, spy.LastCmd.Args)
}
func TestEditWithExplicitEditor(t *testing.T) {
spy := newCommandSpy(nil)
state, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, "myedit")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 1, spy.Count)
assert.Equal(t, "myedit", spy.LastCmd.Bin)
assert.Equal(t, []string{"/tmp/file.klg"}, spy.LastCmd.Args)
// No hint was printed:
assert.Equal(t, "", state.printBuffer)
}
func TestEditWithExplicitEditorWithSpaces(t *testing.T) {
spy := newCommandSpy(nil)
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, "'C:\\Program Files\\Sublime Text'")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 1, spy.Count)
assert.Equal(t, "C:\\Program Files\\Sublime Text", spy.LastCmd.Bin)
assert.Equal(t, []string{"/tmp/file.klg"}, spy.LastCmd.Args)
}
func TestEditWithExplicitEditorWithAdditionalArgs(t *testing.T) {
spy := newCommandSpy(nil)
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, "myedit -f")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 1, spy.Count)
assert.Equal(t, "myedit", spy.LastCmd.Bin)
assert.Equal(t, []string{"-f", "/tmp/file.klg"}, spy.LastCmd.Args)
}
func TestEditFailsWithExplicitEditorThatHasMalformedSyntax(t *testing.T) {
for _, editor := range []string{
// Unmatched single quote:
`'myedit`,
// Unmatched double quote
`myedit --arg "foo`,
} {
spy := newCommandSpy(nil)
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, editor)._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Error(t, err)
assert.Equal(t, "Invalid editor setting", err.Error())
}
}
func TestFailsIfExplicitEditorCrashes(t *testing.T) {
spy := newCommandSpy(func(c command.Command) app.Error {
return app.NewError("Error", "Error", nil)
})
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, "myedit")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Error(t, err)
assert.Equal(t, "Cannot open preferred editor", err.Error())
}
func TestFailsIfAutoEditorsUnsuccessful(t *testing.T) {
spy := newCommandSpy(func(c command.Command) app.Error {
return app.NewError("Error", "Error", nil)
})
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor1", []string{"--file"}),
command.New("editor2", nil),
}, "")._SetExecute(spy.Execute)._Run((&Edit{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Error(t, err)
assert.Equal(t, 2, spy.Count)
assert.Equal(t, "Cannot open any editor", err.Error())
}
func TestEditFailsWithoutFile(t *testing.T) {
spy := newCommandSpy(nil)
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("editor", []string{"--file"}),
}, "")._SetExecute(spy.Execute)._Run((&Edit{}).Run)
require.Error(t, err)
assert.Equal(t, 0, spy.Count)
}
07070100000022000081A40000000000000000000000016863F92F00000295000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/goto.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
)
type Goto struct {
util.OutputFileArgs
}
func (opt *Goto) Run(ctx app.Context) app.Error {
target, rErr := ctx.RetrieveTargetFile(opt.File)
if rErr != nil {
return rErr
}
hasSucceeded := false
for _, c := range ctx.FileExplorers() {
c.Args = append(c.Args, target.Location())
cErr := ctx.Execute(c)
if cErr != nil {
continue
}
hasSucceeded = true
break
}
if !hasSucceeded {
return app.NewError(
"Failed to open file browser",
"Opening a file browser doesn’t seem possible on your system.",
nil,
)
}
return nil
}
07070100000023000081A40000000000000000000000016863F92F0000083C000000000000000000000000000000000000002300000000klog-6.6/klog/app/cli/goto_test.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/command"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestGotoLocation(t *testing.T) {
spy := newCommandSpy(nil)
_, err := NewTestingContext()._SetFileExplorers([]command.Command{
command.New("goto", []string{"--file"}),
})._SetExecute(spy.Execute)._Run((&Goto{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 1, spy.Count)
assert.Equal(t, "goto", spy.LastCmd.Bin)
assert.Equal(t, []string{"--file", "/tmp"}, spy.LastCmd.Args)
}
func TestGotoLocationFirstSucceeds(t *testing.T) {
spy := newCommandSpy(func(c command.Command) app.Error {
if c.Bin == "goto2" {
return nil
}
return app.NewError("Error", "Error", nil)
})
_, err := NewTestingContext()._SetFileExplorers([]command.Command{
command.New("goto1", []string{"--file"}),
command.New("goto2", nil),
command.New("goto3", []string{"--file"}),
})._SetExecute(spy.Execute)._Run((&Goto{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Nil(t, err)
assert.Equal(t, 2, spy.Count)
assert.Equal(t, "goto2", spy.LastCmd.Bin)
assert.Equal(t, []string{"/tmp"}, spy.LastCmd.Args)
}
func TestGotoFails(t *testing.T) {
spy := newCommandSpy(func(_ command.Command) app.Error {
return app.NewError("Error", "Error", nil)
})
_, err := NewTestingContext()._SetFileExplorers([]command.Command{
command.New("goto", []string{"--file"}),
})._SetExecute(spy.Execute)._Run((&Goto{
OutputFileArgs: util.OutputFileArgs{File: "/tmp/file.klg"},
}).Run)
require.Error(t, err)
assert.Equal(t, 1, spy.Count)
assert.Equal(t, "Failed to open file browser", err.Error())
}
func TestGotoFailsWithoutFile(t *testing.T) {
spy := newCommandSpy(nil)
_, err := NewTestingContext()._SetEditors([]command.Command{
command.New("goto", []string{"--file"}),
}, "")._SetExecute(spy.Execute)._Run((&Goto{}).Run)
require.Error(t, err)
assert.Equal(t, 0, spy.Count)
}
07070100000024000081A40000000000000000000000016863F92F0000106B000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/index.go/*
Package cli contains handlers for all available commands.
*/
package cli
import (
"github.com/jotaen/klog/klog/app"
kc "github.com/jotaen/kong-completion"
)
// Guideline for help texts and descriptions:
// - Command and flag descriptions are phrased in imperative style, and they
// end in a period. Examples:
// - Pretty-print records.
// - Sort output by date.
// - Code and literal values are wrapped in single quotes (')
// - Types of flag values are spelled in UPPER-CASE. The type is explained in
// the flag description. For complex types, there should also be an example.
type Cli struct {
Default Default `hidden:"" cmd:"" default:"withargs" help:""`
// Evaluate Files
Print Print `cmd:"" name:"print" group:"Evaluate Files" help:"Pretty-print records."`
Total Total `cmd:"" name:"total" group:"Evaluate Files" help:"Evaluate the total time."`
Report Report `cmd:"" name:"report" group:"Evaluate Files" help:"Print an aggregated calendar report."`
Tags Tags `cmd:"" name:"tags" group:"Evaluate Files" help:"Print total times aggregated by tags."`
Today Today `cmd:"" name:"today" group:"Evaluate Files" help:"Evaluate the current day."`
// Manipulate Files
Track Track `cmd:"" name:"track" group:"Manipulate Files" help:"Add a new entry to a record."`
Start Start `cmd:"" name:"start" group:"Manipulate Files" aliases:"in" help:"Start a new open time range."`
Stop Stop `cmd:"" name:"stop" group:"Manipulate Files" aliases:"out" help:"Close the open time range."`
Pause Pause `cmd:"" name:"pause" group:"Manipulate Files" help:"Pause the open time range."`
Switch Switch `cmd:"" name:"switch" group:"Manipulate Files" help:"Close open range and starts a new one."`
Create Create `cmd:"" name:"create" group:"Manipulate Files" help:"Create a new, empty record."`
// Manage Files
Bookmarks Bookmarks `cmd:"" name:"bookmarks" group:"Manage Files" aliases:"bk" help:"Named aliases for often-used files."`
Bookmark Bookmarks `cmd:"" name:"bookmark" hidden:"" help:"(Alias)"` // Hidden alias for convenience / typo
Edit Edit `cmd:"" name:"edit" group:"Manage Files" help:"Open a file or bookmark in your editor."`
Goto Goto `cmd:"" name:"goto" group:"Manage Files" help:"Open the file explorer at a file or bookmark."`
// Misc
Version Version `cmd:"" name:"version" group:"Misc" help:"Print version info and check for updates."`
Config Config `cmd:"" name:"config" group:"Misc" help:"Print the current configuration."`
Info Info `cmd:"" name:"info" group:"Misc" help:"Print information about klog."`
Json Json `cmd:"" name:"json" group:"Misc" help:"Convert records to JSON."`
Completion kc.Completion `cmd:"" name:"completion" group:"Misc" help:"Output shell code for enabling tab completion."`
}
type Default struct {
Version bool `short:"v" name:"version" help:"Alias for 'klog version'."`
}
func (opt *Default) Help() string {
return `
klog: command line app for time tracking with plain-text files. See also ` + KLOG_WEBSITE_URL + `
Time-tracking data is stored in files ending in the '.klg' extension.
You can use the subcommands below to evaluate, manipulate and manage your files.
Use the '--help' flag on the subcommands to learn more.
You can specify input data in one of these 3 ways:
- by passing the name of a file or a bookmark,
- by piping data to stdin,
- or by setting up a default bookmark.
Run 'klog bookmarks --help' to learn about bookmark usage.
Some general notes on flag usage:
- For flags with values, you can either use a space or an equals sign as delimiter. E.g., both '--flag value' and '--flag=value' are fine.
- For shorthand flags with values, you specify the value without a delimiter. E.g., '-n2' (if the long form is '--number 2').
- For shorthand flags without values, you can compact them. E.g., '-abc' is the same as '-a -b -c'.
`
}
func (opt *Default) Run(ctx app.Context) app.Error {
if opt.Version {
versionCmd := Version{}
return versionCmd.Run(ctx)
}
ctx.Print("klog: command line app for time tracking with plain-text files.\n")
ctx.Print("Run 'klog --help' to learn usage.\n")
return nil
}
07070100000025000081A40000000000000000000000016863F92F0000082A000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/info.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"path/filepath"
"strings"
)
type Info struct {
Spec InfoSpec `cmd:"" name:"spec" help:"Print the .klg file format specification."`
License InfoLicense `cmd:"" name:"license" help:"Print license / copyright information."`
ConfigFolder InfoConfigFolder `cmd:"" name:"config-folder" help:"Print the path of the klog config folder."`
}
func (opt *Info) Help() string {
return `
Run 'klog info config-folder' to see the location of your klog config folder.
The location of the config folder depends on your operating system and environment settings.
You can customise the folder’s location via environment variables – run the command to learn which ones klog relies on.
The config folder is used to store two files:
- 'config.ini' (optional) – you can create this file manually to override some of klog’s default behaviour. Run 'klog config' to learn more.
- 'bookmarks.json' (optional) – if you use bookmarks, 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.
Run 'klog info spec' to read the formal specification of the klog file format.
If you want to review klog’s license and copyright information, run 'klog info license'.
`
}
type InfoConfigFolder struct {
util.QuietArgs
}
func (opt *InfoConfigFolder) Run(ctx app.Context) app.Error {
ctx.Print(ctx.KlogConfigFolder().Path() + "\n")
if !opt.Quiet {
lookups := make([]string, len(app.KLOG_CONFIG_FOLDER))
for i, f := range app.KLOG_CONFIG_FOLDER {
lookups[i] = filepath.Join(f.EnvVarSymbol(), f.Location)
}
ctx.Print("(Lookup order: " + strings.Join(lookups, ", ") + ")\n")
}
return nil
}
type InfoSpec struct{}
func (opt *InfoSpec) Run(ctx app.Context) app.Error {
ctx.Print(ctx.Meta().Specification + "\n")
return nil
}
type InfoLicense struct{}
func (opt *InfoLicense) Run(ctx app.Context) app.Error {
ctx.Print(ctx.Meta().License + "\n")
return nil
}
07070100000026000081A40000000000000000000000016863F92F0000054D000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/json.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser/json"
)
type Json struct {
Pretty bool `name:"pretty" help:"Pretty-print output."`
util.NowArgs
util.FilterArgs
util.SortArgs
util.InputFilesArgs
}
func (opt *Json) Help() string {
return `
The output structure is a JSON object which contains two properties at the top level: 'records' and 'errors'.
If the file is valid, 'records' is an array containing a JSON object for each record, and 'errors' is 'null'.
If the file has syntax errors, 'records' is 'null', and 'errors' contains an array of error objects.
The structure of the 'record' and 'error' objects is always uniform and should be self-explanatory.
You can best explore it by running the command with the --pretty flag.
`
}
func (opt *Json) Run(ctx app.Context) app.Error {
records, err := ctx.ReadInputs(opt.File...)
if err != nil {
parserErrs, isParserErr := err.(app.ParserErrors)
if isParserErr {
ctx.Print(json.ToJson(nil, parserErrs.All(), opt.Pretty) + "\n")
return nil
}
return err
}
now := ctx.Now()
nErr := opt.ApplyNow(now, records...)
if nErr != nil {
return nErr
}
records = opt.ApplyFilter(now, records)
records = opt.ApplySort(records)
ctx.Print(json.ToJson(records, nil, opt.Pretty) + "\n")
return nil
}
07070100000027000081A40000000000000000000000016863F92F0000129E000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/pause.gopackage cli
import (
"fmt"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/reconciling"
"strings"
gotime "time"
)
type Pause struct {
Summary klog.EntrySummary `name:"summary" short:"s" placeholder:"TEXT" help:"Summary text for the pause entry."`
NoAppendTags bool `name:"no-tags" help:"Do not automatically take over (append) tags from open range."`
Extend bool `name:"extend" short:"e" help:"Extend latest pause, instead of adding a new pause entry."`
util.NoStyleArgs
util.WarnArgs
util.OutputFileArgs
}
func (opt *Pause) Help() string {
return `
This command is only available for records that contain an open time range (i.e., an ongoing activity).
The pause is basically a new entry with a negative duration, which is appended to the record.
The command is blocking and keeps updating (incrementing) the duration of the pause entry until the shell process is exited via Ctrl^C.
The file will be written into once per minute.
If you wish to extend an existing pause, you can use the '--extend' flag. In this case it will increment the last pause entry in the record, instead of appending a new entry.
If the open range in the record contains tags in its summary, then these will automatically be taken over and appended to the pause entry.
You can opt out of this behaviour with the '--no-tags' flag.
`
}
func (opt *Pause) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
if opt.Extend && opt.Summary != nil {
return app.NewError(
"Illegal flag combination",
"It’s not possible to combine --extend with --summary",
nil,
)
}
today := klog.NewDateFromGo(ctx.Now())
doReconcile := func(reconcile reconciling.Reconcile) (*reconciling.Result, app.Error) {
return ctx.ReconcileFile(
opt.OutputFileArgs.File,
[]reconciling.Creator{
reconciling.NewReconcilerAtRecord(today),
reconciling.NewReconcilerAtRecord(today.PlusDays(-1)),
},
reconcile,
)
}
// Initial run:
// Ensure that an open range exists, and set up the pause entry:
// - Without `--extend`, append a new entry, including the summary
// - With `--extend`, find a pause and append the summary
lastResult, err := doReconcile(func(reconciler *reconciling.Reconciler) error {
if opt.Extend {
return reconciler.ExtendPause(klog.NewDuration(0, 0))
}
return reconciler.AppendPause(opt.Summary, !opt.NoAppendTags)
})
if err != nil {
return err
}
// Subsequent runs:
// We don’t rely on the accumulated counter, because then it might also accumulate
// imprecisions over time. Therefore, we always base the increment off the initial
// start time. Also, if the computer is set to sleep, it should properly “recover”
// afterwards.
start := ctx.Now()
minsCaptured := 0 // The amount of minutes that have already been written into the file.
return util.WithRepeat(ctx.Print, 500*gotime.Millisecond, func(counter int64) app.Error {
uncapturedIncrement := diffInMinutes(ctx.Now(), start) - minsCaptured
ctx.Debug(func() {
ctx.Print(fmt.Sprintf("Started: %s\n", start))
ctx.Print(fmt.Sprintf("Now: %s\n", ctx.Now()))
ctx.Print(fmt.Sprintf("Incr.: %d\n", uncapturedIncrement))
ctx.Print("\n")
})
if uncapturedIncrement > 0 {
lastResult, err = doReconcile(func(reconciler *reconciling.Reconciler) error {
// Don’t add the summary here, as we already appended it in the initial run.
return reconciler.ExtendPause(klog.NewDuration(0, -1*uncapturedIncrement))
})
minsCaptured += uncapturedIncrement
if err != nil {
return err
}
}
dots := strings.Repeat(".", int(counter%4))
styler, serialiser := ctx.Serialise()
ctx.Print("" +
"Pausing for " +
// Always print number in red, but without sign
styler.Props(tf.StyleProps{Color: tf.RED}).Format(klog.NewDuration(0, minsCaptured).ToString()) +
fmt.Sprintf("%-4s", dots) +
"(since " +
klog.NewTimeFromGo(start).ToString() +
")\n")
ctx.Print("\n" + parser.SerialiseRecords(serialiser, lastResult.Record).ToString() + "\n")
if counter < 14 {
// Display exit hint for a couple of seconds.
ctx.Print("\n")
ctx.Print("Press ^C to stop\n")
}
return nil
})
}
// diffInMinutes computes the “wall-clock” difference between two times.
// Note, the built-in `Time.Sub` function computes the difference of the
// underlying monotonic time counter, which would yield incorrect results
// in case the monotonic timer was suspended, e.g. due to sleep.
func diffInMinutes(t1 gotime.Time, t2 gotime.Time) int {
return int(t1.Unix()-t2.Unix()) / 60
}
07070100000028000081A40000000000000000000000016863F92F00000BC1000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/print.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/service"
"strings"
)
type Print struct {
WithTotals bool `name:"with-totals" help:"Amend output with evaluated total times."`
util.FilterArgs
util.SortArgs
util.WarnArgs
util.NoStyleArgs
util.InputFilesArgs
}
func (opt *Print) Help() string {
return `
Outputs data on the terminal, by default with syntax-highlighting turned on.
Note that the output doesn’t resemble the file byte by byte, but the command may apply some minor clean-ups of the formatting.
If run with filter flags, it only outputs those entries that match the filter clauses.
You can optionally also sort the records, or print out the total times for each record and entry.
`
}
func (opt *Print) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
styler, serialser := ctx.Serialise()
records, err := ctx.ReadInputs(opt.File...)
if err != nil {
return err
}
now := ctx.Now()
records = opt.ApplyFilter(now, records)
if len(records) == 0 {
return nil
}
records = opt.ApplySort(records)
serialisedRecords := parser.SerialiseRecords(serialser, records...)
output := func() string {
if opt.WithTotals {
return printWithDurations(styler, serialisedRecords)
}
return "\n" + serialisedRecords.ToString()
}()
ctx.Print(output + "\n")
opt.WarnArgs.PrintWarnings(ctx, records, nil)
return nil
}
func printWithDurations(styler tf.Styler, ls parser.Lines) string {
type Prefix struct {
d klog.Duration
isSub bool
}
var prefixes []*Prefix
maxColumnLength := 0
var previousRecord klog.Record
previousEntry := -1
for _, l := range ls {
prefix := func() *Prefix {
if l.Record == nil {
previousRecord = nil
previousEntry = -1
return nil
}
if previousRecord == nil {
previousRecord = l.Record
return &Prefix{service.Total(l.Record), false}
}
if l.EntryI != -1 && l.EntryI != previousEntry {
previousEntry = l.EntryI
return &Prefix{l.Record.Entries()[l.EntryI].Duration(), true}
} else {
return nil
}
}()
prefixes = append(prefixes, prefix)
if prefix != nil && len(prefix.d.ToString()) > maxColumnLength {
maxColumnLength = len(prefix.d.ToString())
}
}
result := "\n"
for i, l := range ls {
p := prefixes[i]
if l.Record == nil {
result += "\n"
continue
}
result += func() string {
if p == nil {
return strings.Repeat(" ", maxColumnLength+1)
}
length := len(p.d.ToString())
value := ""
if p.isSub {
value += styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(p.d.ToString())
} else {
value += styler.Props(tf.StyleProps{IsUnderlined: true}).Format(p.d.ToString())
}
return strings.Repeat(" ", maxColumnLength-length+1) + value
}()
result += " | "
result += l.Text
result += "\n"
}
return result
}
07070100000029000081A40000000000000000000000016863F92F00000A00000000000000000000000000000000000000002400000000klog-6.6/klog/app/cli/print_test.gopackage cli
import (
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestPrintOutEmptyInput(t *testing.T) {
{
state, err := NewTestingContext()._SetRecords(``)._Run((&Print{}).Run)
require.Nil(t, err)
assert.Equal(t, "", state.printBuffer)
}
{
state, err := NewTestingContext()._SetRecords(``)._Run((&Print{
WithTotals: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, "", state.printBuffer)
}
}
func TestPrintOutRecord(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-01-31
Hello #world
1h
`)._Run((&Print{}).Run)
require.Nil(t, err)
assert.Equal(t, `
2018-01-31
Hello #world
1h
`, state.printBuffer)
}
func TestPrintOutRecordInCanonicalFormat(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-01-31
Hello #world
09:00-13:00
22:00 - 24:00
60m
2h0m
0h
`)._Run((&Print{}).Run)
require.Nil(t, err)
assert.Equal(t, `
2018-01-31
Hello #world
9:00-13:00
22:00 - 0:00>
1h
2h
0m
`, state.printBuffer)
}
func TestPrintOutRecordsInChronologicalOrder(t *testing.T) {
original := "2018-02-01\n\n2018-01-30\n\n2018-01-31"
stateUnsorted, _ := NewTestingContext().
_SetRecords(original).
_Run((&Print{}).Run)
assert.Equal(t, "\n"+original+"\n\n", stateUnsorted.printBuffer)
stateSortedAsc, _ := NewTestingContext().
_SetRecords(original).
_Run((&Print{SortArgs: util.SortArgs{Sort: "asc"}}).Run)
assert.Equal(t, "\n2018-01-30\n\n2018-01-31\n\n2018-02-01\n\n", stateSortedAsc.printBuffer)
stateSortedDesc, _ := NewTestingContext().
_SetRecords(original).
_Run((&Print{SortArgs: util.SortArgs{Sort: "desc"}}).Run)
assert.Equal(t, "\n2018-02-01\n\n2018-01-31\n\n2018-01-30\n\n", stateSortedDesc.printBuffer)
}
func TestPrintRecordsWithDurations(t *testing.T) {
state, err := NewTestingContext()._SetNow(2018, 02, 07, 19, 00)._SetRecords(`
2018-01-31
Hello #world
Test test test
1h
2018-02-04
10:00 - 17:22
+6h
-5m
2018-02-07
35m
Foo
Bar
18:00 - ? I just
started something
`)._Run((&Print{
WithTotals: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1h | 2018-01-31
| Hello #world
| Test test test
1h | 1h
13h17m | 2018-02-04
7h22m | 10:00 - 17:22
6h | +6h
-5m | -5m
35m | 2018-02-07
35m | 35m
| Foo
| Bar
0m | 18:00 - ? I just
| started something
`, state.printBuffer)
}
0707010000002A000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001D00000000klog-6.6/klog/app/cli/report0707010000002B000081A40000000000000000000000016863F92F000018A7000000000000000000000000000000000000002000000000klog-6.6/klog/app/cli/report.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/report"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
"github.com/jotaen/klog/klog/service/period"
"math"
"strings"
)
type Report struct {
AggregateBy string `name:"aggregate" placeholder:"KIND" short:"a" help:"How to aggregate the data. KIND can be 'day' (default), 'week', 'month', 'quarter' or 'year'." enum:"DAY,day,d,WEEK,week,w,MONTH,month,m,QUARTER,quarter,q,YEAR,year,y," default:"day"`
Fill bool `name:"fill" short:"f" help:"Fill any calendar gaps and show a consecutive sequence of dates."`
Chart bool `name:"chart" short:"c" help:"Includes a bar chart rendering, to aid visual comparison."`
ChartResolution int `name:"chart-res" help:"Configure the chart resolution. INT must be a positive integer, denoting the minutes per rendered block."`
util.DiffArgs
util.FilterArgs
util.NowArgs
util.DecimalArgs
util.WarnArgs
util.NoStyleArgs
util.InputFilesArgs
}
func (opt *Report) Help() string {
return `
It aggregates the totals by period, and prints the respective values chronologically (from oldest to latest).
The default aggregation is by day, but you can choose other periods via the '--aggregate' flag.
The report skips all days (weeks, months, etc.) if no data is available for them.
If you want a consecutive, chronological stream, you can use the '--fill' flag.
`
}
func (opt *Report) Run(ctx app.Context) app.Error {
opt.DecimalArgs.Apply(&ctx)
opt.NoStyleArgs.Apply(&ctx)
cErr := opt.canonicaliseOpts()
if cErr != nil {
return cErr
}
_, serialiser := ctx.Serialise()
records, err := ctx.ReadInputs(opt.File...)
if err != nil {
return err
}
now := ctx.Now()
records = opt.ApplyFilter(now, records)
if len(records) == 0 {
return nil
}
nErr := opt.ApplyNow(now, records...)
if nErr != nil {
return nErr
}
records = service.Sort(records, true)
aggregator := opt.aggregator()
recordGroups, dates := groupByDate(aggregator.DateHash, records)
if opt.Fill {
dates = allDatesRange(records[0].Date(), records[len(records)-1].Date())
}
// Table setup
numberOfValueColumns := func() int {
n := 1
if opt.Diff {
n += 2
}
if opt.Chart {
n += 1
}
return n
}()
table := tf.NewTable(
aggregator.NumberOfPrefixColumns()+numberOfValueColumns,
" ",
)
// Header
aggregator.OnHeaderPrefix(table)
table.CellR(" Total")
if opt.Diff {
table.CellR(" Should").CellR(" Diff")
}
if opt.Chart {
table.Skip(1)
}
// Rows
hashesAlreadyProcessed := make(map[period.Hash]bool)
for _, date := range dates {
hash := aggregator.DateHash(date)
if hashesAlreadyProcessed[hash] {
continue
}
hashesAlreadyProcessed[hash] = true
aggregator.OnRowPrefix(table, date)
rs := recordGroups[hash]
if len(rs) == 0 {
table.Skip(numberOfValueColumns)
continue
}
total := service.Total(rs...)
table.CellR(serialiser.Duration(total))
if opt.Diff {
should := service.ShouldTotalSum(rs...)
diff := service.Diff(should, total)
table.CellR(serialiser.ShouldTotal(should)).CellR(serialiser.SignedDuration(diff))
}
if opt.Chart {
table.CellL(" " + renderBar(opt.ChartResolution, total))
}
}
// Line
table.Skip(aggregator.NumberOfPrefixColumns()).Fill("=")
if opt.Diff {
table.Fill("=").Fill("=")
}
if opt.Chart {
table.Skip(1)
}
// Footer
grandTotal := service.Total(records...)
table.Skip(aggregator.NumberOfPrefixColumns())
table.CellR(serialiser.Duration(grandTotal))
if opt.Diff {
grandShould := service.ShouldTotalSum(records...)
grandDiff := service.Diff(grandShould, grandTotal)
table.CellR(serialiser.ShouldTotal(grandShould)).CellR(serialiser.SignedDuration(grandDiff))
}
if opt.Chart {
table.Skip(1)
}
table.Collect(ctx.Print)
opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings())
return nil
}
func (opt *Report) canonicaliseOpts() app.Error {
if opt.AggregateBy == "" {
opt.AggregateBy = "d"
} else {
opt.AggregateBy = strings.ToLower(opt.AggregateBy[:1])
}
if opt.ChartResolution == 0 {
// If the resolution wasn’t explicitly specified, use a default value
// that aims for a good balance between granularity and overall row width
// in the context of the desired aggregation mode.
switch opt.AggregateBy {
case "y":
opt.ChartResolution = 60 * 8 * 7 // Full working week
case "q":
opt.ChartResolution = 60 * 8 // Full working day
case "m":
opt.ChartResolution = 60 * 4 // Half working day
case "w":
opt.ChartResolution = 60
default: // "d"
opt.ChartResolution = 15
}
} else if opt.ChartResolution > 0 {
// When chart resolution is specified, automatically assume --chart
// to be given as well.
opt.Chart = true
} else if opt.ChartResolution < 0 {
return app.NewErrorWithCode(app.LOGICAL_ERROR, "Invalid resolution", "The resolution must be a positive integer", nil)
}
return nil
}
func (opt *Report) aggregator() report.Aggregator {
switch opt.AggregateBy {
case "y":
return report.NewYearAggregator()
case "q":
return report.NewQuarterAggregator()
case "m":
return report.NewMonthAggregator()
case "w":
return report.NewWeekAggregator()
default: // "d"
return report.NewDayAggregator()
}
}
func allDatesRange(from klog.Date, to klog.Date) []klog.Date {
result := []klog.Date{from}
for {
last := result[len(result)-1]
if last.IsAfterOrEqual(to) {
break
}
result = append(result, last.PlusDays(1))
}
return result
}
func groupByDate(hashProvider func(klog.Date) period.Hash, rs []klog.Record) (map[period.Hash][]klog.Record, []klog.Date) {
days := make(map[period.Hash][]klog.Record, len(rs))
var order []klog.Date
for _, r := range rs {
h := hashProvider(r.Date())
if _, ok := days[h]; !ok {
days[h] = []klog.Record{}
order = append(order, r.Date())
}
days[h] = append(days[h], r)
}
return days, order
}
func renderBar(minutesPerUnit int, d klog.Duration) string {
block := "▇"
blocksCount := func() int {
mins := d.InMinutes()
if mins <= 0 {
return 0
}
return int(math.Ceil(float64(mins) / float64(minutesPerUnit)))
}()
return strings.Repeat(block, blocksCount)
}
0707010000002C000081A40000000000000000000000016863F92F00000176000000000000000000000000000000000000002B00000000klog-6.6/klog/app/cli/report/aggregator.go/*
Package report is a utility for the report command.
*/
package report
import (
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service/period"
)
type Aggregator interface {
NumberOfPrefixColumns() int
DateHash(klog.Date) period.Hash
OnHeaderPrefix(*tf.Table)
OnRowPrefix(*tf.Table, klog.Date)
}
0707010000002D000081A40000000000000000000000016863F92F00000485000000000000000000000000000000000000002400000000klog-6.6/klog/app/cli/report/day.gopackage report
import (
"fmt"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service/period"
)
type dayAggregator struct {
y int
m int
}
func NewDayAggregator() Aggregator {
return &dayAggregator{-1, -1}
}
func (a *dayAggregator) NumberOfPrefixColumns() int {
return 4
}
func (a *dayAggregator) DateHash(date klog.Date) period.Hash {
return period.Hash(period.NewDayFromDate(date).Hash())
}
func (a *dayAggregator) OnHeaderPrefix(table *tf.Table) {
table.
CellL(" "). // 2020
CellL(" "). // Dec
CellL(" "). // Sun
CellR(" ") // 17.
}
func (a *dayAggregator) OnRowPrefix(table *tf.Table, date klog.Date) {
// Year
if date.Year() != a.y {
a.m = -1 // force month to be recalculated
table.CellR(fmt.Sprint(date.Year()))
a.y = date.Year()
} else {
table.Skip(1)
}
// Month
if date.Month() != a.m {
a.m = date.Month()
table.CellR(util.PrettyMonth(a.m)[:3])
} else {
table.Skip(1)
}
// Day
table.CellR(util.PrettyDay(date.Weekday())[:3]).CellR(fmt.Sprintf("%2v.", date.Day()))
}
0707010000002E000081A40000000000000000000000016863F92F00000380000000000000000000000000000000000000002600000000klog-6.6/klog/app/cli/report/month.gopackage report
import (
"fmt"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service/period"
)
type monthAggregator struct {
y int
}
func NewMonthAggregator() Aggregator {
return &monthAggregator{-1}
}
func (a *monthAggregator) NumberOfPrefixColumns() int {
return 2
}
func (a *monthAggregator) DateHash(date klog.Date) period.Hash {
return period.Hash(period.NewMonthFromDate(date).Hash())
}
func (a *monthAggregator) OnHeaderPrefix(table *tf.Table) {
table.
CellL(" "). // 2020
CellL(" ") // Dec
}
func (a *monthAggregator) OnRowPrefix(table *tf.Table, date klog.Date) {
// Year
if date.Year() != a.y {
table.CellR(fmt.Sprint(date.Year()))
a.y = date.Year()
} else {
table.Skip(1)
}
// Month
table.CellR(util.PrettyMonth(date.Month())[:3])
}
0707010000002F000081A40000000000000000000000016863F92F00000366000000000000000000000000000000000000002800000000klog-6.6/klog/app/cli/report/quarter.gopackage report
import (
"fmt"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service/period"
)
type quarterAggregator struct {
y int
}
func NewQuarterAggregator() Aggregator {
return &quarterAggregator{-1}
}
func (a *quarterAggregator) NumberOfPrefixColumns() int {
return 2
}
func (a *quarterAggregator) DateHash(date klog.Date) period.Hash {
return period.Hash(period.NewQuarterFromDate(date).Hash())
}
func (a *quarterAggregator) OnHeaderPrefix(table *tf.Table) {
table.
CellL(" "). // 2020
CellL(" ") // Q2
}
func (a *quarterAggregator) OnRowPrefix(table *tf.Table, date klog.Date) {
// Year
if date.Year() != a.y {
table.CellR(fmt.Sprint(date.Year()))
a.y = date.Year()
} else {
table.Skip(1)
}
// Quarter
table.CellR(fmt.Sprintf("Q%1v", date.Quarter()))
}
07070100000030000081A40000000000000000000000016863F92F0000034B000000000000000000000000000000000000002500000000klog-6.6/klog/app/cli/report/week.gopackage report
import (
"fmt"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service/period"
)
type weekAggregator struct {
y int
}
func NewWeekAggregator() Aggregator {
return &weekAggregator{-1}
}
func (a *weekAggregator) NumberOfPrefixColumns() int {
return 2
}
func (a *weekAggregator) DateHash(date klog.Date) period.Hash {
return period.Hash(period.NewWeekFromDate(date).Hash())
}
func (a *weekAggregator) OnHeaderPrefix(table *tf.Table) {
table.
CellL(" "). // 2020
CellL(" ") // Week 33
}
func (a *weekAggregator) OnRowPrefix(table *tf.Table, date klog.Date) {
year, week := date.WeekNumber()
if year != a.y {
table.CellR(fmt.Sprint(year))
a.y = year
} else {
table.Skip(1)
}
table.CellR(fmt.Sprintf("Week %2v", week))
}
07070100000031000081A40000000000000000000000016863F92F000002A1000000000000000000000000000000000000002500000000klog-6.6/klog/app/cli/report/year.gopackage report
import (
"fmt"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service/period"
)
type yearAggregator struct{}
func NewYearAggregator() Aggregator {
return &yearAggregator{}
}
func (a *yearAggregator) NumberOfPrefixColumns() int {
return 1
}
func (a *yearAggregator) DateHash(date klog.Date) period.Hash {
return period.Hash(period.NewYearFromDate(date).Hash())
}
func (a *yearAggregator) OnHeaderPrefix(table *tf.Table) {
table.
CellL(" ") // 2020
}
func (a *yearAggregator) OnRowPrefix(table *tf.Table, date klog.Date) {
// Year
table.CellR(fmt.Sprint(date.Year()))
}
07070100000032000081A40000000000000000000000016863F92F000037DF000000000000000000000000000000000000002500000000klog-6.6/klog/app/cli/report_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestReportOfEmptyInput(t *testing.T) {
state, err := NewTestingContext()._SetRecords(``)._Run((&Report{}).Run)
require.Nil(t, err)
assert.Equal(t, "", state.printBuffer)
}
func TestReportOfEmptyFilteredData(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2022-10-30
8h
`)._Run((&Report{
FilterArgs: util.FilterArgs{Date: klog.Ɀ_Date_(2022, 10, 31)},
Fill: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, "", state.printBuffer)
}
func TestDayReportOfRecords(t *testing.T) {
/*
Aspects tested:
- Multiple records per date unified into one item
- Sorting by date
- Not repeating year or month label
- Weekdays
*/
state, err := NewTestingContext()._SetRecords(`
2021-01-17
332h
2021-01-17
1h
2019-12-01
2021-03-03
<23:00 - 0:00
2020-12-30
1h
8:00am - 04:47pm
2021-03-02
-8h2m
2021-01-19
5m
`)._SetNow(2021, 3, 4, 0, 0)._Run((&Report{WarnArgs: util.WarnArgs{NoWarn: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2019 Dec Sun 1. 0m
2020 Dec Wed 30. 9h47m
2021 Jan Sun 17. 333h
Tue 19. 5m
Mar Tue 2. -8h2m
Wed 3. 1h
========
335h50m
`, state.printBuffer)
}
func TestDayReportConsecutive(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2020-09-29
1h
2020-10-04
3h
2020-10-02
`)._Run((&Report{Fill: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2020 Sep Tue 29. 1h
Wed 30.
Oct Thu 1.
Fri 2. 0m
Sat 3.
Sun 4. 3h
========
4h
`, state.printBuffer)
}
func TestDayReportWithDiff(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-07-07 (8h!)
8h
2018-07-08 (5h30m!)
2h
2018-07-09 (2h!)
5h20m
2018-07-09 (19m!)
`)._Run((&Report{DiffArgs: util.DiffArgs{Diff: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2018 Jul Sat 7. 8h 8h! 0m
Sun 8. 2h 5h30m! -3h30m
Mon 9. 5h20m 2h19m! +3h1m
======== ========= ========
15h20m 15h49m! -29m
`, state.printBuffer)
}
func TestDayReportWithDecimal(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-07-07 (8h!)
8h
2018-07-08 (5h30m!)
2h
2018-07-09 (2h!)
5h20m
2018-07-09 (19m!)
`)._Run((&Report{DiffArgs: util.DiffArgs{Diff: true}, DecimalArgs: util.DecimalArgs{Decimal: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2018 Jul Sat 7. 480 480 0
Sun 8. 120 330 -210
Mon 9. 320 139 181
======== ========= ========
920 949 -29
`, state.printBuffer)
}
func TestWeekReport(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2016-01-03
1h
2016-01-04
1h
2016-12-31
1h
2017-01-01
1h
`)._Run((&Report{AggregateBy: "week"}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2015 Week 53 1h
2016 Week 1 1h
Week 52 2h
========
4h
`, state.printBuffer)
}
func TestWeekReportWithFillAndDiff(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-12-09 (8h!)
8h
2018-12-26 (1h30m!)
2h
2018-12-31 (30m!)
15m
2019-01-02 (2h!)
3h
2019-01-08 (19m!)
`)._Run((&Report{AggregateBy: "week", DiffArgs: util.DiffArgs{Diff: true}, Fill: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2018 Week 49 8h 8h! 0m
Week 50
Week 51
Week 52 2h 1h30m! +30m
2019 Week 1 3h15m 2h30m! +45m
Week 2 0m 19m! -19m
======== ========= ========
13h15m 12h19m! +56m
`, state.printBuffer)
}
func TestQuarterReport(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-02-02 (8h!)
8h
2018-04-10 (5h30m!)
2h
2018-05-23 (2h!)
5h20m
2019-01-01 (19m!)
`)._Run((&Report{AggregateBy: "quarter", DiffArgs: util.DiffArgs{Diff: true}, Fill: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2018 Q1 8h 8h! 0m
Q2 7h20m 7h30m! -10m
Q3
Q4
2019 Q1 0m 19m! -19m
======== ========= ========
15h20m 15h49m! -29m
`, state.printBuffer)
}
func TestMonthReport(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-02-02 (8h!)
8h
2018-04-10 (5h30m!)
2h
2018-05-23 (2h!)
5h20m
2019-01-01 (19m!)
`)._Run((&Report{AggregateBy: "month", DiffArgs: util.DiffArgs{Diff: true}, Fill: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2018 Feb 8h 8h! 0m
Mar
Apr 2h 5h30m! -3h30m
May 5h20m 2h! +3h20m
Jun
Jul
Aug
Sep
Oct
Nov
Dec
2019 Jan 0m 19m! -19m
======== ========= ========
15h20m 15h49m! -29m
`, state.printBuffer)
}
func TestYearReport(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2016-02-02 (8h!)
8h
2018-04-10 (5h30m!)
2h
2018-05-23 (2h!)
5h20m
2019-01-01 (19m!)
`)._Run((&Report{AggregateBy: "year", DiffArgs: util.DiffArgs{Diff: true}, Fill: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2016 8h 8h! 0m
2017
2018 7h20m 7h30m! -10m
2019 0m 19m! -19m
======== ========= ========
15h20m 15h49m! -29m
`, state.printBuffer)
}
func TestReportWithChart(t *testing.T) {
t.Run("Daily (default) aggregation", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-11
-2h
2025-01-11
0m
2025-01-13
1m
2025-01-14
5h
2025-01-16
5h1m
2025-01-17
5h15m
2025-01-18
5h30m
2025-01-20
5h51m
2025-01-22
6h
2025-01-25
9h
`)._Run((&Report{Chart: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Jan Sat 11. -2h
Mon 13. 1m ▇
Tue 14. 5h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Thu 16. 5h1m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Fri 17. 5h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Sat 18. 5h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Mon 20. 5h51m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Wed 22. 6h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Sat 25. 9h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
========
39h38m
`, state.printBuffer)
})
t.Run("Weekly aggregation", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-01
40h
2025-01-08
48h45m
2025-01-15
31h15m
`)._Run((&Report{Chart: true, AggregateBy: "w", WarnArgs: util.WarnArgs{NoWarn: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Week 1 40h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Week 2 48h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Week 3 31h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
========
120h
`, state.printBuffer)
})
t.Run("Monthly aggregation", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-01
173h
2025-02-01
208h30m
2025-03-01
126h15m
`)._Run((&Report{Chart: true, AggregateBy: "m", WarnArgs: util.WarnArgs{NoWarn: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Jan 173h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Feb 208h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Mar 126h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
========
507h45m
`, state.printBuffer)
})
t.Run("Quarterly aggregation", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-01
316h
2025-04-01
392h30m
2025-07-01
237h45m
`)._Run((&Report{Chart: true, AggregateBy: "q", WarnArgs: util.WarnArgs{NoWarn: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Q1 316h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Q2 392h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Q3 237h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
========
946h15m
`, state.printBuffer)
})
t.Run("Yearly aggregation", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-01
1735h
2026-01-01
2154h45m
2027-01-01
1189h15m
`)._Run((&Report{Chart: true, AggregateBy: "y", WarnArgs: util.WarnArgs{NoWarn: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 1735h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
2026 2154h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
2027 1189h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
========
5079h
`, state.printBuffer)
})
}
func TestReportWithScaledChart(t *testing.T) {
t.Run("Custom resolution (large)", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-14
12h
2025-01-16
18h37m
`)._Run((&Report{Chart: true, ChartResolution: 120}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Jan Tue 14. 12h ▇▇▇▇▇▇
Thu 16. 18h37m ▇▇▇▇▇▇▇▇▇▇
========
30h37m
`, state.printBuffer)
})
t.Run("Custom resolution (small)", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-14
1h30m
2025-01-16
45m
`)._Run((&Report{Chart: true, ChartResolution: 5}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Jan Tue 14. 1h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Thu 16. 45m ▇▇▇▇▇▇▇▇▇
========
2h15m
`, state.printBuffer)
})
t.Run("Setting resolution implies --chart", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-14
12h
2025-01-16
18h37m
`)._Run((&Report{ChartResolution: 120}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
2025 Jan Tue 14. 12h ▇▇▇▇▇▇
Thu 16. 18h37m ▇▇▇▇▇▇▇▇▇▇
========
30h37m
`, state.printBuffer)
})
t.Run("With --diff flag", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2025-01-14 (2h!)
1h30m
2025-01-16 (1h!)
45m
`)._Run((&Report{Chart: true, DiffArgs: util.DiffArgs{Diff: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
2025 Jan Tue 14. 1h30m 2h! -30m ▇▇▇▇▇▇
Thu 16. 45m 1h! -15m ▇▇▇
======== ========= ========
2h15m 3h! -45m
`, state.printBuffer)
})
t.Run("Invalid resolution", func(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`
2025-01-14
12h
2025-01-16
18h37m
`)._Run((&Report{ChartResolution: -10}).Run)
require.Error(t, err)
assert.Equal(t, "Invalid resolution", err.Error())
})
}
07070100000033000081A40000000000000000000000016863F92F00000A65000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/start.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser/reconciling"
"github.com/jotaen/klog/klog/parser/txt"
"github.com/jotaen/klog/klog/service"
)
type Start struct {
util.SummaryArgs
util.AtDateAndTimeArgs
util.NoStyleArgs
util.WarnArgs
util.OutputFileArgs
}
func (opt *Start) Help() string {
return `
This appends a new open-ended entry to the record.
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.
You can otherwise specify a date with '--date'.
Unless the '--time' flag is specified, it defaults to the current time as start time.
If you prefer your time to be rounded, you can use the '--round' flag.
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.
Note that '--resume' will fall back to the last record, if the current record doesn’t contain any entries yet.
`
}
func (opt *Start) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
now := ctx.Now()
date := opt.AtDate(now)
time, tErr := opt.AtTime(now, ctx.Config())
if tErr != nil {
return tErr
}
additionalData := reconciling.AdditionalData{}
ctx.Config().DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) {
additionalData.ShouldTotal = s
})
spy := PreviousRecordSpy{}
return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs},
[]reconciling.Creator{
spy.phonyCreator(date),
reconciling.NewReconcilerAtRecord(date),
reconciling.NewReconcilerForNewRecord(date, opt.DateFormat(ctx.Config()), additionalData),
},
func(reconciler *reconciling.Reconciler) error {
summary, sErr := opt.Summary(reconciler.Record, spy.PreviousRecord)
if sErr != nil {
return sErr
}
return reconciler.StartOpenRange(time, opt.TimeFormat(ctx.Config()), summary)
},
)
}
type PreviousRecordSpy struct {
PreviousRecord klog.Record
}
// phonyCreator is a no-op “pass-through” creator, whose only purpose it is to hook into
// the reconciler-creation mechanism, to get a handle on the records for determining
// the previous record.
func (p *PreviousRecordSpy) phonyCreator(currentDate klog.Date) reconciling.Creator {
return func(records []klog.Record, _ []txt.Block) *reconciling.Reconciler {
for _, r := range service.Sort(records, false) {
if r.Date().IsAfterOrEqual(currentDate) {
continue
}
p.PreviousRecord = r
return nil
}
return nil
}
}
07070100000034000081A40000000000000000000000016863F92F0000326C000000000000000000000000000000000000002400000000klog-6.6/klog/app/cli/start_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestStartWithAutoTime(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-12:00
`)._SetNow(1920, 2, 2, 15, 24)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
9:00-12:00
15:24-?
`, state.writtenFileContents)
}
func TestStartWithExplicitDateAndAutoTimeYesterday(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-12:00
`)._SetNow(1920, 2, 3, 23, 35)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
9:00-12:00
23:35>-?
`, state.writtenFileContents)
}
func TestStartWithExplicitTime(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-12:00
`)._SetNow(1920, 2, 2, 23, 0)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
Time: klog.Ɀ_Time_(15, 24),
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
9:00-12:00
15:24-?
`, state.writtenFileContents)
}
func TestStartWithExplicitDateAndTime(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-12:00
`)._SetNow(1920, 9, 28, 12, 16)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
Time: klog.Ɀ_Time_(15, 24),
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
9:00-12:00
15:24-?
`, state.writtenFileContents)
}
func TestStartFailsIfDateIsInPastAndNoTimeIsGiven(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-???
`)._SetNow(1920, 9, 28, 12, 15)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
}).Run)
require.Error(t, err)
assert.Equal(t, "Please specify a time value for dates in the past", err.Details())
assert.Equal(t, state.writtenFileContents, "")
}
func TestStartFailsIfAlreadyStarted(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-???
`)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
Time: klog.Ɀ_Time_(12, 35),
},
}).Run)
require.Error(t, err)
assert.Equal(t, "There is already an open range in this record", err.Details())
assert.Equal(t, state.writtenFileContents, "")
}
func TestStartWithSummary(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
9:00-12:00
`)._SetNow(1920, 2, 2, 15, 24)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
SummaryArgs: util.SummaryArgs{
SummaryText: klog.Ɀ_EntrySummary_("Started something"),
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
9:00-12:00
15:24-? Started something
`, state.writtenFileContents)
}
func TestStartAtUnknownDateCreatesNewRecord(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
09:23 - ???
`)._SetNow(1623, 12, 11, 12, 49)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-11
12:49 - ???
1623-12-13
09:23 - ???
`, state.writtenFileContents)
}
func TestStartNewRecordWithShouldTotal(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
09:23 - ???
`)._SetNow(1623, 12, 11, 12, 49)._SetFileConfig(`
default_should_total = 8h!
`)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-11 (8h!)
12:49 - ???
1623-12-13
09:23 - ???
`, state.writtenFileContents)
}
func TestStartWithStyle(t *testing.T) {
t.Run("For empty file and no preferences, use recommended default.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/02
`)._SetNow(1920, 2, 2, 9, 44)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/02
9:44 - ?
`, state.writtenFileContents)
})
t.Run("Without any preference, detect from file.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/02
9:00am-1:00pm
3h
`)._SetNow(1920, 2, 3, 8, 12)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/02
9:00am-1:00pm
3h
1920/02/03
8:12am-?
`, state.writtenFileContents)
})
t.Run("Use preference from config file, if given.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/02
9:00am-1:00pm
3h
`)._SetNow(1920, 2, 3, 8, 12)._SetFileConfig(`
date_format = YYYY-MM-DD
time_convention = 24h
`)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/02
9:00am-1:00pm
3h
1920-02-03
8:12-?
`, state.writtenFileContents)
})
t.Run("If explicit flag was provided, that takes ultimate precedence.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920/02/02
9:00am-1:00pm
`)._SetFileConfig(`
time_convention = 12h
`)._SetNow(1920, 2, 2, 8, 12)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
Time: klog.Ɀ_Time_(9, 44),
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920/02/02
9:00am-1:00pm
9:44-?
`, state.writtenFileContents)
})
}
func TestStartWithRounding(t *testing.T) {
t.Run("With --round flag", func(t *testing.T) {
r5, _ := service.NewRounding(5)
state, err := NewTestingContext()._SetRecords(`
2005-05-05
`)._SetNow(2005, 5, 5, 8, 12)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Round: r5},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - ?
`, state.writtenFileContents)
})
t.Run("With file config", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2005-05-05
`)._SetNow(2005, 5, 5, 8, 12)._SetFileConfig(`
default_rounding = 15m
`)._Run((&Start{}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:15 - ?
`, state.writtenFileContents)
})
t.Run("Flag trumps file config", func(t *testing.T) {
r5, _ := service.NewRounding(5)
state, err := NewTestingContext()._SetRecords(`
2005-05-05
`)._SetNow(2005, 5, 5, 8, 12)._SetFileConfig(`
default_rounding = 60m
`)._Run((&Start{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Round: r5},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - ?
`, state.writtenFileContents)
})
}
func TestStartWithResume(t *testing.T) {
t.Run("No previous entry, no previous record -> Empty entry summary", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
12:49 - ?
`, state.writtenFileContents)
})
t.Run("No previous entry, but previous record -> Take over from previous record", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
14:00 - 15:00 Did something
10m Some activity
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1623-12-12
14:00 - 15:00 Did something
10m Some activity
1623-12-13
12:49 - ? Some activity
`, state.writtenFileContents)
})
t.Run("No previous entry, and previous record empty -> Start over blank", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1623-12-12
1623-12-13
12:49 - ?
`, state.writtenFileContents)
})
t.Run("No previous entry summary -> Empty entry summary", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
8:13 - 9:44
12:49 - ?
`, state.writtenFileContents)
})
t.Run("With previous entry summary -> Take it over", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44 Work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
8:13 - 9:44 Work
12:49 - ? Work
`, state.writtenFileContents)
})
t.Run("With previous entry summaries -> Take over the last one", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44 Work
9:51 - 11:22 More work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
8:13 - 9:44 Work
9:51 - 11:22 More work
12:49 - ? More work
`, state.writtenFileContents)
})
t.Run("With previous multiline entry summary -> Take it over completely", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44
Work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
8:13 - 9:44
Work
12:49 - ?
Work
`, state.writtenFileContents)
})
t.Run("Fails if --summary flag is specified as well", func(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44
Work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
SummaryText: klog.Ɀ_EntrySummary_("Test"),
},
}).Run)
require.Error(t, err)
})
t.Run("Fails if --resume-nth flag is specified as well", func(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44
Work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
ResumeNth: 1,
},
}).Run)
require.Error(t, err)
})
}
func TestStartWithResumeNth(t *testing.T) {
t.Run("Takes over first entry", func(t *testing.T) {
for _, nth := range []int{1, -3} {
state, err := NewTestingContext()._SetRecords(`1623-12-13
1h Foo
2h Bar
3h Asdf
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
ResumeNth: nth,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
1h Foo
2h Bar
3h Asdf
12:49 - ? Foo
`, state.writtenFileContents)
}
})
t.Run("Takes over second entry", func(t *testing.T) {
for _, nth := range []int{2, -2} {
state, err := NewTestingContext()._SetRecords(`1623-12-13
1h Foo
2h Bar
3h Asdf
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
ResumeNth: nth,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
1h Foo
2h Bar
3h Asdf
12:49 - ? Bar
`, state.writtenFileContents)
}
})
t.Run("Takes over empty entry", func(t *testing.T) {
for _, nth := range []int{3, -1} {
state, err := NewTestingContext()._SetRecords(`1623-12-13
1h Foo
2h Bar
3h
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
ResumeNth: nth,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `1623-12-13
1h Foo
2h Bar
3h
12:49 - ?
`, state.writtenFileContents)
}
})
t.Run("Fails if out of bounds", func(t *testing.T) {
for _, nth := range []int{4, 5, 10, -4, -5, -10} {
_, err := NewTestingContext()._SetRecords(`1623-12-13
1h Foo
2h Bar
3h Asdf
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
ResumeNth: nth,
},
}).Run)
require.Error(t, err)
}
})
t.Run("Doesn’t fall back to previous record", func(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`1623-12-12
1h Foo
2h Bar
3h Asdf
1623-12-13
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
ResumeNth: -1,
},
}).Run)
require.Error(t, err)
})
t.Run("Fails if --summary flag is specified as well", func(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44
Work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
ResumeNth: 1,
SummaryText: klog.Ɀ_EntrySummary_("Test"),
},
}).Run)
require.Error(t, err)
})
t.Run("Fails if --resume flag is specified as well", func(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`1623-12-13
8:13 - 9:44
Work
`)._SetNow(1623, 12, 13, 12, 49)._Run((&Start{
SummaryArgs: util.SummaryArgs{
Resume: true,
ResumeNth: 1,
},
}).Run)
require.Error(t, err)
})
}
07070100000035000081A40000000000000000000000016863F92F000007AD000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/stop.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser/reconciling"
)
type Stop struct {
Summary klog.EntrySummary `name:"summary" short:"s" placeholder:"TEXT" help:"Text to append to the entry summary."`
util.AtDateAndTimeArgs
util.NoStyleArgs
util.WarnArgs
util.OutputFileArgs
}
func (opt *Stop) Help() string {
return `
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.
By default, it targets the record at today’s date.
You can otherwise specify a date with '--date'.
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.
You may also specify a summary via '--summary', which will be appended to the existing summary of the entry.
`
}
func (opt *Stop) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
now := ctx.Now()
date := opt.AtDate(now)
time, err := opt.AtTime(now, ctx.Config())
if err != nil {
return err
}
// Only fall back to yesterday if no explicit date has been given.
// Otherwise, it wouldn’t make sense to decrement the day.
shouldTryYesterday := opt.WasAutomatic()
yesterday := date.PlusDays(-1)
return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs},
[]reconciling.Creator{
reconciling.NewReconcilerAtRecord(date),
func() reconciling.Creator {
if shouldTryYesterday {
return reconciling.NewReconcilerAtRecord(yesterday)
}
return nil
}(),
},
func(reconciler *reconciling.Reconciler) error {
if shouldTryYesterday && reconciler.Record.Date().IsEqualTo(yesterday) {
time, _ = time.Plus(klog.NewDuration(24, 0))
}
return reconciler.CloseOpenRange(time, opt.TimeFormat(ctx.Config()), opt.Summary)
},
)
}
07070100000036000081A40000000000000000000000016863F92F0000161E000000000000000000000000000000000000002300000000klog-6.6/klog/app/cli/stop_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestStop(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
11:22-?
`)._SetNow(1920, 2, 2, 15, 24)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
11:22-15:24
`, state.writtenFileContents)
}
func TestStopFallsBackWithShiftedTimeToYesterdayWithAutoTime(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
22:22-?
`)._SetNow(1920, 2, 3, 4, 16)._Run((&Stop{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
22:22-4:16>
`, state.writtenFileContents)
}
func TestDoesNotFallBackToYesterdayWhenTimeIsExplicit(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`
1920-02-02
22:22-?
`)._SetNow(1920, 2, 3, 4, 16)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Time: klog.Ɀ_Time_(23, 30)},
}).Run)
require.Error(t, err)
}
func TestStopsYesterdaysRecordWithShiftedAutoTime(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22pm-?
`)._SetNow(1920, 2, 3, 2, 49)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22pm-2:49am>
`, state.writtenFileContents)
}
func TestStopWithStyle(t *testing.T) {
t.Run("Without any preference, detect from file.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22am-?
`)._SetNow(1920, 2, 2, 14, 49)._Run((&Stop{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22am-2:49pm
`, state.writtenFileContents)
})
t.Run("Use preference from config file, if given.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22am-?
`)._SetFileConfig(`
time_convention = 24h
`)._SetNow(1920, 2, 2, 14, 49)._Run((&Stop{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22am-14:49
`, state.writtenFileContents)
})
t.Run("If explicit flag was provided, that takes ultimate precedence.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22am-?
`)._SetFileConfig(`
time_convention = 12h
`)._SetNow(1920, 2, 2, 14, 49)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Time: klog.Ɀ_Time_(14, 49)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22am-14:49
`, state.writtenFileContents)
})
}
func TestStopWithExtendingSummary(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
11:22-? Started something...
`)._SetNow(1920, 2, 2, 15, 24)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
Summary: klog.Ɀ_EntrySummary_("Done!"),
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
11:22-15:24 Started something... Done!
`, state.writtenFileContents)
}
func TestStopFailsIfNoTimeSpecifiedForPastDates(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
15:00-?
`)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1624, 02, 1)},
},
}).Run)
require.Error(t, err)
assert.Equal(t, "Missing time parameter", err.Error())
assert.Equal(t, "", state.writtenFileContents)
}
func TestStopFailsIfNoRecentRecord(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
15:00-?
`)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1624, 02, 1)},
Time: klog.Ɀ_Time_(16, 00),
},
}).Run)
require.Error(t, err)
assert.Equal(t, "No such record", err.Error())
assert.Equal(t, "", state.writtenFileContents)
}
func TestStopFailsIfNoOpenRange(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
15:00-16:00
`)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1623, 12, 12)},
Time: klog.Ɀ_Time_(16, 00),
},
}).Run)
require.Error(t, err)
assert.Equal(t, "Manipulation failed", err.Error())
assert.Equal(t, "No open time range", err.Details())
assert.Equal(t, "", state.writtenFileContents)
}
func TestStopWithRounding(t *testing.T) {
t.Run("With --round flag", func(t *testing.T) {
r15, _ := service.NewRounding(15)
state, err := NewTestingContext()._SetRecords(`
2005-05-05
8:10 - ?
`)._SetNow(2005, 5, 5, 11, 24)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Round: r15},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - 11:30
`, state.writtenFileContents)
})
t.Run("With file config", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2005-05-05
8:10 - ?
`)._SetNow(2005, 5, 5, 11, 24)._SetFileConfig(`
default_rounding = 30m
`)._Run((&Stop{}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - 11:30
`, state.writtenFileContents)
})
t.Run("--round flag trumps file config", func(t *testing.T) {
r5, _ := service.NewRounding(5)
state, err := NewTestingContext()._SetRecords(`
2005-05-05
8:10 - ?
`)._SetNow(2005, 5, 5, 11, 24)._SetFileConfig(`
default_rounding = 30m
`)._Run((&Stop{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Round: r5},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - 11:25
`, state.writtenFileContents)
})
}
07070100000037000081A40000000000000000000000016863F92F000006A7000000000000000000000000000000000000002000000000klog-6.6/klog/app/cli/switch.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser/reconciling"
)
type Switch struct {
util.SummaryArgs
util.AtDateAndTimeArgs
util.NoStyleArgs
util.WarnArgs
util.OutputFileArgs
}
func (opt *Switch) Help() string {
return `
Closes a previously ongoing activity (i.e., open time range), and starts a new one.
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.
By default, it uses the record at today’s date for the new entry. You can otherwise specify a date with '--date'.
Unless the '--time' flag is specified, it defaults to the current time as start/stop time.
If you prefer your time to be rounded, you can use the '--round' flag.
`
}
func (opt *Switch) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
now := ctx.Now()
date := opt.AtDate(now)
time, tErr := opt.AtTime(now, ctx.Config())
if tErr != nil {
return tErr
}
return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs},
[]reconciling.Creator{
reconciling.NewReconcilerAtRecord(date),
},
func(reconciler *reconciling.Reconciler) error {
return reconciler.CloseOpenRange(time, opt.TimeFormat(ctx.Config()), nil)
},
func(reconciler *reconciling.Reconciler) error {
summary, sErr := opt.Summary(reconciler.Record, nil)
if sErr != nil {
return sErr
}
return reconciler.StartOpenRange(time, opt.TimeFormat(ctx.Config()), summary)
},
)
}
07070100000038000081A40000000000000000000000016863F92F00001659000000000000000000000000000000000000002500000000klog-6.6/klog/app/cli/switch_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestSwitch(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
11:22-?
`)._SetNow(1920, 2, 2, 15, 24)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
11:22-15:24
15:24-?
`, state.writtenFileContents)
}
func TestSwitchWithSummaries(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
11:22-? Currently ongoing...
...task
1920-02-03
Next day
`)._SetNow(1920, 2, 2, 15, 24)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1920, 2, 2)},
},
SummaryArgs: util.SummaryArgs{
SummaryText: klog.Ɀ_EntrySummary_("Start", "over"),
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
11:22-15:24 Currently ongoing...
...task
15:24-? Start
over
1920-02-03
Next day
`, state.writtenFileContents)
}
func TestSwitchWithResume(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-03
8:00 - 9:00 First
9:00 - ? Second
`)._SetNow(1920, 2, 3, 9, 31)._Run((&Switch{
SummaryArgs: util.SummaryArgs{
Resume: true,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-03
8:00 - 9:00 First
9:00 - 9:31 Second
9:31 - ? Second
`, state.writtenFileContents)
}
func TestSwitchWithResumeNth(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-03
8:00 - 9:00 First
9:00 - ? Second
`)._SetNow(1920, 2, 3, 9, 31)._Run((&Switch{
SummaryArgs: util.SummaryArgs{
ResumeNth: 1,
},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-03
8:00 - 9:00 First
9:00 - 9:31 Second
9:31 - ? First
`, state.writtenFileContents)
}
func TestSwitchCannotResumeAndSummary(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-03
8:00 - 9:00 First
9:00 - ? Second
`)._SetNow(1920, 2, 3, 9, 31)._Run((&Switch{
SummaryArgs: util.SummaryArgs{
Resume: true,
SummaryText: klog.Ɀ_EntrySummary_("Foo"),
},
}).Run)
require.Error(t, err)
assert.Equal(t, "Manipulation failed", err.Error())
assert.Equal(t, "", state.writtenFileContents)
}
func TestSwitchWithStyle(t *testing.T) {
t.Run("Without any preference, detect from file.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22am-???
`)._SetNow(1920, 2, 2, 14, 49)._Run((&Switch{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22am-2:49pm
2:49pm-???
`, state.writtenFileContents)
})
t.Run("Use preference from config file, if given.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22am-?
`)._SetFileConfig(`
time_convention = 24h
`)._SetNow(1920, 2, 2, 14, 49)._Run((&Switch{}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22am-14:49
14:49-?
`, state.writtenFileContents)
})
t.Run("If explicit flag was provided, that takes ultimate precedence.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1920-02-02
10:22am-?
`)._SetFileConfig(`
time_convention = 12h
`)._SetNow(1920, 2, 2, 14, 49)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Time: klog.Ɀ_Time_(14, 49)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1920-02-02
10:22am-14:49
14:49-?
`, state.writtenFileContents)
})
}
func TestSwitchFailsIfNoRecentRecord(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
15:00-?
`)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1624, 02, 1)},
Time: klog.Ɀ_Time_(16, 00),
},
}).Run)
require.Error(t, err)
assert.Equal(t, "No such record", err.Error())
assert.Equal(t, "", state.writtenFileContents)
}
func TestSwitchFailsIfNoOpenRange(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1623-12-12
15:00-16:00
`)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1623, 12, 12)},
Time: klog.Ɀ_Time_(16, 00),
},
}).Run)
require.Error(t, err)
assert.Equal(t, "Manipulation failed", err.Error())
assert.Equal(t, "No open time range", err.Details())
assert.Equal(t, "", state.writtenFileContents)
}
func TestSwitchWithRounding(t *testing.T) {
t.Run("With --round flag", func(t *testing.T) {
r15, _ := service.NewRounding(15)
state, err := NewTestingContext()._SetRecords(`
2005-05-05
8:10 - ?
`)._SetNow(2005, 5, 5, 11, 24)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Round: r15},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - 11:30
11:30 - ?
`, state.writtenFileContents)
})
t.Run("With file config", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2005-05-05
8:10 - ?
`)._SetNow(2005, 5, 5, 11, 24)._SetFileConfig(`
default_rounding = 30m
`)._Run((&Switch{}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - 11:30
11:30 - ?
`, state.writtenFileContents)
})
t.Run("--round flag trumps file config", func(t *testing.T) {
r5, _ := service.NewRounding(5)
state, err := NewTestingContext()._SetRecords(`
2005-05-05
8:10 - ?
`)._SetNow(2005, 5, 5, 11, 24)._SetFileConfig(`
default_rounding = 30m
`)._Run((&Switch{
AtDateAndTimeArgs: util.AtDateAndTimeArgs{Round: r5},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
2005-05-05
8:10 - 11:25
11:25 - ?
`, state.writtenFileContents)
})
}
07070100000039000081A40000000000000000000000016863F92F00000B1F000000000000000000000000000000000000001E00000000klog-6.6/klog/app/cli/tags.gopackage cli
import (
"fmt"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
)
type Tags struct {
Values bool `name:"values" short:"v" help:"Display breakdown of tag values (if the data contains any; e.g.: '#tag=value')."`
Count bool `name:"count" short:"c" help:"Display the number of matching entries per tag."`
WithUntagged bool `name:"with-untagged" short:"u" help:"Display remainder of any untagged entries"`
util.FilterArgs
util.NowArgs
util.DecimalArgs
util.WarnArgs
util.NoStyleArgs
util.InputFilesArgs
}
func (opt *Tags) Help() string {
return `
If a tag appears in the overall record summary, then all of the record’s entries match.
If a tag appears in an entry summary, only that particular entry matches.
If tags are specified redundantly in the data, the respective time is still counted uniquely.
If you use tags with values (e.g., '#tag=value'), then these also match against the base tag (e.g., '#tag').
You can use the '--values' flag to display an additional breakdown by tag value.
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').
`
}
func (opt *Tags) Run(ctx app.Context) app.Error {
opt.DecimalArgs.Apply(&ctx)
opt.NoStyleArgs.Apply(&ctx)
styler, serialiser := ctx.Serialise()
records, err := ctx.ReadInputs(opt.File...)
if err != nil {
return err
}
now := ctx.Now()
records = opt.ApplyFilter(now, records)
nErr := opt.ApplyNow(now, records...)
if nErr != nil {
return nErr
}
tagStats, untagged := service.AggregateTotalsByTags(records...)
numberOfColumns := 2
if opt.Values {
numberOfColumns++
}
if opt.Count {
numberOfColumns++
}
countString := func(c int) string {
return styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(fmt.Sprintf(" (%d)", c))
}
table := tf.NewTable(numberOfColumns, " ")
for _, t := range tagStats {
totalString := serialiser.Duration(t.Total)
if t.Tag.Value() == "" {
table.CellL("#" + t.Tag.Name())
table.CellL(totalString)
if opt.Values {
table.Skip(1)
}
if opt.Count {
table.CellL(countString(t.Count))
}
} else if opt.Values {
table.CellL(" " + styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(t.Tag.Value()))
table.Skip(1)
table.CellL(totalString)
if opt.Count {
table.CellL(countString(t.Count))
}
}
}
if opt.WithUntagged {
table.CellL("(untagged)")
table.CellL(serialiser.Duration(untagged.Total))
if opt.Values {
table.Skip(1)
}
if opt.Count {
table.CellL(countString(untagged.Count))
}
}
table.Collect(ctx.Print)
opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings())
return nil
}
0707010000003A000081A40000000000000000000000016863F92F000016E1000000000000000000000000000000000000002300000000klog-6.6/klog/app/cli/tags_test.gopackage cli
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestTagsOfEmptyInput(t *testing.T) {
state, err := NewTestingContext()._SetRecords(``)._Run((&Tags{}).Run)
require.Nil(t, err)
assert.Equal(t, "", state.printBuffer)
}
func TestPrintTagsOverview(t *testing.T) {
/*
Aspects tested:
- Aggregate totals by tags
- Sort output alphabetically
- Print in tabular manner
*/
ctx := NewTestingContext()._SetRecords(`
1995-03-17
#sports
3h #badminton
1h #running=home-trail
1h #running=river-route
1995-03-28
Was #sick, need to compensate later
-30m #running
1995-04-02
9h something untagged
45m #badminton
1995-04-19
#sports #running (Don’t count that twice!)
14:00 - 17:00 #sports #running
`)
t.Run("Without argument", func(t *testing.T) {
state, err := ctx._Run((&Tags{}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m
#running 4h30m
#sick -30m
#sports 8h
`, state.printBuffer)
})
t.Run("With count", func(t *testing.T) {
state, err := ctx._Run((&Tags{
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m (2)
#running 4h30m (4)
#sick -30m (1)
#sports 8h (4)
`, state.printBuffer)
})
t.Run("With values", func(t *testing.T) {
state, err := ctx._Run((&Tags{
Values: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m
#running 4h30m
home-trail 1h
river-route 1h
#sick -30m
#sports 8h
`, state.printBuffer)
})
t.Run("With values and count", func(t *testing.T) {
state, err := ctx._Run((&Tags{
Values: true,
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m (2)
#running 4h30m (4)
home-trail 1h (1)
river-route 1h (1)
#sick -30m (1)
#sports 8h (4)
`, state.printBuffer)
})
t.Run("With untagged", func(t *testing.T) {
state, err := ctx._Run((&Tags{
WithUntagged: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m
#running 4h30m
#sick -30m
#sports 8h
(untagged) 9h
`, state.printBuffer)
})
t.Run("With untagged and count", func(t *testing.T) {
state, err := ctx._Run((&Tags{
WithUntagged: true,
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m (2)
#running 4h30m (4)
#sick -30m (1)
#sports 8h (4)
(untagged) 9h (1)
`, state.printBuffer)
})
t.Run("With values and untagged", func(t *testing.T) {
state, err := ctx._Run((&Tags{
Values: true,
WithUntagged: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m
#running 4h30m
home-trail 1h
river-route 1h
#sick -30m
#sports 8h
(untagged) 9h
`, state.printBuffer)
})
t.Run("With values and untagged and count", func(t *testing.T) {
state, err := ctx._Run((&Tags{
Values: true,
WithUntagged: true,
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#badminton 3h45m (2)
#running 4h30m (4)
home-trail 1h (1)
river-route 1h (1)
#sick -30m (1)
#sports 8h (4)
(untagged) 9h (1)
`, state.printBuffer)
})
}
func TestPrintUntaggedIfNoTags(t *testing.T) {
t.Run("No tags present", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1995-03-17
1h
`)._Run((&Tags{
WithUntagged: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
(untagged) 1h
`, state.printBuffer)
})
t.Run("Empty file", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
`)._Run((&Tags{
WithUntagged: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
(untagged) 0m
`, state.printBuffer)
})
t.Run("Empty file (with count)", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
`)._Run((&Tags{
WithUntagged: true,
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
(untagged) 0m (0)
`, state.printBuffer)
})
}
func TestPrintTagsWithUnicodeCharacters(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1995-03-17
1h #ascii
2h #üñïčöδę
`)._Run((&Tags{}).Run)
require.Nil(t, err)
assert.Equal(t, `
#ascii 1h
#üñïčöδę 2h
`, state.printBuffer)
}
func TestPrintTagsOverviewWithUntaggedEmptyStates(t *testing.T) {
ctx := NewTestingContext()._SetRecords(`
1995-03-17
3h #ticket
`)
t.Run("Include 0 line", func(t *testing.T) {
state, err := ctx._Run((&Tags{
WithUntagged: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#ticket 3h
(untagged) 0m
`, state.printBuffer)
})
t.Run("Include 0 count", func(t *testing.T) {
state, err := ctx._Run((&Tags{
WithUntagged: true,
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#ticket 3h (1)
(untagged) 0m (0)
`, state.printBuffer)
})
}
func TestPrintTagsOverviewWithValueGrouping(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1995-03-17
3h #ticket=481
1h #ticket=105
1h
`)._Run((&Tags{Values: true}).Run)
require.Nil(t, err)
assert.Equal(t, `
#ticket 4h
105 1h
481 3h
`, state.printBuffer)
}
func TestPrintTagsOverviewWithValueGroupingAndCount(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1995-03-17
3h #ticket=481
1h #ticket=105
1h
`)._Run((&Tags{
Values: true,
Count: true,
}).Run)
require.Nil(t, err)
assert.Equal(t, `
#ticket 4h (2)
105 1h (1)
481 3h (1)
`, state.printBuffer)
}
func TestPrintTagsOverviewWithoutValueGrouping(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1995-03-17
3h #ticket=481
1h #ticket=105
1h
`)._Run((&Tags{}).Run)
require.Nil(t, err)
assert.Equal(t, `
#ticket 4h
`, state.printBuffer)
}
0707010000003B000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000002500000000klog-6.6/klog/app/cli/terminalformat0707010000003C000081A40000000000000000000000016863F92F00000847000000000000000000000000000000000000003500000000klog-6.6/klog/app/cli/terminalformat/colour_theme.gopackage terminalformat
type ColourTheme string
type colourCodes map[Colour]string
const (
COLOUR_THEME_NO_COLOUR = ColourTheme("no_colour")
COLOUR_THEME_DARK = ColourTheme("dark")
COLOUR_THEME_LIGHT = ColourTheme("light")
COLOUR_THEME_BASIC = ColourTheme("basic")
)
func NewStyler(c ColourTheme) Styler {
switch c {
case COLOUR_THEME_NO_COLOUR:
return Styler{
props: StyleProps{},
colourCodes: make(colourCodes),
reset: "",
foregroundPrefix: "",
backgroundPrefix: "",
colourSuffix: "",
underlined: "",
bold: "",
}
case COLOUR_THEME_DARK:
return newStyler256bit(colourCodes{
TEXT: "015",
TEXT_SUBDUED: "249",
TEXT_INVERSE: "000",
GREEN: "120",
RED: "167",
BLUE_DARK: "117",
BLUE_LIGHT: "027",
PURPLE: "213",
YELLOW: "221",
})
case COLOUR_THEME_LIGHT:
return newStyler256bit(colourCodes{
TEXT: "000",
TEXT_SUBDUED: "237",
TEXT_INVERSE: "015",
GREEN: "028",
RED: "124",
BLUE_DARK: "025",
BLUE_LIGHT: "033",
PURPLE: "055",
YELLOW: "208",
})
case COLOUR_THEME_BASIC:
return newStyler8bit(colourCodes{
TEXT: "", // Disabled
TEXT_SUBDUED: "", // Disabled
TEXT_INVERSE: "0",
GREEN: "2",
RED: "1",
BLUE_DARK: "4",
BLUE_LIGHT: "6",
PURPLE: "5",
YELLOW: "3",
})
}
panic("Unknown colour theme")
}
func newStyler256bit(cc colourCodes) Styler {
return Styler{
props: StyleProps{},
colourCodes: cc,
reset: "\033[0m",
foregroundPrefix: "\033[38;5;",
backgroundPrefix: "\033[48;5;",
colourSuffix: "m",
underlined: "\033[4m",
bold: "\033[1m",
}
}
func newStyler8bit(cc colourCodes) Styler {
return Styler{
props: StyleProps{},
colourCodes: cc,
reset: "\033[0m",
foregroundPrefix: "\033[3",
backgroundPrefix: "\033[4",
colourSuffix: "m",
underlined: "\033[4m",
bold: "\033[1m",
}
}
0707010000003D000081A40000000000000000000000016863F92F00000439000000000000000000000000000000000000002F00000000klog-6.6/klog/app/cli/terminalformat/reflow.gopackage terminalformat
import "strings"
type Reflower struct {
maxLength int
newLine string
}
func NewReflower(maxLineLength int, newLineChar string) Reflower {
return Reflower{
maxLength: maxLineLength,
newLine: newLineChar,
}
}
func (b Reflower) Reflow(text string, linePrefixes []string) string {
SPACE := " "
var resultParagraphs []string
for _, paragraph := range strings.Split(text, b.newLine) {
words := strings.Split(paragraph, SPACE)
lines := []string{""}
currentLinePrefix := ""
for i, word := range words {
nr := len(lines) - 1
isLastWordOfText := i == len(words)-1
if !isLastWordOfText && len(lines[nr])+len(words[i+1]) > b.maxLength {
lines = append(lines, "")
nr = len(lines) - 1
}
if lines[nr] == "" {
if len(linePrefixes) > nr {
currentLinePrefix = linePrefixes[nr]
}
lines[nr] += currentLinePrefix
} else {
lines[nr] += SPACE
}
lines[nr] += word
}
resultParagraphs = append(resultParagraphs, strings.Join(lines, b.newLine))
}
return strings.Join(resultParagraphs, b.newLine)
}
0707010000003E000081A40000000000000000000000016863F92F00000701000000000000000000000000000000000000003400000000klog-6.6/klog/app/cli/terminalformat/reflow_test.gopackage terminalformat
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestLineBreakerReflowsText(t *testing.T) {
reflower := NewReflower(60, "\n")
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.
However, existing line breaks are respected.
The same is true for blank-line-separated paragraphs`
assert.Equal(t, `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.
However, existing line breaks are respected.
The same is true for blank-line-separated paragraphs`, reflower.Reflow(original, nil))
}
func TestLineBreakerDoesNotDoAnythingIfEmptyInput(t *testing.T) {
reflower := NewReflower(60, "\n")
assert.Equal(t, "", reflower.Reflow("", nil))
assert.Equal(t, "", reflower.Reflow(" ", nil))
assert.Equal(t, "\n", reflower.Reflow("\n", nil))
}
func TestLineBreakerPrependsPrefix(t *testing.T) {
reflower := NewReflower(60, "\n")
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."
assert.Equal(t, ` 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.`, reflower.Reflow(original, []string{" "}))
}
func TestLineBreakerPrependsMultiplePrefixes(t *testing.T) {
reflower := NewReflower(30, "\n")
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."
assert.Equal(t, `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.`, reflower.Reflow(original, []string{"", "| "}))
}
0707010000003F000081A40000000000000000000000016863F92F0000051C000000000000000000000000000000000000002E00000000klog-6.6/klog/app/cli/terminalformat/style.gopackage terminalformat
type StyleProps struct {
Color Colour
Background Colour
IsBold bool
IsUnderlined bool
}
type Styler struct {
props StyleProps
colourCodes map[Colour]string
reset string
foregroundPrefix string
backgroundPrefix string
colourSuffix string
underlined string
bold string
}
type Colour int
const (
unspecified = iota
TEXT
TEXT_SUBDUED
TEXT_INVERSE
GREEN
RED
YELLOW
BLUE_DARK
BLUE_LIGHT
PURPLE
)
func (s Styler) Format(text string) string {
return s.seqs() + text + s.reset
}
func (s Styler) Props(p StyleProps) Styler {
newS := s
newS.props = p
return newS
}
func (s Styler) FormatAndRestore(text string, previousStyle Styler) string {
return s.Format(text) + previousStyle.seqs()
}
func (s Styler) seqs() string {
seqs := s.reset
if s.props.Color != unspecified && s.colourCodes[s.props.Color] != "" {
seqs = seqs + s.foregroundPrefix + s.colourCodes[s.props.Color] + s.colourSuffix
}
if s.props.Background != unspecified && s.colourCodes[s.props.Background] != "" {
seqs = seqs + s.backgroundPrefix + s.colourCodes[s.props.Background] + s.colourSuffix
}
if s.props.IsUnderlined {
seqs = seqs + s.underlined
}
if s.props.IsBold {
seqs = seqs + s.bold
}
return seqs
}
07070100000040000081A40000000000000000000000016863F92F000007E1000000000000000000000000000000000000002E00000000klog-6.6/klog/app/cli/terminalformat/table.gopackage terminalformat
import (
"strings"
"unicode/utf8"
)
type Options struct {
fill bool
align Alignment
}
type cell struct {
Options
value string
len int
}
type Table struct {
cells []cell
numberOfColumns int
longestCell []int
currentColumn int
columnSeparator string
}
type Alignment int
const (
ALIGN_LEFT Alignment = iota
ALIGN_RIGHT
)
func NewTable(numberOfColumns int, columnSeparator string) *Table {
if numberOfColumns <= 1 {
panic("Column count must be greater than 1")
}
return &Table{
cells: []cell{},
numberOfColumns: numberOfColumns,
longestCell: make([]int, numberOfColumns),
currentColumn: 0,
columnSeparator: columnSeparator,
}
}
func (t *Table) Cell(text string, opts Options) *Table {
c := cell{
Options: opts,
value: text,
len: utf8.RuneCountInString(StripAllAnsiSequences(text)),
}
t.cells = append(t.cells, c)
if c.len > t.longestCell[t.currentColumn] {
t.longestCell[t.currentColumn] = c.len
}
t.currentColumn++
if t.currentColumn >= t.numberOfColumns {
t.currentColumn = 0
}
return t
}
func (t *Table) CellL(text string) *Table {
return t.Cell(text, Options{align: ALIGN_LEFT})
}
func (t *Table) CellR(text string) *Table {
return t.Cell(text, Options{align: ALIGN_RIGHT})
}
func (t *Table) Skip(numberOfCells int) *Table {
for i := 0; i < numberOfCells; i++ {
t.Cell("", Options{})
}
return t
}
func (t *Table) Fill(sequence string) *Table {
t.Cell(sequence, Options{fill: true})
return t
}
func (t *Table) Collect(fn func(string)) {
for i, c := range t.cells {
col := i % t.numberOfColumns
if i > 0 && col == 0 {
fn("\n")
}
if col > 0 {
fn(t.columnSeparator)
}
if c.fill {
fn(strings.Repeat(c.value, t.longestCell[col]))
} else {
padding := strings.Repeat(" ", t.longestCell[col]-c.len)
if c.align == ALIGN_RIGHT {
fn(padding)
}
fn(c.value)
if c.align == ALIGN_LEFT {
fn(padding)
}
}
}
if len(t.cells) > 0 {
fn("\n")
}
}
07070100000041000081A40000000000000000000000016863F92F000005A8000000000000000000000000000000000000003300000000klog-6.6/klog/app/cli/terminalformat/table_test.gopackage terminalformat
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestPrintTable(t *testing.T) {
result := ""
styler := NewStyler(COLOUR_THEME_DARK)
table := NewTable(3, " ")
table.
Cell("FIRST", Options{align: ALIGN_LEFT}).
Cell("SECOND", Options{align: ALIGN_RIGHT}).
Cell("THIRD", Options{align: ALIGN_RIGHT}).
CellL("1").
CellR("2").
CellR("3").
Cell("long-text", Options{align: ALIGN_LEFT}).
Cell(styler.Props(StyleProps{IsUnderlined: true}).Format("asdf"), Options{align: ALIGN_RIGHT}).
Fill("-").
Skip(2).
Cell("foo", Options{align: ALIGN_LEFT})
table.Collect(func(x string) { result += x })
assert.Equal(t, `FIRST SECOND THIRD
1 2 3
long-text `+"\x1b[0m\x1b[4m"+`asdf`+"\x1b[0m"+` -----
foo
`, result)
}
func TestPrintEmptyTable(t *testing.T) {
// If the table is empty, it shouldn’t print a trailing newline.
result := ""
table := NewTable(3, " ")
table.Collect(func(x string) { result += x })
assert.Equal(t, ``, result)
}
func TestPrintTableWithUnicode(t *testing.T) {
result := ""
table := NewTable(3, " ")
table.
Cell("FIRST", Options{align: ALIGN_LEFT}).
Cell("SECOND", Options{align: ALIGN_LEFT}).
Cell("THIRD", Options{align: ALIGN_LEFT}).
CellL("first").
CellR("șëčøñd").
CellR("third")
table.Collect(func(x string) { result += x })
assert.Equal(t, `FIRST SECOND THIRD
first șëčøñd third
`, result)
}
07070100000042000081A40000000000000000000000016863F92F000000D2000000000000000000000000000000000000002D00000000klog-6.6/klog/app/cli/terminalformat/util.gopackage terminalformat
import "regexp"
var ansiSequencePattern = regexp.MustCompile(`\x1b\[[\d;]+m`)
func StripAllAnsiSequences(text string) string {
return ansiSequencePattern.ReplaceAllString(text, "")
}
07070100000043000081A40000000000000000000000016863F92F000001D7000000000000000000000000000000000000003200000000klog-6.6/klog/app/cli/terminalformat/util_test.gopackage terminalformat
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestStripAllAnsiSequences(t *testing.T) {
assert.Equal(t, "test 123", StripAllAnsiSequences("test 123"))
assert.Equal(t, "test 123", StripAllAnsiSequences("test 123"))
assert.Equal(t, "test 123", StripAllAnsiSequences("test \x1b[0m\x1b[4m123\x1b[0m"))
assert.Equal(t, "test 123", StripAllAnsiSequences("\x1b[0m\x1b[4mtest\x1b[0m \x1b[0m\x1b[4m123\x1b[0m"))
}
07070100000044000081A40000000000000000000000016863F92F00010033000000000000000000000000000000000000003600000000klog-6.6/klog/app/cli/terminalformat/xterm-colors.svg<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="960" height="1400">
<style>
/* rect { stroke: black; stroke-width:0.15pt; } */
text {
font-size:9pt;
font-family: "Bitstream Vera Sans Mono", "Droid Sans Mono", "Menlo", "Monaco", "Consolas", "Inconsolata", "Courier New";
fill: white;
}
.head {
font-family: 'Helvetica Neue', Helvetica, Calibri, Arial, sans-serif;
font-size: 20pt;
}
.tiny { font-size: 6pt; }
</style>
<rect x="0" y="0" width="960" height="1400" style="fill:black"/>
<g transform="translate(0,0)">
<g transform="translate(0,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#000000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >000000</text>
<text x="2" y="57" style="fill:#ffffff;" >016</text>
</g>
<g transform="translate(0,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00005f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >00005f</text>
<text x="2" y="57" style="fill:#ffffff;" >017</text>
</g>
<g transform="translate(0,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#000087;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >000087</text>
<text x="2" y="57" style="fill:#ffffff;" >018</text>
</g>
<g transform="translate(0,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#0000af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0000af</text>
<text x="2" y="57" style="fill:#ffffff;" >019</text>
</g>
<g transform="translate(0,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#0000d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0000d7</text>
<text x="2" y="57" style="fill:#ffffff;" >020</text>
</g>
<g transform="translate(0,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#0000ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0000ff</text>
<text x="2" y="57" style="fill:#ffffff;" >021</text>
</g>
<g transform="translate(80,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#005f00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >005f00</text>
<text x="2" y="57" style="fill:#ffffff;" >022</text>
</g>
<g transform="translate(80,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#005f5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >005f5f</text>
<text x="2" y="57" style="fill:#ffffff;" >023</text>
</g>
<g transform="translate(80,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#005f87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >005f87</text>
<text x="2" y="57" style="fill:#ffffff;" >024</text>
</g>
<g transform="translate(80,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#005faf;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >005faf</text>
<text x="2" y="57" style="fill:#ffffff;" >025</text>
</g>
<g transform="translate(80,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#005fd7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >005fd7</text>
<text x="2" y="57" style="fill:#ffffff;" >026</text>
</g>
<g transform="translate(80,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#005fff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >005fff</text>
<text x="2" y="57" style="fill:#ffffff;" >027</text>
</g>
<g transform="translate(160,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#008700;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >008700</text>
<text x="2" y="57" style="fill:#ffffff;" >028</text>
</g>
<g transform="translate(160,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00875f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >00875f</text>
<text x="2" y="57" style="fill:#ffffff;" >029</text>
</g>
<g transform="translate(160,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#008787;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >008787</text>
<text x="2" y="57" style="fill:#ffffff;" >030</text>
</g>
<g transform="translate(160,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#0087af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0087af</text>
<text x="2" y="57" style="fill:#ffffff;" >031</text>
</g>
<g transform="translate(160,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#0087d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0087d7</text>
<text x="2" y="57" style="fill:#ffffff;" >032</text>
</g>
<g transform="translate(160,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#0087ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0087ff</text>
<text x="2" y="57" style="fill:#ffffff;" >033</text>
</g>
<g transform="translate(240,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#00af00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >00af00</text>
<text x="2" y="57" style="fill:#ffffff;" >034</text>
</g>
<g transform="translate(240,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00af5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >00af5f</text>
<text x="2" y="57" style="fill:#ffffff;" >035</text>
</g>
<g transform="translate(240,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#00af87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >00af87</text>
<text x="2" y="57" style="fill:#ffffff;" >036</text>
</g>
<g transform="translate(240,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#00afaf;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >00afaf</text>
<text x="2" y="57" style="fill:#ffffff;" >037</text>
</g>
<g transform="translate(240,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#00afd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00afd7</text>
<text x="2" y="57" style="fill:#000000;" >038</text>
</g>
<g transform="translate(240,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#00afff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00afff</text>
<text x="2" y="57" style="fill:#000000;" >039</text>
</g>
<g transform="translate(320,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#00d700;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00d700</text>
<text x="2" y="57" style="fill:#000000;" >040</text>
</g>
<g transform="translate(320,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00d75f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00d75f</text>
<text x="2" y="57" style="fill:#000000;" >041</text>
</g>
<g transform="translate(320,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#00d787;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00d787</text>
<text x="2" y="57" style="fill:#000000;" >042</text>
</g>
<g transform="translate(320,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#00d7af;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00d7af</text>
<text x="2" y="57" style="fill:#000000;" >043</text>
</g>
<g transform="translate(320,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#00d7d7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00d7d7</text>
<text x="2" y="57" style="fill:#000000;" >044</text>
</g>
<g transform="translate(320,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#00d7ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00d7ff</text>
<text x="2" y="57" style="fill:#000000;" >045</text>
</g>
<g transform="translate(400,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ff00</text>
<text x="2" y="57" style="fill:#000000;" >046</text>
</g>
<g transform="translate(400,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ff5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ff5f</text>
<text x="2" y="57" style="fill:#000000;" >047</text>
</g>
<g transform="translate(400,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ff87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ff87</text>
<text x="2" y="57" style="fill:#000000;" >048</text>
</g>
<g transform="translate(400,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ffaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ffaf</text>
<text x="2" y="57" style="fill:#000000;" >049</text>
</g>
<g transform="translate(400,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ffd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ffd7</text>
<text x="2" y="57" style="fill:#000000;" >050</text>
</g>
<g transform="translate(400,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ffff</text>
<text x="2" y="57" style="fill:#000000;" >051</text>
</g>
<g transform="translate(480,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fff00</text>
<text x="2" y="57" style="fill:#000000;" >082</text>
</g>
<g transform="translate(480,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fff5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fff5f</text>
<text x="2" y="57" style="fill:#000000;" >083</text>
</g>
<g transform="translate(480,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fff87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fff87</text>
<text x="2" y="57" style="fill:#000000;" >084</text>
</g>
<g transform="translate(480,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fffaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fffaf</text>
<text x="2" y="57" style="fill:#000000;" >085</text>
</g>
<g transform="translate(480,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fffd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fffd7</text>
<text x="2" y="57" style="fill:#000000;" >086</text>
</g>
<g transform="translate(480,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fffff</text>
<text x="2" y="57" style="fill:#000000;" >087</text>
</g>
<g transform="translate(560,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fd700;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fd700</text>
<text x="2" y="57" style="fill:#000000;" >076</text>
</g>
<g transform="translate(560,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fd75f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fd75f</text>
<text x="2" y="57" style="fill:#000000;" >077</text>
</g>
<g transform="translate(560,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fd787;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fd787</text>
<text x="2" y="57" style="fill:#000000;" >078</text>
</g>
<g transform="translate(560,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fd7af;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fd7af</text>
<text x="2" y="57" style="fill:#000000;" >079</text>
</g>
<g transform="translate(560,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fd7d7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fd7d7</text>
<text x="2" y="57" style="fill:#000000;" >080</text>
</g>
<g transform="translate(560,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fd7ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fd7ff</text>
<text x="2" y="57" style="fill:#000000;" >081</text>
</g>
<g transform="translate(640,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#5faf00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5faf00</text>
<text x="2" y="57" style="fill:#ffffff;" >070</text>
</g>
<g transform="translate(640,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#5faf5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5faf5f</text>
<text x="2" y="57" style="fill:#000000;" >071</text>
</g>
<g transform="translate(640,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#5faf87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5faf87</text>
<text x="2" y="57" style="fill:#000000;" >072</text>
</g>
<g transform="translate(640,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fafaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fafaf</text>
<text x="2" y="57" style="fill:#000000;" >073</text>
</g>
<g transform="translate(640,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fafd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fafd7</text>
<text x="2" y="57" style="fill:#000000;" >074</text>
</g>
<g transform="translate(640,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#5fafff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >5fafff</text>
<text x="2" y="57" style="fill:#000000;" >075</text>
</g>
<g transform="translate(720,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f8700;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f8700</text>
<text x="2" y="57" style="fill:#ffffff;" >064</text>
</g>
<g transform="translate(720,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f875f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f875f</text>
<text x="2" y="57" style="fill:#ffffff;" >065</text>
</g>
<g transform="translate(720,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f8787;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f8787</text>
<text x="2" y="57" style="fill:#ffffff;" >066</text>
</g>
<g transform="translate(720,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f87af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f87af</text>
<text x="2" y="57" style="fill:#ffffff;" >067</text>
</g>
<g transform="translate(720,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f87d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f87d7</text>
<text x="2" y="57" style="fill:#ffffff;" >068</text>
</g>
<g transform="translate(720,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f87ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f87ff</text>
<text x="2" y="57" style="fill:#ffffff;" >069</text>
</g>
<g transform="translate(800,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f5f00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f5f00</text>
<text x="2" y="57" style="fill:#ffffff;" >058</text>
</g>
<g transform="translate(800,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f5f5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f5f5f</text>
<text x="2" y="57" style="fill:#ffffff;" >059</text>
</g>
<g transform="translate(800,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f5f87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f5f87</text>
<text x="2" y="57" style="fill:#ffffff;" >060</text>
</g>
<g transform="translate(800,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f5faf;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f5faf</text>
<text x="2" y="57" style="fill:#ffffff;" >061</text>
</g>
<g transform="translate(800,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f5fd7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f5fd7</text>
<text x="2" y="57" style="fill:#ffffff;" >062</text>
</g>
<g transform="translate(800,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f5fff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f5fff</text>
<text x="2" y="57" style="fill:#ffffff;" >063</text>
</g>
<g transform="translate(880,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f0000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f0000</text>
<text x="2" y="57" style="fill:#ffffff;" >052</text>
</g>
<g transform="translate(880,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f005f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f005f</text>
<text x="2" y="57" style="fill:#ffffff;" >053</text>
</g>
<g transform="translate(880,120)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f0087;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f0087</text>
<text x="2" y="57" style="fill:#ffffff;" >054</text>
</g>
<g transform="translate(880,180)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f00af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f00af</text>
<text x="2" y="57" style="fill:#ffffff;" >055</text>
</g>
<g transform="translate(880,240)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f00d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f00d7</text>
<text x="2" y="57" style="fill:#ffffff;" >056</text>
</g>
<g transform="translate(880,300)">
<rect x="0" y="0" width="80" height="60" style="fill:#5f00ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >5f00ff</text>
<text x="2" y="57" style="fill:#ffffff;" >057</text>
</g>
<g transform="translate(0,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#8700ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8700ff</text>
<text x="2" y="57" style="fill:#ffffff;" >093</text>
</g>
<g transform="translate(0,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#8700d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8700d7</text>
<text x="2" y="57" style="fill:#ffffff;" >092</text>
</g>
<g transform="translate(0,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#8700af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8700af</text>
<text x="2" y="57" style="fill:#ffffff;" >091</text>
</g>
<g transform="translate(0,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#870087;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >870087</text>
<text x="2" y="57" style="fill:#ffffff;" >090</text>
</g>
<g transform="translate(0,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#87005f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >87005f</text>
<text x="2" y="57" style="fill:#ffffff;" >089</text>
</g>
<g transform="translate(0,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#870000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >870000</text>
<text x="2" y="57" style="fill:#ffffff;" >088</text>
</g>
<g transform="translate(80,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#875fff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >875fff</text>
<text x="2" y="57" style="fill:#ffffff;" >099</text>
</g>
<g transform="translate(80,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#875fd7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >875fd7</text>
<text x="2" y="57" style="fill:#ffffff;" >098</text>
</g>
<g transform="translate(80,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#875faf;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >875faf</text>
<text x="2" y="57" style="fill:#ffffff;" >097</text>
</g>
<g transform="translate(80,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#875f87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >875f87</text>
<text x="2" y="57" style="fill:#ffffff;" >096</text>
</g>
<g transform="translate(80,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#875f5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >875f5f</text>
<text x="2" y="57" style="fill:#ffffff;" >095</text>
</g>
<g transform="translate(80,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#875f00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >875f00</text>
<text x="2" y="57" style="fill:#ffffff;" >094</text>
</g>
<g transform="translate(160,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#8787ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8787ff</text>
<text x="2" y="57" style="fill:#ffffff;" >105</text>
</g>
<g transform="translate(160,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#8787d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8787d7</text>
<text x="2" y="57" style="fill:#ffffff;" >104</text>
</g>
<g transform="translate(160,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#8787af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8787af</text>
<text x="2" y="57" style="fill:#ffffff;" >103</text>
</g>
<g transform="translate(160,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#878787;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >878787</text>
<text x="2" y="57" style="fill:#ffffff;" >102</text>
</g>
<g transform="translate(160,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#87875f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >87875f</text>
<text x="2" y="57" style="fill:#ffffff;" >101</text>
</g>
<g transform="translate(160,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#878700;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >878700</text>
<text x="2" y="57" style="fill:#ffffff;" >100</text>
</g>
<g transform="translate(240,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#87afff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87afff</text>
<text x="2" y="57" style="fill:#000000;" >111</text>
</g>
<g transform="translate(240,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#87afd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87afd7</text>
<text x="2" y="57" style="fill:#000000;" >110</text>
</g>
<g transform="translate(240,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#87afaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87afaf</text>
<text x="2" y="57" style="fill:#000000;" >109</text>
</g>
<g transform="translate(240,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#87af87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87af87</text>
<text x="2" y="57" style="fill:#000000;" >108</text>
</g>
<g transform="translate(240,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#87af5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87af5f</text>
<text x="2" y="57" style="fill:#000000;" >107</text>
</g>
<g transform="translate(240,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#87af00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87af00</text>
<text x="2" y="57" style="fill:#000000;" >106</text>
</g>
<g transform="translate(320,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#87d7ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87d7ff</text>
<text x="2" y="57" style="fill:#000000;" >117</text>
</g>
<g transform="translate(320,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#87d7d7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87d7d7</text>
<text x="2" y="57" style="fill:#000000;" >116</text>
</g>
<g transform="translate(320,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#87d7af;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87d7af</text>
<text x="2" y="57" style="fill:#000000;" >115</text>
</g>
<g transform="translate(320,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#87d787;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87d787</text>
<text x="2" y="57" style="fill:#000000;" >114</text>
</g>
<g transform="translate(320,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#87d75f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87d75f</text>
<text x="2" y="57" style="fill:#000000;" >113</text>
</g>
<g transform="translate(320,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#87d700;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87d700</text>
<text x="2" y="57" style="fill:#000000;" >112</text>
</g>
<g transform="translate(400,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#87ffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87ffff</text>
<text x="2" y="57" style="fill:#000000;" >123</text>
</g>
<g transform="translate(400,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#87ffd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87ffd7</text>
<text x="2" y="57" style="fill:#000000;" >122</text>
</g>
<g transform="translate(400,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#87ffaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87ffaf</text>
<text x="2" y="57" style="fill:#000000;" >121</text>
</g>
<g transform="translate(400,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#87ff87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87ff87</text>
<text x="2" y="57" style="fill:#000000;" >120</text>
</g>
<g transform="translate(400,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#87ff5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87ff5f</text>
<text x="2" y="57" style="fill:#000000;" >119</text>
</g>
<g transform="translate(400,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#87ff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >87ff00</text>
<text x="2" y="57" style="fill:#000000;" >118</text>
</g>
<g transform="translate(480,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#afffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afffff</text>
<text x="2" y="57" style="fill:#000000;" >159</text>
</g>
<g transform="translate(480,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#afffd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afffd7</text>
<text x="2" y="57" style="fill:#000000;" >158</text>
</g>
<g transform="translate(480,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#afffaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afffaf</text>
<text x="2" y="57" style="fill:#000000;" >157</text>
</g>
<g transform="translate(480,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#afff87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afff87</text>
<text x="2" y="57" style="fill:#000000;" >156</text>
</g>
<g transform="translate(480,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#afff5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afff5f</text>
<text x="2" y="57" style="fill:#000000;" >155</text>
</g>
<g transform="translate(480,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#afff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afff00</text>
<text x="2" y="57" style="fill:#000000;" >154</text>
</g>
<g transform="translate(560,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#afd7ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afd7ff</text>
<text x="2" y="57" style="fill:#000000;" >153</text>
</g>
<g transform="translate(560,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#afd7d7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afd7d7</text>
<text x="2" y="57" style="fill:#000000;" >152</text>
</g>
<g transform="translate(560,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#afd7af;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afd7af</text>
<text x="2" y="57" style="fill:#000000;" >151</text>
</g>
<g transform="translate(560,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#afd787;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afd787</text>
<text x="2" y="57" style="fill:#000000;" >150</text>
</g>
<g transform="translate(560,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#afd75f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afd75f</text>
<text x="2" y="57" style="fill:#000000;" >149</text>
</g>
<g transform="translate(560,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#afd700;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afd700</text>
<text x="2" y="57" style="fill:#000000;" >148</text>
</g>
<g transform="translate(640,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#afafff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afafff</text>
<text x="2" y="57" style="fill:#000000;" >147</text>
</g>
<g transform="translate(640,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#afafd7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afafd7</text>
<text x="2" y="57" style="fill:#000000;" >146</text>
</g>
<g transform="translate(640,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#afafaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afafaf</text>
<text x="2" y="57" style="fill:#000000;" >145</text>
</g>
<g transform="translate(640,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#afaf87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afaf87</text>
<text x="2" y="57" style="fill:#000000;" >144</text>
</g>
<g transform="translate(640,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#afaf5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afaf5f</text>
<text x="2" y="57" style="fill:#000000;" >143</text>
</g>
<g transform="translate(640,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#afaf00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >afaf00</text>
<text x="2" y="57" style="fill:#000000;" >142</text>
</g>
<g transform="translate(720,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#af87ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >af87ff</text>
<text x="2" y="57" style="fill:#000000;" >141</text>
</g>
<g transform="translate(720,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#af87d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af87d7</text>
<text x="2" y="57" style="fill:#ffffff;" >140</text>
</g>
<g transform="translate(720,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#af87af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af87af</text>
<text x="2" y="57" style="fill:#ffffff;" >139</text>
</g>
<g transform="translate(720,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#af8787;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af8787</text>
<text x="2" y="57" style="fill:#ffffff;" >138</text>
</g>
<g transform="translate(720,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#af875f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af875f</text>
<text x="2" y="57" style="fill:#ffffff;" >137</text>
</g>
<g transform="translate(720,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#af8700;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af8700</text>
<text x="2" y="57" style="fill:#ffffff;" >136</text>
</g>
<g transform="translate(800,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#af5fff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af5fff</text>
<text x="2" y="57" style="fill:#ffffff;" >135</text>
</g>
<g transform="translate(800,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#af5fd7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af5fd7</text>
<text x="2" y="57" style="fill:#ffffff;" >134</text>
</g>
<g transform="translate(800,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#af5faf;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af5faf</text>
<text x="2" y="57" style="fill:#ffffff;" >133</text>
</g>
<g transform="translate(800,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#af5f87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af5f87</text>
<text x="2" y="57" style="fill:#ffffff;" >132</text>
</g>
<g transform="translate(800,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#af5f5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af5f5f</text>
<text x="2" y="57" style="fill:#ffffff;" >131</text>
</g>
<g transform="translate(800,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#af5f00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af5f00</text>
<text x="2" y="57" style="fill:#ffffff;" >130</text>
</g>
<g transform="translate(880,380)">
<rect x="0" y="0" width="80" height="60" style="fill:#af00ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af00ff</text>
<text x="2" y="57" style="fill:#ffffff;" >129</text>
</g>
<g transform="translate(880,440)">
<rect x="0" y="0" width="80" height="60" style="fill:#af00d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af00d7</text>
<text x="2" y="57" style="fill:#ffffff;" >128</text>
</g>
<g transform="translate(880,500)">
<rect x="0" y="0" width="80" height="60" style="fill:#af00af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af00af</text>
<text x="2" y="57" style="fill:#ffffff;" >127</text>
</g>
<g transform="translate(880,560)">
<rect x="0" y="0" width="80" height="60" style="fill:#af0087;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af0087</text>
<text x="2" y="57" style="fill:#ffffff;" >126</text>
</g>
<g transform="translate(880,620)">
<rect x="0" y="0" width="80" height="60" style="fill:#af005f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af005f</text>
<text x="2" y="57" style="fill:#ffffff;" >125</text>
</g>
<g transform="translate(880,680)">
<rect x="0" y="0" width="80" height="60" style="fill:#af0000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >af0000</text>
<text x="2" y="57" style="fill:#ffffff;" >124</text>
</g>
<g transform="translate(0,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#d70000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d70000</text>
<text x="2" y="57" style="fill:#ffffff;" >160</text>
</g>
<g transform="translate(0,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#d7005f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d7005f</text>
<text x="2" y="57" style="fill:#ffffff;" >161</text>
</g>
<g transform="translate(0,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#d70087;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d70087</text>
<text x="2" y="57" style="fill:#ffffff;" >162</text>
</g>
<g transform="translate(0,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#d700af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d700af</text>
<text x="2" y="57" style="fill:#ffffff;" >163</text>
</g>
<g transform="translate(0,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#d700d7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d700d7</text>
<text x="2" y="57" style="fill:#ffffff;" >164</text>
</g>
<g transform="translate(0,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#d700ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d700ff</text>
<text x="2" y="57" style="fill:#ffffff;" >165</text>
</g>
<g transform="translate(80,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#d75f00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d75f00</text>
<text x="2" y="57" style="fill:#ffffff;" >166</text>
</g>
<g transform="translate(80,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#d75f5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d75f5f</text>
<text x="2" y="57" style="fill:#ffffff;" >167</text>
</g>
<g transform="translate(80,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#d75f87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d75f87</text>
<text x="2" y="57" style="fill:#ffffff;" >168</text>
</g>
<g transform="translate(80,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#d75faf;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d75faf</text>
<text x="2" y="57" style="fill:#ffffff;" >169</text>
</g>
<g transform="translate(80,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#d75fd7;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d75fd7</text>
<text x="2" y="57" style="fill:#ffffff;" >170</text>
</g>
<g transform="translate(80,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#d75fff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >d75fff</text>
<text x="2" y="57" style="fill:#ffffff;" >171</text>
</g>
<g transform="translate(160,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#d78700;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d78700</text>
<text x="2" y="57" style="fill:#000000;" >172</text>
</g>
<g transform="translate(160,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#d7875f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d7875f</text>
<text x="2" y="57" style="fill:#000000;" >173</text>
</g>
<g transform="translate(160,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#d78787;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d78787</text>
<text x="2" y="57" style="fill:#000000;" >174</text>
</g>
<g transform="translate(160,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#d787af;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d787af</text>
<text x="2" y="57" style="fill:#000000;" >175</text>
</g>
<g transform="translate(160,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#d787d7;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d787d7</text>
<text x="2" y="57" style="fill:#000000;" >176</text>
</g>
<g transform="translate(160,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#d787ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d787ff</text>
<text x="2" y="57" style="fill:#000000;" >177</text>
</g>
<g transform="translate(240,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfaf00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfaf00</text>
<text x="2" y="57" style="fill:#000000;" >178</text>
</g>
<g transform="translate(240,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfaf5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfaf5f</text>
<text x="2" y="57" style="fill:#000000;" >179</text>
</g>
<g transform="translate(240,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfaf87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfaf87</text>
<text x="2" y="57" style="fill:#000000;" >180</text>
</g>
<g transform="translate(240,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfafaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfafaf</text>
<text x="2" y="57" style="fill:#000000;" >181</text>
</g>
<g transform="translate(240,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfafdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfafdf</text>
<text x="2" y="57" style="fill:#000000;" >182</text>
</g>
<g transform="translate(240,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfafff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfafff</text>
<text x="2" y="57" style="fill:#000000;" >183</text>
</g>
<g transform="translate(320,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfdf00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfdf00</text>
<text x="2" y="57" style="fill:#000000;" >184</text>
</g>
<g transform="translate(320,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfdf5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfdf5f</text>
<text x="2" y="57" style="fill:#000000;" >185</text>
</g>
<g transform="translate(320,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfdf87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfdf87</text>
<text x="2" y="57" style="fill:#000000;" >186</text>
</g>
<g transform="translate(320,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfdfaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfdfaf</text>
<text x="2" y="57" style="fill:#000000;" >187</text>
</g>
<g transform="translate(320,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfdfdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfdfdf</text>
<text x="2" y="57" style="fill:#000000;" >188</text>
</g>
<g transform="translate(320,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfdfff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfdfff</text>
<text x="2" y="57" style="fill:#000000;" >189</text>
</g>
<g transform="translate(400,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfff00</text>
<text x="2" y="57" style="fill:#000000;" >190</text>
</g>
<g transform="translate(400,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfff5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfff5f</text>
<text x="2" y="57" style="fill:#000000;" >191</text>
</g>
<g transform="translate(400,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfff87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfff87</text>
<text x="2" y="57" style="fill:#000000;" >192</text>
</g>
<g transform="translate(400,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfffaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfffaf</text>
<text x="2" y="57" style="fill:#000000;" >193</text>
</g>
<g transform="translate(400,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfffdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfffdf</text>
<text x="2" y="57" style="fill:#000000;" >194</text>
</g>
<g transform="translate(400,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#dfffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dfffff</text>
<text x="2" y="57" style="fill:#000000;" >195</text>
</g>
<g transform="translate(480,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffff00</text>
<text x="2" y="57" style="fill:#000000;" >226</text>
</g>
<g transform="translate(480,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffff5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffff5f</text>
<text x="2" y="57" style="fill:#000000;" >227</text>
</g>
<g transform="translate(480,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffff87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffff87</text>
<text x="2" y="57" style="fill:#000000;" >228</text>
</g>
<g transform="translate(480,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffffaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffffaf</text>
<text x="2" y="57" style="fill:#000000;" >229</text>
</g>
<g transform="translate(480,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffffdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffffdf</text>
<text x="2" y="57" style="fill:#000000;" >230</text>
</g>
<g transform="translate(480,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffffff</text>
<text x="2" y="57" style="fill:#000000;" >231</text>
</g>
<g transform="translate(560,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffdf00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffdf00</text>
<text x="2" y="57" style="fill:#000000;" >220</text>
</g>
<g transform="translate(560,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffdf5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffdf5f</text>
<text x="2" y="57" style="fill:#000000;" >221</text>
</g>
<g transform="translate(560,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffdf87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffdf87</text>
<text x="2" y="57" style="fill:#000000;" >222</text>
</g>
<g transform="translate(560,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffdfaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffdfaf</text>
<text x="2" y="57" style="fill:#000000;" >223</text>
</g>
<g transform="translate(560,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffdfdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffdfdf</text>
<text x="2" y="57" style="fill:#000000;" >224</text>
</g>
<g transform="translate(560,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffdfff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffdfff</text>
<text x="2" y="57" style="fill:#000000;" >225</text>
</g>
<g transform="translate(640,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffaf00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffaf00</text>
<text x="2" y="57" style="fill:#000000;" >214</text>
</g>
<g transform="translate(640,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffaf5f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffaf5f</text>
<text x="2" y="57" style="fill:#000000;" >215</text>
</g>
<g transform="translate(640,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffaf87;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffaf87</text>
<text x="2" y="57" style="fill:#000000;" >216</text>
</g>
<g transform="translate(640,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffafaf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffafaf</text>
<text x="2" y="57" style="fill:#000000;" >217</text>
</g>
<g transform="translate(640,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffafdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffafdf</text>
<text x="2" y="57" style="fill:#000000;" >218</text>
</g>
<g transform="translate(640,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffafff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffafff</text>
<text x="2" y="57" style="fill:#000000;" >219</text>
</g>
<g transform="translate(720,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff8700;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff8700</text>
<text x="2" y="57" style="fill:#000000;" >208</text>
</g>
<g transform="translate(720,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff875f;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff875f</text>
<text x="2" y="57" style="fill:#000000;" >209</text>
</g>
<g transform="translate(720,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff8787;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff8787</text>
<text x="2" y="57" style="fill:#000000;" >210</text>
</g>
<g transform="translate(720,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff87af;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff87af</text>
<text x="2" y="57" style="fill:#000000;" >211</text>
</g>
<g transform="translate(720,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff87df;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff87df</text>
<text x="2" y="57" style="fill:#000000;" >212</text>
</g>
<g transform="translate(720,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff87ff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff87ff</text>
<text x="2" y="57" style="fill:#000000;" >213</text>
</g>
<g transform="translate(800,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff5f00;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff5f00</text>
<text x="2" y="57" style="fill:#ffffff;" >202</text>
</g>
<g transform="translate(800,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff5f5f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff5f5f</text>
<text x="2" y="57" style="fill:#ffffff;" >203</text>
</g>
<g transform="translate(800,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff5f87;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff5f87</text>
<text x="2" y="57" style="fill:#ffffff;" >204</text>
</g>
<g transform="translate(800,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff5faf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff5faf</text>
<text x="2" y="57" style="fill:#000000;" >205</text>
</g>
<g transform="translate(800,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff5fdf;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff5fdf</text>
<text x="2" y="57" style="fill:#000000;" >206</text>
</g>
<g transform="translate(800,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff5fff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ff5fff</text>
<text x="2" y="57" style="fill:#000000;" >207</text>
</g>
<g transform="translate(880,760)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff0000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff0000</text>
<text x="2" y="57" style="fill:#ffffff;" >196</text>
</g>
<g transform="translate(880,820)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff005f;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff005f</text>
<text x="2" y="57" style="fill:#ffffff;" >197</text>
</g>
<g transform="translate(880,880)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff0087;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff0087</text>
<text x="2" y="57" style="fill:#ffffff;" >198</text>
</g>
<g transform="translate(880,940)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff00af;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff00af</text>
<text x="2" y="57" style="fill:#ffffff;" >199</text>
</g>
<g transform="translate(880,1000)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff00df;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff00df</text>
<text x="2" y="57" style="fill:#ffffff;" >200</text>
</g>
<g transform="translate(880,1060)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff00ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff00ff</text>
<text x="2" y="57" style="fill:#ffffff;" >201</text>
</g>
</g>
<g transform="translate(0,1140)">
<g transform="translate(0,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#080808;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >080808</text>
<text x="2" y="57" style="fill:#ffffff;" >232</text>
</g>
<g transform="translate(80,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#121212;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >121212</text>
<text x="2" y="57" style="fill:#ffffff;" >233</text>
</g>
<g transform="translate(160,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#1c1c1c;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >1c1c1c</text>
<text x="2" y="57" style="fill:#ffffff;" >234</text>
</g>
<g transform="translate(240,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#262626;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >262626</text>
<text x="2" y="57" style="fill:#ffffff;" >235</text>
</g>
<g transform="translate(320,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#303030;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >303030</text>
<text x="2" y="57" style="fill:#ffffff;" >236</text>
</g>
<g transform="translate(400,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#3a3a3a;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >3a3a3a</text>
<text x="2" y="57" style="fill:#ffffff;" >237</text>
</g>
<g transform="translate(480,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#444444;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >444444</text>
<text x="2" y="57" style="fill:#ffffff;" >238</text>
</g>
<g transform="translate(560,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#4e4e4e;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >4e4e4e</text>
<text x="2" y="57" style="fill:#ffffff;" >239</text>
</g>
<g transform="translate(640,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#585858;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >585858</text>
<text x="2" y="57" style="fill:#ffffff;" >240</text>
</g>
<g transform="translate(720,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#626262;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >626262</text>
<text x="2" y="57" style="fill:#ffffff;" >241</text>
</g>
<g transform="translate(800,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#6c6c6c;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >6c6c6c</text>
<text x="2" y="57" style="fill:#ffffff;" >242</text>
</g>
<g transform="translate(880,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#767676;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >767676</text>
<text x="2" y="57" style="fill:#ffffff;" >243</text>
</g>
<g transform="translate(0,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#eeeeee;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >eeeeee</text>
<text x="2" y="57" style="fill:#000000;" >255</text>
</g>
<g transform="translate(80,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#e4e4e4;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >e4e4e4</text>
<text x="2" y="57" style="fill:#000000;" >254</text>
</g>
<g transform="translate(160,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#dadada;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >dadada</text>
<text x="2" y="57" style="fill:#000000;" >253</text>
</g>
<g transform="translate(240,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#d0d0d0;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >d0d0d0</text>
<text x="2" y="57" style="fill:#000000;" >252</text>
</g>
<g transform="translate(320,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#c6c6c6;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >c6c6c6</text>
<text x="2" y="57" style="fill:#000000;" >251</text>
</g>
<g transform="translate(400,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#bcbcbc;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >bcbcbc</text>
<text x="2" y="57" style="fill:#000000;" >250</text>
</g>
<g transform="translate(480,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#b2b2b2;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >b2b2b2</text>
<text x="2" y="57" style="fill:#000000;" >249</text>
</g>
<g transform="translate(560,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#a8a8a8;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >a8a8a8</text>
<text x="2" y="57" style="fill:#000000;" >248</text>
</g>
<g transform="translate(640,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#9e9e9e;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >9e9e9e</text>
<text x="2" y="57" style="fill:#000000;" >247</text>
</g>
<g transform="translate(720,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#949494;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >949494</text>
<text x="2" y="57" style="fill:#ffffff;" >246</text>
</g>
<g transform="translate(800,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#8a8a8a;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >8a8a8a</text>
<text x="2" y="57" style="fill:#ffffff;" >245</text>
</g>
<g transform="translate(880,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#808080;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >808080</text>
<text x="2" y="57" style="fill:#ffffff;" >244</text>
</g>
</g>
<g transform="translate(0,1280)">
<g transform="translate(0,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#000000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >000000</text>
<text x="2" y="57" style="fill:#ffffff;" >000</text>
</g>
<g transform="translate(80,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#800000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >800000</text>
<text x="2" y="57" style="fill:#ffffff;" >001</text>
</g>
<g transform="translate(160,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#008000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >008000</text>
<text x="2" y="57" style="fill:#ffffff;" >002</text>
</g>
<g transform="translate(240,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#808000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >808000</text>
<text x="2" y="57" style="fill:#ffffff;" >003</text>
</g>
<g transform="translate(320,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#000080;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >000080</text>
<text x="2" y="57" style="fill:#ffffff;" >004</text>
</g>
<g transform="translate(400,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#800080;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >800080</text>
<text x="2" y="57" style="fill:#ffffff;" >005</text>
</g>
<g transform="translate(480,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#008080;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >008080</text>
<text x="2" y="57" style="fill:#ffffff;" >006</text>
</g>
<g transform="translate(560,0)">
<rect x="0" y="0" width="80" height="60" style="fill:#c0c0c0;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >c0c0c0</text>
<text x="2" y="57" style="fill:#000000;" >007</text>
</g>
<g transform="translate(0,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#808080;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >808080</text>
<text x="2" y="57" style="fill:#ffffff;" >008</text>
</g>
<g transform="translate(80,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff0000;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff0000</text>
<text x="2" y="57" style="fill:#ffffff;" >009</text>
</g>
<g transform="translate(160,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ff00</text>
<text x="2" y="57" style="fill:#000000;" >010</text>
</g>
<g transform="translate(240,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffff00;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffff00</text>
<text x="2" y="57" style="fill:#000000;" >011</text>
</g>
<g transform="translate(320,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#0000ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >0000ff</text>
<text x="2" y="57" style="fill:#ffffff;" >012</text>
</g>
<g transform="translate(400,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#ff00ff;" />
<text x="40" y="32" style="fill:#ffffff;text-anchor:middle;" >ff00ff</text>
<text x="2" y="57" style="fill:#ffffff;" >013</text>
</g>
<g transform="translate(480,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#00ffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >00ffff</text>
<text x="2" y="57" style="fill:#000000;" >014</text>
</g>
<g transform="translate(560,60)">
<rect x="0" y="0" width="80" height="60" style="fill:#ffffff;" />
<text x="40" y="32" style="fill:#000000;text-anchor:middle;" >ffffff</text>
<text x="2" y="57" style="fill:#000000;" >015</text>
</g>
</g>
<g transform="translate(640, 1280)">
<text x="20" y="32" class="head">xterm-256-color chart</text>
<text x="20" y="78" class="head tiny">Spooned carefully into SVG by Jason Milkins in 2012</text>
<text x="20" y="90" class="head tiny">this SVG is in the public domain</text>
<a xlink:href="https://gist.github.com/2868981"><text x="20" y="102" class="head tiny">https://gist.github.com/2868981 - contains the dataset in yaml</text></a>
</g>
</svg>
07070100000045000081A40000000000000000000000016863F92F00001442000000000000000000000000000000000000002A00000000klog-6.6/klog/app/cli/testcontext_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/command"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/reconciling"
"github.com/jotaen/klog/klog/parser/txt"
gotime "time"
)
func NewTestingContext() TestingContext {
bc := app.NewEmptyBookmarksCollection()
config := app.NewDefaultConfig(tf.COLOUR_THEME_NO_COLOUR)
styler := tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR)
return TestingContext{
State: State{
printBuffer: "",
writtenFileContents: "",
},
now: gotime.Now(),
records: nil,
blocks: nil,
styler: styler,
serialiser: app.NewSerialiser(styler, false),
bookmarks: bc,
editorsAuto: nil,
editorExplicit: "",
fileExplorers: nil,
execute: func(_ command.Command) app.Error {
return nil
},
config: &config,
}
}
func (ctx TestingContext) _SetRecords(recordsText string) TestingContext {
records, blocks, err := parser.NewSerialParser().Parse(recordsText)
if err != nil {
panic("Invalid records")
}
ctx.records = records
ctx.blocks = blocks
return ctx
}
func (ctx TestingContext) _SetNow(Y int, M int, D int, h int, m int) TestingContext {
ctx.now = gotime.Date(Y, gotime.Month(M), D, h, m, 0, 0, gotime.UTC)
return ctx
}
func (ctx TestingContext) _SetEditors(auto []command.Command, explicit string) TestingContext {
ctx.editorsAuto = auto
ctx.editorExplicit = explicit
return ctx
}
func (ctx TestingContext) _SetFileExplorers(cs []command.Command) TestingContext {
ctx.fileExplorers = cs
return ctx
}
func (ctx TestingContext) _SetFileConfig(configFile string) TestingContext {
fileCfg := app.FromConfigFile{FileContents: configFile}
err := fileCfg.Apply(ctx.config)
if err != nil {
panic(err)
}
return ctx
}
func (ctx TestingContext) _SetExecute(execute func(command.Command) app.Error) TestingContext {
ctx.execute = execute
return ctx
}
func (ctx TestingContext) _Run(cmd func(app.Context) app.Error) (State, app.Error) {
cmdErr := cmd(&ctx)
out := ctx.printBuffer
if len(out) > 0 && out[0] != '\n' {
out = "\n" + out
}
return State{out, ctx.writtenFileContents}, cmdErr
}
type State struct {
printBuffer string
writtenFileContents string
}
type TestingContext struct {
State
now gotime.Time
records []klog.Record
blocks []txt.Block
styler tf.Styler
serialiser app.TextSerialiser
bookmarks app.BookmarksCollection
editorsAuto []command.Command
editorExplicit string
fileExplorers []command.Command
execute func(command.Command) app.Error
config *app.Config
}
func (ctx *TestingContext) Print(s string) {
ctx.printBuffer += s
}
func (ctx *TestingContext) ReadLine() (string, app.Error) {
return "", nil
}
func (ctx *TestingContext) HomeFolder() string {
return "~"
}
func (ctx *TestingContext) KlogConfigFolder() app.File {
return app.NewFileOrPanic("/tmp/sample-klog-config-folder")
}
func (ctx *TestingContext) Meta() app.Meta {
return app.Meta{
Specification: "",
License: "",
Version: "v0.0",
SrcHash: "abc1234",
}
}
func (ctx *TestingContext) ReadInputs(_ ...app.FileOrBookmarkName) ([]klog.Record, app.Error) {
return ctx.records, nil
}
func (ctx *TestingContext) ReconcileFile(_ app.FileOrBookmarkName, creators []reconciling.Creator, reconcile ...reconciling.Reconcile) (*reconciling.Result, app.Error) {
result, err := app.ApplyReconciler(ctx.records, ctx.blocks, creators, reconcile...)
if err != nil {
return nil, err
}
ctx.writtenFileContents = result.AllSerialised
return result, nil
}
func (ctx *TestingContext) WriteFile(_ app.File, contents string) app.Error {
ctx.writtenFileContents = contents
return nil
}
func (ctx *TestingContext) Now() gotime.Time {
return ctx.now
}
func (ctx *TestingContext) RetrieveTargetFile(fileArg app.FileOrBookmarkName) (app.FileWithContents, app.Error) {
if fileArg == "" {
return nil, app.NewError("Error", "Error", nil)
}
return app.NewFileWithContents(string(fileArg), "")
}
func (ctx *TestingContext) ReadBookmarks() (app.BookmarksCollection, app.Error) {
return ctx.bookmarks, nil
}
func (ctx *TestingContext) ManipulateBookmarks(_ func(app.BookmarksCollection) app.Error) app.Error {
return nil
}
func (ctx *TestingContext) Execute(cmd command.Command) app.Error {
return ctx.execute(cmd)
}
func (ctx *TestingContext) Editors() (string, []command.Command) {
return ctx.editorExplicit, ctx.editorsAuto
}
func (ctx *TestingContext) FileExplorers() []command.Command {
return ctx.fileExplorers
}
func (ctx *TestingContext) Serialise() (tf.Styler, app.TextSerialiser) {
return ctx.styler, ctx.serialiser
}
func (ctx *TestingContext) ConfigureSerialisation(fn func(tf.Styler, bool) (tf.Styler, bool)) {
styler, decimalDuration := fn(ctx.styler, ctx.serialiser.DecimalDuration)
ctx.styler = styler
ctx.serialiser = app.NewSerialiser(styler, decimalDuration)
}
func (ctx *TestingContext) Debug(_ func()) {}
func (ctx *TestingContext) Config() app.Config {
return *ctx.config
}
07070100000046000081A40000000000000000000000016863F92F0000023E000000000000000000000000000000000000002600000000klog-6.6/klog/app/cli/testspy_test.gopackage cli
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/command"
)
type commandSpy struct {
LastCmd command.Command
Count int
onCmd func(command.Command) app.Error
}
func (c *commandSpy) Execute(cmd command.Command) app.Error {
c.LastCmd = cmd
c.Count++
return c.onCmd(cmd)
}
func newCommandSpy(onCmd func(command.Command) app.Error) *commandSpy {
if onCmd == nil {
onCmd = func(_ command.Command) app.Error {
return nil
}
}
return &commandSpy{
LastCmd: command.Command{},
Count: 0,
onCmd: onCmd,
}
}
07070100000047000081A40000000000000000000000016863F92F0000163C000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/today.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
gotime "time"
)
type Today struct {
util.DiffArgs
util.NowArgs
Follow bool `name:"follow" short:"f" help:"Keep shell open and follow changes."`
util.DecimalArgs
util.WarnArgs
util.NoStyleArgs
util.InputFilesArgs
}
func (opt *Today) Help() string {
return `
Convenience command to get a brief overview (“check in”) of the current day.
It evaluates the total time separately for today’s records and all other records.
If there are no records today, it falls back to yesterday.
When both '--now' and '--diff' are set, it also calculates the forecasted end-time at which your time goal will be reached.
(I.e. when the difference between should and actual time would be 0.)
Use the '--follow' flag to keep the shell open and display changes live.
`
}
func (opt *Today) Run(ctx app.Context) app.Error {
opt.DecimalArgs.Apply(&ctx)
opt.NoStyleArgs.Apply(&ctx)
if opt.Follow {
return util.WithRepeat(ctx.Print, 1*gotime.Second, func(counter int64) app.Error {
err := handle(opt, ctx)
if counter < 7 {
// Display exit hint for a couple of seconds.
ctx.Print("\nPress ^C to exit\n")
}
return err
})
}
return handle(opt, ctx)
}
var (
INDENT = " "
N_A = "n/a"
QQQ = "???"
COL_1 = 8
COL_2 = 10
COL_3 = 9
COL_4 = 11
)
func handle(opt *Today, ctx app.Context) app.Error {
records, err := ctx.ReadInputs(opt.File...)
if err != nil {
return err
}
now := ctx.Now()
nErr := opt.ApplyNow(now, records...)
if nErr != nil {
return nErr
}
styler, serialiser := ctx.Serialise()
currentRecords, otherRecords, isYesterday := splitIntoCurrentAndOther(now, records)
hasCurrentRecords := len(currentRecords) > 0
currentTotal, currentShouldTotal, currentDiff := opt.evaluate(currentRecords)
currentEndTime, _ := klog.NewTimeFromGo(now).Plus(klog.NewDuration(0, 0).Minus(currentDiff))
otherTotal, otherShouldTotal, otherDiff := opt.evaluate(otherRecords)
grandTotal := currentTotal.Plus(otherTotal)
grandShouldTotal := klog.NewShouldTotal(0, currentShouldTotal.Plus(otherShouldTotal).InMinutes())
grandDiff := service.Diff(grandShouldTotal, grandTotal)
grandEndTime, _ := klog.NewTimeFromGo(now).Plus(klog.NewDuration(0, 0).Minus(grandDiff))
numberOfValueColumns := func() int {
if opt.Diff {
if opt.Now {
return 4
}
return 3
}
return 1
}()
numberOfColumns := 1 + numberOfValueColumns
table := tf.NewTable(numberOfColumns, " ")
// Headline:
table.
CellL(" ").
CellR(" Total")
if opt.Diff {
table.CellR(" Should").CellR(" Diff")
if opt.Now {
table.CellR(" End-Time")
}
}
// Current:
if isYesterday {
table.CellL("Yesterday")
} else {
table.CellL("Today")
}
if hasCurrentRecords {
table.CellR(serialiser.Duration(currentTotal))
} else {
table.CellR(N_A)
}
if opt.Diff {
if hasCurrentRecords {
table.
CellR(serialiser.ShouldTotal(currentShouldTotal)).
CellR(serialiser.SignedDuration(currentDiff))
} else {
table.CellR(N_A).CellR(N_A)
}
if opt.Now {
if hasCurrentRecords {
if currentEndTime != nil {
if opt.HadOpenRange() {
table.CellR(serialiser.Time(currentEndTime))
} else {
table.CellR(
styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).
Format("(" + currentEndTime.ToString() + ")"))
}
} else {
table.CellR(QQQ)
}
} else {
table.CellR(N_A)
}
}
}
// Other:
table.CellL("Other").CellR(serialiser.Duration(otherTotal))
if opt.Diff {
table.
CellR(serialiser.ShouldTotal(otherShouldTotal)).
CellR(serialiser.SignedDuration(otherDiff))
if opt.Now {
table.Skip(1)
}
}
// Line:
table.Skip(1).Fill("=")
if opt.Diff {
table.Fill("=").Fill("=")
if opt.Now {
table.Skip(1)
}
}
// GrandTotal:
table.CellL("All").CellR(serialiser.Duration(grandTotal))
if opt.Diff {
table.
CellR(serialiser.ShouldTotal(grandShouldTotal)).
CellR(serialiser.SignedDuration(grandDiff))
if opt.Now {
if hasCurrentRecords {
if grandEndTime != nil {
if opt.HadOpenRange() {
table.CellR(serialiser.Time(grandEndTime))
} else {
table.CellR(
styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).
Format("(" + grandEndTime.ToString() + ")"))
}
} else {
table.CellR(QQQ)
}
} else {
table.CellR(N_A)
}
}
}
table.Collect(ctx.Print)
opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings())
return nil
}
func (opt *Today) evaluate(records []klog.Record) (klog.Duration, klog.Duration, klog.Duration) {
total := service.Total(records...)
shouldTotal := service.ShouldTotalSum(records...)
diff := service.Diff(shouldTotal, total)
return total, shouldTotal, diff
}
func splitIntoCurrentAndOther(now gotime.Time, records []klog.Record) ([]klog.Record, []klog.Record, bool) {
var todaysRecords []klog.Record
var yesterdaysRecords []klog.Record
var otherRecords []klog.Record
today := klog.NewDateFromGo(now)
yesterday := today.PlusDays(-1)
for _, r := range records {
if r.Date().IsEqualTo(today) {
todaysRecords = append(todaysRecords, r)
} else if r.Date().IsEqualTo(yesterday) {
yesterdaysRecords = append(yesterdaysRecords, r)
} else {
otherRecords = append(otherRecords, r)
}
}
if len(todaysRecords) > 0 {
return todaysRecords, append(otherRecords, yesterdaysRecords...), false
}
if len(yesterdaysRecords) > 0 {
return yesterdaysRecords, otherRecords, true
}
return nil, otherRecords, false
}
07070100000048000081A40000000000000000000000016863F92F000010B0000000000000000000000000000000000000002400000000klog-6.6/klog/app/cli/today_test.gopackage cli
import (
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestPrintsTodaysEvalutaion(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 19, 9)._SetRecords(`
1999-03-12
5m
1999-03-13
12h
1999-03-14
1h
1999-03-14
3h
13:15 - 15:00
`)._Run((&Today{}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
Today 5h45m
Other 12h5m
========
All 17h50m
`, state.printBuffer)
}
func TestFallsBackToYesterday(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 15, 0)._SetRecords(`
1999-03-12
5m
1999-03-13
12h
`)._Run((&Today{}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
Yesterday 12h
Other 5m
========
All 12h5m
`, state.printBuffer)
}
func TestPrintsEvaluationWithDiff(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 19, 12)._SetRecords(`
1999-03-12 (3h10m!)
6h50m
1999-03-14 (6h!)
14:38 - 18:13
`)._Run((&Today{DiffArgs: util.DiffArgs{Diff: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff
Today 3h35m 6h! -2h25m
Other 6h50m 3h10m! +3h40m
======== ========= ========
All 10h25m 9h10m! +1h15m
`, state.printBuffer)
}
func TestPrintsEvaluationWithNow(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 18, 13)._SetRecords(`
1999-03-12
6h50m
1999-03-14
14:38 - ??
`)._Run((&Today{NowArgs: util.NowArgs{Now: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total
Today 3h35m
Other 6h50m
========
All 10h25m
`, state.printBuffer)
}
func TestPrintsEvaluationWithDiffAndNow(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 18, 13)._SetRecords(`
1999-03-12 (3h10m!)
6h50m
1999-03-14 (6h!)
14:38 - ?
`)._Run((&Today{DiffArgs: util.DiffArgs{Diff: true}, NowArgs: util.NowArgs{Now: true}}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff End-Time
Today 3h35m 6h! -2h25m 20:38
Other 6h50m 3h10m! +3h40m
======== ========= ========
All 10h25m 9h10m! +1h15m 16:58
`, state.printBuffer)
}
func TestPrintsEvaluationWithDiffAndNowEndTimeInParenthesisIfNoOpenRange(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 21, 05)._SetRecords(`
1999-03-12 (3h10m!)
6h50m
1999-03-14 (6h!)
14:38 - 21:05
`)._Run((&Today{
DiffArgs: util.DiffArgs{Diff: true},
NowArgs: util.NowArgs{Now: true},
WarnArgs: util.WarnArgs{NoWarn: true},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff End-Time
Today 6h27m 6h! +27m (20:38)
Other 6h50m 3h10m! +3h40m
======== ========= ========
All 13h17m 9h10m! +4h7m (16:58)
`, state.printBuffer)
}
func TestPrintsPlaceholderIfEndTimeIsOutOfBounds(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 14, 18, 13)._SetRecords(`
1999-03-14 (60h!)
1h
`)._Run((&Today{
DiffArgs: util.DiffArgs{Diff: true},
NowArgs: util.NowArgs{Now: true},
WarnArgs: util.WarnArgs{NoWarn: true},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff End-Time
Today 1h 60h! -59h ???
Other 0m 0m! 0m
======== ========= ========
All 1h 60h! -59h ???
`, state.printBuffer)
}
func TestPrintsNAWhenNoCurrentRecord(t *testing.T) {
state, err := NewTestingContext()._SetNow(1999, 3, 16, 18, 13)._SetRecords(`
1999-03-12 (3h10m!)
6h50m
`)._Run((&Today{
DiffArgs: util.DiffArgs{Diff: true},
NowArgs: util.NowArgs{Now: true},
WarnArgs: util.WarnArgs{NoWarn: true},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
Total Should Diff End-Time
Today n/a n/a n/a n/a
Other 6h50m 3h10m! +3h40m
======== ========= ========
All 6h50m 3h10m! +3h40m n/a
`, state.printBuffer)
}
07070100000049000081A40000000000000000000000016863F92F000006A0000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/total.gopackage cli
import (
"fmt"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
)
type Total struct {
util.FilterArgs
util.DiffArgs
util.NowArgs
util.DecimalArgs
util.WarnArgs
util.NoStyleArgs
util.InputFilesArgs
}
func (opt *Total) Help() string {
return `
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 - ?').
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”.
If the records contain should-total values, you can also compute the difference between should-total and actual total by using the '--diff' flag.
`
}
func (opt *Total) Run(ctx app.Context) app.Error {
opt.DecimalArgs.Apply(&ctx)
opt.NoStyleArgs.Apply(&ctx)
_, serialiser := ctx.Serialise()
records, err := ctx.ReadInputs(opt.File...)
if err != nil {
return err
}
now := ctx.Now()
records = opt.ApplyFilter(now, records)
nErr := opt.ApplyNow(now, records...)
if nErr != nil {
return nErr
}
total := service.Total(records...)
ctx.Print(fmt.Sprintf("Total: %s\n", serialiser.Duration(total)))
if opt.Diff {
should := service.ShouldTotalSum(records...)
diff := service.Diff(should, total)
ctx.Print(fmt.Sprintf("Should: %s\n", serialiser.ShouldTotal(should)))
ctx.Print(fmt.Sprintf("Diff: %s\n", serialiser.SignedDuration(diff)))
}
ctx.Print(fmt.Sprintf("(In %d record%s)\n", len(records), func() string {
if len(records) == 1 {
return ""
}
return "s"
}()))
opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings())
return nil
}
0707010000004A000081A40000000000000000000000016863F92F0000077A000000000000000000000000000000000000002400000000klog-6.6/klog/app/cli/total_test.gopackage cli
import (
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestTotalOfEmptyInput(t *testing.T) {
state, err := NewTestingContext()._SetRecords(``)._Run((&Total{}).Run)
require.Nil(t, err)
assert.Equal(t, "\nTotal: 0m\n(In 0 records)\n", state.printBuffer)
}
func TestTotalOfInput(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-11-08
1h
2018-11-09
16:00-17:00
2150-11-10
Open ranges are not considered
16:00 - ?
`)._Run((&Total{WarnArgs: util.WarnArgs{NoWarn: true}}).Run)
require.Nil(t, err)
assert.Equal(t, "\nTotal: 2h\n(In 3 records)\n", state.printBuffer)
}
func TestTotalWithDiffing(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-11-08 (8h!)
8h30m
2018-11-09 (7h45m!)
8:00 - 16:00
`)._Run((&Total{DiffArgs: util.DiffArgs{Diff: true}}).Run)
require.Nil(t, err)
assert.Equal(t, "\nTotal: 16h30m\nShould: 15h45m!\nDiff: +45m\n(In 2 records)\n", state.printBuffer)
}
func TestTotalWithNow(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-11-08 (8h!)
8h30m
2018-11-09 (7h45m!)
8:00 - ?
`)._SetNow(2018, 11, 9, 8, 30)._Run((&Total{NowArgs: util.NowArgs{Now: true}}).Run)
require.Nil(t, err)
assert.Equal(t, "\nTotal: 9h\n(In 2 records)\n", state.printBuffer)
}
func TestTotalWithNowUncloseable(t *testing.T) {
_, err := NewTestingContext()._SetRecords(`
2018-11-08 (8h!)
8h30m
2018-11-09 (7h45m!)
8:00 - ?
`)._SetNow(2018, 13, 9, 8, 30)._Run((&Total{NowArgs: util.NowArgs{Now: true}}).Run)
require.Error(t, err)
}
func TestTotalAsDecimal(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
2018-11-08 (8h!)
8h30m
`)._SetNow(2018, 11, 9, 8, 30)._Run((&Total{DecimalArgs: util.DecimalArgs{Decimal: true}}).Run)
require.Nil(t, err)
assert.Equal(t, "\nTotal: 510\n(In 1 record)\n", state.printBuffer)
}
0707010000004B000081A40000000000000000000000016863F92F00000738000000000000000000000000000000000000001F00000000klog-6.6/klog/app/cli/track.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/parser/reconciling"
)
type Track struct {
Entry klog.EntrySummary `arg:"" required:"" placeholder:"ENTRY" help:"The new entry to add."`
util.AtDateArgs
util.NoStyleArgs
util.WarnArgs
util.OutputFileArgs
}
func (opt *Track) Help() string {
return `
The given text is appended to the record as new entry (taken over as is, i.e. including the entry summary). Example invocations:
klog track '1h' file.klg
klog track '15:00 - 16:00 Went out running' file.klg
klog track '6h30m #work' file.klg
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.
You can otherwise specify a date with '--date'.
Remember to use 'quotes' if the entry consists of multiple words, to avoid the text being split or otherwise pre-processed by your shell.
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.
`
}
func (opt *Track) Run(ctx app.Context) app.Error {
opt.NoStyleArgs.Apply(&ctx)
now := ctx.Now()
date := opt.AtDate(now)
additionalData := reconciling.AdditionalData{}
ctx.Config().DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) {
additionalData.ShouldTotal = s
})
return util.Reconcile(ctx, util.ReconcileOpts{OutputFileArgs: opt.OutputFileArgs, WarnArgs: opt.WarnArgs},
[]reconciling.Creator{
reconciling.NewReconcilerAtRecord(date),
reconciling.NewReconcilerForNewRecord(date, opt.DateFormat(ctx.Config()), additionalData),
},
func(reconciler *reconciling.Reconciler) error {
return reconciler.AppendEntry(opt.Entry)
},
)
}
0707010000004C000081A40000000000000000000000016863F92F00000E4E000000000000000000000000000000000000002400000000klog-6.6/klog/app/cli/track_test.gopackage cli
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestTrackEntryInEmptyFile(t *testing.T) {
state, err := NewTestingContext()._SetRecords("")._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1855, 4, 25)},
}).Run)
require.Nil(t, err)
assert.Equal(t, "1855-04-25\n 2h\n", state.writtenFileContents)
}
func TestTrackEntryInExistingFile(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855-04-25
1h
`)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1855, 4, 25)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1855-04-25
1h
2h
`, state.writtenFileContents)
}
func TestTrackEntryAtUnknownDateCreatesNewRecord(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855-04-25
1h
`)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(2000, 1, 1)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1855-04-25
1h
2000-01-01
2h
`, state.writtenFileContents)
}
func TestTrackNewRecordWithShouldTotal(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855-04-25
1h
`)._SetFileConfig(`
default_should_total = 7h30m!
`)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(2000, 1, 1)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1855-04-25
1h
2000-01-01 (7h30m!)
2h
`, state.writtenFileContents)
}
func TestTrackFailsIfEntryInvalid(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855-04-25
1h
`)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("Foo"),
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(1855, 4, 25)},
}).Run)
require.Error(t, err)
assert.Equal(t, "Manipulation failed", err.Error())
assert.Equal(t, "This operation wouldn’t result in a valid record", err.Details())
assert.Equal(t, "", state.writtenFileContents)
}
func TestTrackWithStyle(t *testing.T) {
t.Run("For empty file and no preferences, use recommended default.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords("").
_SetNow(2000, 1, 1, 12, 00).
_Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
}).Run)
require.Nil(t, err)
assert.Equal(t, "2000-01-01\n 2h\n", state.writtenFileContents)
})
t.Run("Without any preference, detect from file.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855/04/25
1h
`)._SetNow(2000, 1, 1, 12, 00)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1855/04/25
1h
2000/01/01
2h
`, state.writtenFileContents)
})
t.Run("Use preference from config file, if given.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855/04/25
1h
`)._SetFileConfig(`
date_format = YYYY-MM-DD
`)._SetNow(2000, 1, 1, 12, 00)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1855/04/25
1h
2000-01-01
2h
`, state.writtenFileContents)
})
t.Run("If explicit flag was provided, that takes ultimate precedence.", func(t *testing.T) {
state, err := NewTestingContext()._SetRecords(`
1855/04/25
1h
`)._SetFileConfig(`
date_format = YYYY/MM/DD
`)._Run((&Track{
Entry: klog.Ɀ_EntrySummary_("2h"),
AtDateArgs: util.AtDateArgs{Date: klog.Ɀ_Date_(2000, 1, 1)},
}).Run)
require.Nil(t, err)
assert.Equal(t, `
1855/04/25
1h
2000-01-01
2h
`, state.writtenFileContents)
})
}
0707010000004D000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001B00000000klog-6.6/klog/app/cli/util0707010000004E000081A40000000000000000000000016863F92F00003639000000000000000000000000000000000000002300000000klog-6.6/klog/app/cli/util/args.gopackage util
import (
"strings"
gotime "time"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser/reconciling"
"github.com/jotaen/klog/klog/service"
"github.com/jotaen/klog/klog/service/period"
)
type InputFilesArgs struct {
File []app.FileOrBookmarkName `arg:"" optional:"" type:"string" predictor:"file_or_bookmark" name:"file or bookmark" help:"One or more .klg source files or bookmarks. If absent, klog tries to use the default bookmark."`
}
type OutputFileArgs struct {
File app.FileOrBookmarkName `arg:"" optional:"" type:"string" predictor:"file_or_bookmark" name:"file or bookmark" help:"One .klg source file or bookmark. If absent, klog tries to use the default bookmark."`
}
type AtDateArgs struct {
Date klog.Date `name:"date" placeholder:"DATE" short:"d" help:"The date of the record."`
Today bool `name:"today" help:"Use today’s date."`
Yesterday bool `name:"yesterday" help:"Use yesterday’s date."`
Tomorrow bool `name:"tomorrow" help:"Use tomorrow’s date."`
}
func (args *AtDateArgs) AtDate(now gotime.Time) klog.Date {
if args.Date != nil {
return args.Date
}
today := klog.NewDateFromGo(now) // That’s effectively/implicitly `--today`
if args.Yesterday {
return today.PlusDays(-1)
} else if args.Tomorrow {
return today.PlusDays(1)
}
return today
}
func (args *AtDateArgs) DateFormat(config app.Config) reconciling.ReformatDirective[klog.DateFormat] {
if args.Date != nil {
return reconciling.NoReformat[klog.DateFormat]()
}
fd := reconciling.ReformatAutoStyle[klog.DateFormat]()
config.DateUseDashes.Unwrap(func(x bool) {
fd = reconciling.ReformatExplicitly(klog.DateFormat{UseDashes: x})
})
return fd
}
type AtDateAndTimeArgs struct {
Round service.Rounding `name:"round" placeholder:"ROUNDING" short:"r" help:"Round time to nearest multiple number. ROUNDING can be one of '5m', '10m', '12m', '15m', '20m', '30m' or '60m' / '1h'."`
AtDateArgs
Time klog.Time `name:"time" placeholder:"TIME" short:"t" help:"Specify the time (defaults to now). TIME can be given in the 24h or 12h notation, e.g. '13:00' or '1:00pm'."`
}
func (args *AtDateAndTimeArgs) AtTime(now gotime.Time, config app.Config) (klog.Time, app.Error) {
if args.Time != nil {
return args.Time, nil
}
date := args.AtDate(now)
today := klog.NewDateFromGo(now)
time := klog.NewTimeFromGo(now)
if args.Round != nil {
time = service.RoundToNearest(time, args.Round)
} else {
config.DefaultRounding.Unwrap(func(r service.Rounding) {
time = service.RoundToNearest(time, r)
})
}
if today.IsEqualTo(date) {
return time, nil
} else if today.PlusDays(-1).IsEqualTo(date) {
shiftedTime, _ := time.Plus(klog.NewDuration(24, 0))
return shiftedTime, nil
} else if today.PlusDays(1).IsEqualTo(date) {
shiftedTime, _ := time.Plus(klog.NewDuration(-24, 0))
return shiftedTime, nil
}
return nil, app.NewErrorWithCode(
app.LOGICAL_ERROR,
"Missing time parameter",
"Please specify a time value for dates in the past",
nil,
)
}
func (args *AtDateAndTimeArgs) TimeFormat(config app.Config) reconciling.ReformatDirective[klog.TimeFormat] {
if args.Time != nil {
return reconciling.NoReformat[klog.TimeFormat]()
}
fd := reconciling.ReformatAutoStyle[klog.TimeFormat]()
config.TimeUse24HourClock.Unwrap(func(x bool) {
fd = reconciling.ReformatExplicitly(klog.TimeFormat{Use24HourClock: x})
})
return fd
}
func (args *AtDateAndTimeArgs) WasAutomatic() bool {
return args.Date == nil && args.Time == nil
}
type DiffArgs struct {
Diff bool `name:"diff" short:"d" help:"Show difference between actual and should-total time."`
}
type NowArgs struct {
Now bool `name:"now" short:"n" help:"Assume open ranges to be closed at this moment."`
hadOpenRange bool // Field only for internal use
}
func (args *NowArgs) ApplyNow(reference gotime.Time, rs ...klog.Record) app.Error {
if args.Now {
hasClosedAnyRange, err := service.CloseOpenRanges(reference, rs...)
if err != nil {
return app.NewErrorWithCode(
app.LOGICAL_ERROR,
"Cannot apply --now flag",
"There are records with uncloseable time ranges",
err,
)
}
args.hadOpenRange = hasClosedAnyRange
return nil
}
return nil
}
func (args *NowArgs) HadOpenRange() bool {
return args.hadOpenRange
}
func (args *NowArgs) GetNowWarnings() []string {
if args.Now && !args.hadOpenRange {
return []string{"You specified --now, but there was no open-ended time range."}
}
return nil
}
type FilterArgs struct {
// General filters
Tags []klog.Tag `name:"tag" placeholder:"TAG" group:"Filter" help:"Records (or entries) that match these tags. You can omit the leading '#'."`
Date klog.Date `name:"date" placeholder:"DATE" group:"Filter" help:"Records at this date. DATE has to be in format YYYY-MM-DD or YYYY/MM/DD. E.g., '2024-01-31' or '2024/01/31'."`
Since klog.Date `name:"since" placeholder:"DATE" group:"Filter" help:"Records since this date (inclusive)."`
Until klog.Date `name:"until" placeholder:"DATE" group:"Filter" help:"Records until this date (inclusive)."`
After klog.Date `name:"after" placeholder:"DATE" group:"Filter" help:"Records after this date (exclusive)."`
Before klog.Date `name:"before" placeholder:"DATE" group:"Filter" help:"Records before this date (exclusive)."`
EntryType service.EntryType `name:"entry-type" placeholder:"TYPE" group:"Filter" help:"Entries of this type. TYPE can be 'range', 'open-range', 'duration', 'duration-positive' or 'duration-negative'."`
Period period.Period `name:"period" placeholder:"PERIOD" group:"Filter" help:"Records within a calendar period. PERIOD has to be in format YYYY, YYYY-MM, YYYY-Www or YYYY-Qq. E.g., '2024', '2024-04', '2022-W21' or '2024-Q1'."`
// Shortcut filters
// The `XXX` ones are dummy entries just for the help output
Today bool `name:"today" group:"Filter" help:"Records at today’s date."`
Yesterday bool `name:"yesterday" group:"Filter" help:"Records at yesterday’s date."`
Tomorrow bool `name:"tomorrow" group:"Filter" help:"Records at tomorrow’s date."`
ThisXXX bool `name:"this-***" group:"Filter" help:"Records of this week/month/quarter/year, e.g. '--this-week' or '--this-quarter'."`
LastXXX bool `name:"last-***" group:"Filter" help:"Records of last week/month/quarter/year, e.g. '--last-month' or '--last-year'."`
ThisWeek bool `hidden:"" name:"this-week" group:"Filter"`
ThisWeekAlias bool `hidden:"" name:"thisweek" group:"Filter"`
LastWeek bool `hidden:"" name:"last-week" group:"Filter"`
LastWeekAlias bool `hidden:"" name:"lastweek" group:"Filter"`
ThisMonth bool `hidden:"" name:"this-month" group:"Filter"`
ThisMonthAlias bool `hidden:"" name:"thismonth" group:"Filter"`
LastMonth bool `hidden:"" name:"last-month" group:"Filter"`
LastMonthAlias bool `hidden:"" name:"lastmonth" group:"Filter"`
ThisQuarter bool `hidden:"" name:"this-quarter" group:"Filter"`
ThisQuarterAlias bool `hidden:"" name:"thisquarter" group:"Filter"`
LastQuarter bool `hidden:"" name:"last-quarter" group:"Filter"`
LastQuarterAlias bool `hidden:"" name:"lastquarter" group:"Filter"`
ThisYear bool `hidden:"" name:"this-year" group:"Filter"`
ThisYearAlias bool `hidden:"" name:"thisyear" group:"Filter"`
LastYear bool `hidden:"" name:"last-year" group:"Filter"`
LastYearAlias bool `hidden:"" name:"lastyear" group:"Filter"`
}
// FilterArgsCompletionOverrides enables/disables tab completion for
// certain flags.
var FilterArgsCompletionOverrides = map[string]bool{
"this-***": false, // disable, although not flagged as hidden
"last-***": false,
"this-week": true, // enable, although flagged as hidden
"last-week": true,
"this-month": true,
"last-month": true,
"this-quarter": true,
"last-quarter": true,
"this-year": true,
"last-year": true,
}
func (args *FilterArgs) ApplyFilter(now gotime.Time, rs []klog.Record) []klog.Record {
today := klog.NewDateFromGo(now)
qry := service.FilterQry{
BeforeOrEqual: args.Until,
AfterOrEqual: args.Since,
Tags: args.Tags,
AtDate: args.Date,
}
if args.Period != nil {
qry.BeforeOrEqual = args.Period.Until()
qry.AfterOrEqual = args.Period.Since()
}
if args.After != nil {
qry.AfterOrEqual = args.After.PlusDays(1)
}
if args.Before != nil {
qry.BeforeOrEqual = args.Before.PlusDays(-1)
}
if args.Today {
qry.AtDate = today
}
if args.Yesterday {
qry.AtDate = today.PlusDays(-1)
}
if args.Tomorrow {
qry.AtDate = today.PlusDays(+1)
}
if args.EntryType != "" {
qry.EntryType = args.EntryType
}
shortcutPeriod := func() period.Period {
if args.ThisWeek || args.ThisWeekAlias {
return period.NewWeekFromDate(today).Period()
}
if args.LastWeek || args.LastWeekAlias {
return period.NewWeekFromDate(today).Previous().Period()
}
if args.ThisMonth || args.ThisMonthAlias {
return period.NewMonthFromDate(today).Period()
}
if args.LastMonth || args.LastMonthAlias {
return period.NewMonthFromDate(today).Previous().Period()
}
if args.ThisQuarter || args.ThisQuarterAlias {
return period.NewQuarterFromDate(today).Period()
}
if args.LastQuarter || args.LastQuarterAlias {
return period.NewQuarterFromDate(today).Previous().Period()
}
if args.ThisYear || args.ThisYearAlias {
return period.NewYearFromDate(today).Period()
}
if args.LastYear || args.LastYearAlias {
return period.NewYearFromDate(today).Previous().Period()
}
return nil
}()
if shortcutPeriod != nil {
qry.AfterOrEqual = shortcutPeriod.Since()
qry.BeforeOrEqual = shortcutPeriod.Until()
}
return service.Filter(rs, qry)
}
type WarnArgs struct {
NoWarn bool `name:"no-warn" help:"Suppress warnings about potential mistakes or logical errors."`
}
func (args *WarnArgs) PrintWarnings(ctx app.Context, records []klog.Record, additionalWarnings []string) {
styler, _ := ctx.Serialise()
if args.NoWarn {
return
}
for _, msg := range additionalWarnings {
ctx.Print(PrettifyGeneralWarning(msg, styler))
}
disabledCheckers := ctx.Config().NoWarnings.UnwrapOr(service.NewDisabledCheckers())
service.CheckForWarnings(func(w service.Warning) {
ctx.Print(PrettifyWarning(w, styler))
}, ctx.Now(), records, disabledCheckers)
}
type NoStyleArgs struct {
NoStyle bool `name:"no-style" help:"Do not style or colour the values."`
}
func (args *NoStyleArgs) Apply(ctx *app.Context) {
if args.NoStyle {
(*ctx).ConfigureSerialisation(func(styler tf.Styler, decimalDuration bool) (tf.Styler, bool) {
return tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR), decimalDuration
})
}
}
type QuietArgs struct {
Quiet bool `name:"quiet" help:"Output parseable data without descriptive text."`
}
type SortArgs struct {
Sort string `name:"sort" placeholder:"ORDER" help:"Sort output by date. ORDER can be 'asc' or 'desc'." enum:"asc,desc,ASC,DESC," default:""`
}
func (args *SortArgs) ApplySort(rs []klog.Record) []klog.Record {
if args.Sort == "" {
return rs
}
startWithOldest := false
if strings.ToLower(args.Sort) == "asc" {
startWithOldest = true
}
return service.Sort(rs, startWithOldest)
}
type DecimalArgs struct {
Decimal bool `name:"decimal" help:"Display totals as decimal values (in minutes)."`
}
func (args *DecimalArgs) Apply(ctx *app.Context) {
if args.Decimal {
(*ctx).ConfigureSerialisation(func(styler tf.Styler, decimalDuration bool) (tf.Styler, bool) {
return styler, true
})
}
}
type SummaryArgs struct {
SummaryText klog.EntrySummary `name:"summary" short:"s" placeholder:"TEXT" help:"Summary text for the new entry."`
Resume bool `name:"resume" short:"R" help:"Take over summary of last entry (if applicable)."`
ResumeNth int `name:"resume-nth" short:"N" help:"Take over summary of nth entry. If INT is positive, it counts from the start (beginning with '1'); if negative, it counts from the end (beginning with '-1')"`
}
func (args *SummaryArgs) Summary(currentRecord klog.Record, previousRecord klog.Record) (klog.EntrySummary, app.Error) {
// Check for conflicting flags.
if args.SummaryText != nil && (args.Resume || args.ResumeNth != 0) {
return nil, app.NewErrorWithCode(
app.LOGICAL_ERROR,
"Conflicting flags: --summary and --resume cannot be used at the same time",
"",
nil,
)
}
if args.Resume && args.ResumeNth != 0 {
return nil, app.NewError(
"Illegal flag combination",
"Cannot combine --resume and --resume-nth",
nil,
)
}
// Return summary flag, if specified.
if args.SummaryText != nil {
return args.SummaryText, nil
}
// If --resume was specified: return summary of last entry from current record, if
// it has any entries. Otherwise, return summary of last entry from previous record,
// if exists.
if args.Resume {
if e, ok := findNthEntry(currentRecord, -1); ok {
return e.Summary(), nil
}
if previousRecord != nil {
if e, ok := findNthEntry(previousRecord, -1); ok {
return e.Summary(), nil
}
}
return nil, nil
}
// If --resume-nth was specified: return summary of nth-entry. In contrast to --resume,
// don’t fall back to previous record, as that would be unintuitive here.
if args.ResumeNth != 0 {
if e, ok := findNthEntry(currentRecord, args.ResumeNth); ok {
return e.Summary(), nil
}
return nil, app.NewError(
"No such entry",
"",
nil,
)
}
return nil, nil
}
func findNthEntry(r klog.Record, nr int) (klog.Entry, bool) {
entriesCount := len(r.Entries())
i := func() int {
if nr > 0 {
return nr - 1
}
return entriesCount + nr
}()
if i < 0 || i > entriesCount-1 {
return klog.Entry{}, false
}
return r.Entries()[i], true
}
0707010000004F000081A40000000000000000000000016863F92F00000E42000000000000000000000000000000000000002900000000klog-6.6/klog/app/cli/util/prettifier.gopackage util
import (
"errors"
"fmt"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service"
"strings"
)
var Reflower = tf.NewReflower(80, "\n")
// PrettifyAppError prints app errors including details.
func PrettifyAppError(err app.Error, isDebug bool) error {
message := "Error: " + err.Error() + "\n"
message += Reflower.Reflow(err.Details(), nil)
if isDebug && err.Original() != nil {
message += "\n\nOriginal Error:\n" + err.Original().Error()
}
return errors.New(message)
}
// PrettifyParsingError turns a parsing error into a coloured and well-structured form.
func PrettifyParsingError(err app.ParserErrors, styler tf.Styler) error {
message := ""
INDENT := " "
for _, e := range err.All() {
message += "\n"
message += fmt.Sprintf(
styler.Props(tf.StyleProps{Background: tf.RED, Color: tf.RED}).Format("[")+
styler.Props(tf.StyleProps{Background: tf.RED, Color: tf.TEXT_INVERSE}).Format("SYNTAX ERROR")+
styler.Props(tf.StyleProps{Background: tf.RED, Color: tf.RED}).Format("]")+
styler.Props(tf.StyleProps{Color: tf.RED}).Format(" in line %d"),
e.LineNumber(),
)
if e.Origin() != "" {
message += fmt.Sprintf(
styler.Props(tf.StyleProps{Color: tf.RED}).Format(" of file %s"),
e.Origin(),
)
}
message += "\n"
message += fmt.Sprintf(
styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED}).Format(INDENT+"%s"),
// Replace all tabs with one space each, otherwise the carets might
// not be in line with the text anymore (since we can’t know how wide
// a tab is).
strings.Replace(e.LineText(), "\t", " ", -1),
) + "\n"
message += fmt.Sprintf(
styler.Props(tf.StyleProps{Color: tf.RED}).Format(INDENT+"%s%s"),
strings.Repeat(" ", e.Position()), strings.Repeat("^", e.Length()),
) + "\n"
message += fmt.Sprintf(
styler.Props(tf.StyleProps{Color: tf.YELLOW}).Format("%s"),
Reflower.Reflow(e.Message(), []string{INDENT}),
) + "\n"
}
return errors.New(message)
}
// PrettifyWarning formats a warning about a record.
func PrettifyWarning(w service.Warning, styler tf.Styler) string {
return PrettifyGeneralWarning(w.Date().ToString()+": "+w.Warning(), styler)
}
// PrettifyGeneralWarning formats a general warning message.
func PrettifyGeneralWarning(message string, styler tf.Styler) string {
result := ""
result += styler.Props(tf.StyleProps{Background: tf.YELLOW, Color: tf.YELLOW}).Format("[")
result += styler.Props(tf.StyleProps{Background: tf.YELLOW, Color: tf.TEXT_INVERSE}).Format("WARNING")
result += styler.Props(tf.StyleProps{Background: tf.YELLOW, Color: tf.YELLOW}).Format("]")
result += " "
result += styler.Props(tf.StyleProps{Color: tf.YELLOW}).Format(message)
result += "\n"
return result
}
// PrettyMonth returns the full english name of a month.
func PrettyMonth(m int) string {
switch m {
case 1:
return "January"
case 2:
return "February"
case 3:
return "March"
case 4:
return "April"
case 5:
return "May"
case 6:
return "June"
case 7:
return "July"
case 8:
return "August"
case 9:
return "September"
case 10:
return "October"
case 11:
return "November"
case 12:
return "December"
}
panic("Illegal month") // this can/should never happen
}
// PrettyDay returns the full english name of a weekday.
func PrettyDay(d int) string {
switch d {
case 1:
return "Monday"
case 2:
return "Tuesday"
case 3:
return "Wednesday"
case 4:
return "Thursday"
case 5:
return "Friday"
case 6:
return "Saturday"
case 7:
return "Sunday"
}
panic("Illegal weekday") // this can/should never happen
}
07070100000050000081A40000000000000000000000016863F92F00000AF3000000000000000000000000000000000000002E00000000klog-6.6/klog/app/cli/util/prettifier_test.gopackage util
import (
"errors"
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser/txt"
"github.com/stretchr/testify/assert"
"testing"
)
var styler = tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR)
func TestFormatParserError(t *testing.T) {
block1, _ := txt.ParseBlock("Good text\nSome malformed text", 37)
block2, _ := txt.ParseBlock("Another issue!", 133)
err := app.NewParserErrors([]txt.Error{
txt.NewError(block1, 1, 0, 4, "CODE", "Error", "Short explanation."),
txt.NewError(block2, 0, 8, 5, "CODE", "Problem", "More info.").SetOrigin("some-file.klg"),
})
text := PrettifyParsingError(err, styler).Error()
assert.Equal(t, `
[SYNTAX ERROR] in line 39
Some malformed text
^^^^
Error: Short explanation.
[SYNTAX ERROR] in line 134 of file some-file.klg
Another issue!
^^^^^
Problem: More info.
`, tf.StripAllAnsiSequences(text))
}
func TestReflowsLongMessages(t *testing.T) {
block, _ := txt.ParseBlock("Foo bar", 1)
err := app.NewParserErrors([]txt.Error{
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."),
})
text := PrettifyParsingError(err, styler).Error()
assert.Equal(t, `
[SYNTAX ERROR] in line 2
Foo bar
^^^
Some Title: A verbose description with details, potentially spanning multiple
lines with a comprehensive text and tremendously helpful information.
But
it
respects
newlines.
`, tf.StripAllAnsiSequences(text))
}
func TestConvertsTabToSpaces(t *testing.T) {
block, _ := txt.ParseBlock("\tFoo\tbar", 13)
err := app.NewParserErrors([]txt.Error{
txt.NewError(block, 0, 0, 8, "CODE", "Error title", "Error details"),
})
text := PrettifyParsingError(err, styler).Error()
assert.Equal(t, `
[SYNTAX ERROR] in line 14
Foo bar
^^^^^^^^
Error title: Error details
`, tf.StripAllAnsiSequences(text))
}
func TestFormatAppError(t *testing.T) {
err := app.NewError("Some message", "A more detailed explanation", nil)
text := PrettifyAppError(err, false).Error()
assert.Equal(t, `Error: Some message
A more detailed explanation`, text)
}
func TestFormatAppErrorWithDebugFlag(t *testing.T) {
textWithNilErr := PrettifyAppError(
app.NewError("Some message", "A more detailed explanation", nil),
true).Error()
assert.Equal(t, `Error: Some message
A more detailed explanation`, textWithNilErr)
textWithErr := PrettifyAppError(
app.NewError("Some message", "A more detailed explanation", errors.New("ORIG_ERR")),
true).Error()
assert.Equal(t, `Error: Some message
A more detailed explanation
Original Error:
ORIG_ERR`, textWithErr)
}
07070100000051000081A40000000000000000000000016863F92F00000289000000000000000000000000000000000000002800000000klog-6.6/klog/app/cli/util/reconcile.gopackage util
import (
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/reconciling"
)
type ReconcileOpts struct {
OutputFileArgs
WarnArgs
}
func Reconcile(ctx app.Context, opts ReconcileOpts, creators []reconciling.Creator, reconcile ...reconciling.Reconcile) app.Error {
result, err := ctx.ReconcileFile(opts.OutputFileArgs.File, creators, reconcile...)
if err != nil {
return err
}
_, serialiser := ctx.Serialise()
ctx.Print("\n" + parser.SerialiseRecords(serialiser, result.Record).ToString() + "\n")
opts.WarnArgs.PrintWarnings(ctx, result.AllRecords, nil)
return nil
}
07070100000052000081A40000000000000000000000016863F92F00000347000000000000000000000000000000000000002A00000000klog-6.6/klog/app/cli/util/with_repeat.gopackage util
import (
"github.com/jotaen/klog/klog/app"
"os"
"os/signal"
"syscall"
gotime "time"
)
// WithRepeat repetitively invokes the callback at the desired rate.
// It always clears the terminal screen.
func WithRepeat(print func(string), interval gotime.Duration, fn func(int64) app.Error) app.Error {
// Handle ^C gracefully
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
os.Exit(0)
}()
// Call handler function repetitively
print("\033[2J") // Initial screen clearing
ticker := gotime.NewTicker(interval)
defer ticker.Stop()
secondsCounter := int64(0) // Choose large type because of overflow
for ; true; <-ticker.C {
secondsCounter += 1
print("\033[H\033[J") // Cursor reset
err := fn(secondsCounter)
if err != nil {
return err
}
}
return nil
}
07070100000053000081A40000000000000000000000016863F92F0000119A000000000000000000000000000000000000002100000000klog-6.6/klog/app/cli/version.gopackage cli
import (
"encoding/json"
"fmt"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli/util"
"io"
"net/http"
"strings"
"time"
)
type Version struct {
NoCheck bool `name:"no-check" help:"Don’t check online for updates."` // used for the smoke test
util.QuietArgs
}
func (opt *Version) Help() string {
return `
If you don’t use a package manager for managing your klog installation, you can subscribe to the release notifications on the Github repository (https://github.com/jotaen/klog).
`
}
const KLOG_WEBSITE_URL = "https://klog.jotaen.net"
var versionCheckers = []versionChecker{
{"https://klog.jotaen.net/versions/latest.json", &versionFromJotaen{}},
{"https://api.github.com/repos/jotaen/klog/releases/latest", &versionFromGithub{}},
}
func (opt *Version) Run(ctx app.Context) app.Error {
if ctx.Meta().Version == "" {
return app.NewError(
"Cannot check version",
"There is no version information embedded in your binary. Please check manually for updates on "+KLOG_WEBSITE_URL,
nil,
)
}
if opt.Quiet {
ctx.Print(ctx.Meta().Version + "\n")
return nil
}
ctx.Print("Command line tool: " + ctx.Meta().Version)
ctx.Print(" [" + ctx.Meta().SrcHash + "]\n")
ctx.Print("File format: version " + klog.SPEC_VERSION + "\n")
if opt.NoCheck {
return nil
}
ctx.Print("\nChecking for newer version...")
stopTicker := make(chan bool)
go progressTicker(func() {
ctx.Print(".")
}, stopTicker)
origin, v := fetchVersionInfo(versionCheckers)
close(stopTicker)
ctx.Print("\n")
if v == nil {
return app.NewError(
"Failed to retrieve version information.",
"Please try again later, or check manually at "+KLOG_WEBSITE_URL,
nil,
)
}
ctx.Debug(func() {
ctx.Print("Fetched from: " + origin.url + "\n")
})
if v.Version() == ctx.Meta().Version && ctx.Meta().SrcHash == v.SrcHash() {
ctx.Print("You already have the latest version!\n")
} else {
ctx.Print(fmt.Sprintf("New version available: %s [%s]\n", v.Version(), v.SrcHash()))
downloadLinkPath := ""
if v.DownloadLinkPath() != "" {
downloadLinkPath = "/" + v.DownloadLinkPath()
}
ctx.Print("See: " + KLOG_WEBSITE_URL + downloadLinkPath + "\n")
}
return nil
}
type versionInfo interface {
Version() string
SrcHash() string
DownloadLinkPath() string
IsValid() bool
}
type versionChecker struct {
url string
structure versionInfo
}
func progressTicker(onTick func(), stop chan bool) {
ticker := time.NewTicker(500 * time.Millisecond)
for i := 1; i <= 20; i++ {
select {
case <-ticker.C:
onTick()
case <-stop:
ticker.Stop()
return
}
}
}
// fetchVersionInfo requests version info from the origins by trying them
// one after the other. It returns the first response that succeeds.
func fetchVersionInfo(origins []versionChecker) (*versionChecker, versionInfo) {
for _, origin := range origins {
req, err := http.NewRequest(http.MethodGet, origin.url, nil)
if err != nil {
continue
}
res, err := (&http.Client{
Timeout: time.Second * 5,
}).Do(req)
if err != nil {
continue
}
body, err := io.ReadAll(res.Body)
if err != nil {
continue
}
v := origin.structure
err = json.Unmarshal(body, &v)
if err != nil || !v.IsValid() {
continue
}
return &origin, v
}
return nil, nil
}
// versionFromJotaen checks the version from klog.jotaen.net
type versionFromJotaen struct {
Version_ string `json:"version"`
BuildHash_ string `json:"src_hash"`
DownloadLinkPath_ string `json:"download_link_path"`
}
func (v *versionFromJotaen) Version() string { return v.Version_ }
func (v *versionFromJotaen) SrcHash() string {
if len(v.BuildHash_) < 7 {
return ""
}
return v.BuildHash_[:7]
}
func (v *versionFromJotaen) DownloadLinkPath() string { return v.DownloadLinkPath_ }
func (v *versionFromJotaen) IsValid() bool {
return strings.HasPrefix(v.Version_, "v") && len(v.BuildHash_) >= 7
}
// versionFromGithub checks the version from github.com
type versionFromGithub struct {
Tag string `json:"tag_name"`
CommitHash string `json:"target_commitish"`
}
func (v *versionFromGithub) Version() string { return v.Tag }
func (v *versionFromGithub) SrcHash() string {
if len(v.CommitHash) < 7 {
return v.CommitHash
}
return v.CommitHash[:7]
}
func (v *versionFromGithub) DownloadLinkPath() string {
return ""
}
func (v *versionFromGithub) IsValid() bool {
return strings.HasPrefix(v.Tag, "v") && len(v.CommitHash) >= 7
}
07070100000054000081A40000000000000000000000016863F92F00003287000000000000000000000000000000000000001C00000000klog-6.6/klog/app/config.gopackage app
import (
"errors"
"sort"
"strings"
"github.com/jotaen/genie"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service"
)
// Config contain all variable settings that influence the behaviour of
// the application. Some of these properties can be controlled by the user
// in one way or the other, depending on their purpose.
type Config struct {
// IsDebug indicates whether klog should print additional debug information.
// This is an ephemeral property, which is used for debugging purposes, and not
// supposed to be configured permanently.
IsDebug MandatoryParam[bool]
// Editor is the CLI command with which to invoke the preferred editor.
Editor OptionalParam[string]
// ColourScheme specifies the background of the terminal, so that
// the output colours can be adjusted accordingly.
ColourScheme MandatoryParam[tf.ColourTheme]
// CpuKernels is the number of available CPUs that klog is allowed to utilise.
// The value must be `1` or higher.
// This is a low-level property that is not supposed to be exposed to end-users at all.
CpuKernels MandatoryParam[int]
// DefaultRounding is the default for the --round flag.
DefaultRounding OptionalParam[service.Rounding]
// DefaultShouldTotal is the default should total for new records.
DefaultShouldTotal OptionalParam[klog.ShouldTotal]
// DateUseDashes denotes the preferred date format: YYYY-MM-DD (true) or YYYY/MM/DD (false).
DateUseDashes OptionalParam[bool]
// TimeUse24HourClock denotes the preferred time format: 13:00 (true) or 1:00pm (false).
TimeUse24HourClock OptionalParam[bool]
// NoWarnings indicates klog should suppress any warning types.
NoWarnings OptionalParam[service.DisabledCheckers]
}
type Reader interface {
Apply(*Config) Error
}
// NewConfig creates a new application configuration by merging the config
// based on the following precedence: (1) env variables, (2) config file,
// (3) determined values.
func NewConfig(determined FromDeterminedValues, env FromEnvVars, file FromConfigFile) (Config, Error) {
config := NewDefaultConfig(tf.COLOUR_THEME_DARK)
for _, c := range []Reader{determined, file, env} {
err := c.Apply(&config)
if err != nil {
return Config{}, err
}
}
return config, nil
}
func NewDefaultConfig(c tf.ColourTheme) Config {
return Config{
IsDebug: newMandatoryParam(false),
Editor: newOptionalParam[string](),
ColourScheme: newMandatoryParam(c),
CpuKernels: newMandatoryParam(1),
DefaultRounding: newOptionalParam[service.Rounding](),
DefaultShouldTotal: newOptionalParam[klog.ShouldTotal](),
NoWarnings: newOptionalParam[service.DisabledCheckers](),
}
}
type configOrigin int
const (
configOriginEnv = iota + 1
configOriginFile
configOriginStaticValues
)
type BaseParam[T any] struct {
value T
origin configOrigin
}
type MandatoryParam[T any] struct {
BaseParam[T]
}
func newMandatoryParam[T any](defaultValue T) MandatoryParam[T] {
return MandatoryParam[T]{BaseParam[T]{
value: defaultValue,
origin: 0,
}}
}
func (p MandatoryParam[T]) Value() T {
return p.value
}
func (p *MandatoryParam[T]) override(value T, o configOrigin) {
p.value = value
p.origin = o
}
type OptionalParam[T any] struct {
BaseParam[T]
isSet bool
}
func newOptionalParam[T any]() OptionalParam[T] {
return OptionalParam[T]{
isSet: false,
}
}
func (p OptionalParam[T]) Unwrap(f func(T)) {
if p.isSet {
f(p.value)
}
}
func (p OptionalParam[T]) UnwrapOr(defaultValue T) T {
if p.isSet {
return p.value
}
return defaultValue
}
func (p *OptionalParam[T]) set(value T, o configOrigin) {
p.value = value
p.isSet = true
p.origin = o
}
// FromDeterminedValues is the part of the configuration that is automatically
// determined, e.g. by constraints of the runtime environment.
type FromDeterminedValues struct {
NumCpus int
}
func (e FromDeterminedValues) Apply(config *Config) Error {
config.CpuKernels.override(e.NumCpus, configOriginStaticValues)
return nil
}
// FromEnvVars is the part of the configuration that is determined
// by environment variables.
type FromEnvVars struct {
GetVar func(string) string
}
func (e FromEnvVars) Apply(config *Config) Error {
if e.GetVar("KLOG_DEBUG") != "" {
config.IsDebug.override(true, configOriginEnv)
}
if e.GetVar("NO_COLOR") != "" {
config.ColourScheme.override(tf.COLOUR_THEME_NO_COLOUR, configOriginEnv)
}
if e.GetVar("EDITOR") != "" {
config.Editor.set(e.GetVar("EDITOR"), configOriginEnv)
}
return nil
}
// FromConfigFile is the part of the configuration that the user can
// control via a configuration file.
type FromConfigFile struct {
FileContents string
}
var CONFIG_FILE_ENTRIES = []ConfigFileEntries[any]{
{
Name: "editor",
reader: func(value string, config *Config) error {
if value != "" {
config.Editor.set(value, configOriginFile)
}
return nil
},
value: func(c Config) (string, configOrigin) {
return c.Editor.value, c.Editor.origin
},
Help: Help{
Summary: "The CLI command that shall be invoked when running `klog edit`.",
Value: "The config property can be any valid CLI command, as you would type it on the terminal. klog will append the target file path as last input argument to that command. Note: you can use quotes in order to prevent undesired shell word-splitting, e.g. if the command name/path contains spaces.",
Default: "If absent/empty, `klog edit` tries to fall back to the $EDITOR environment variable (which, by the way, takes precedence, if set).",
},
}, {
Name: "colour_scheme",
reader: func(value string, config *Config) error {
switch value {
case string(tf.COLOUR_THEME_DARK):
config.ColourScheme.override(tf.COLOUR_THEME_DARK, configOriginFile)
case string(tf.COLOUR_THEME_NO_COLOUR):
config.ColourScheme.override(tf.COLOUR_THEME_NO_COLOUR, configOriginFile)
case string(tf.COLOUR_THEME_LIGHT):
config.ColourScheme.override(tf.COLOUR_THEME_LIGHT, configOriginFile)
case string(tf.COLOUR_THEME_BASIC):
config.ColourScheme.override(tf.COLOUR_THEME_BASIC, configOriginFile)
default:
return errors.New("The value must be `dark`, `light`, `basic`, or `no_colour`")
}
return nil
},
value: func(c Config) (string, configOrigin) {
return string(c.ColourScheme.Value()), c.ColourScheme.origin
},
Help: Help{
Summary: "The colour scheme of your terminal, so that klog can choose an optimal colour theme for its output.",
Value: "The config property must be one of: `dark`, `light`, `basic`, or `no_colour`",
Default: "If absent/empty, klog assumes a `dark` theme.",
},
}, {
Name: "default_rounding",
reader: func(value string, config *Config) error {
rounding, err := service.NewRoundingFromString(value)
if err != nil {
return err
}
config.DefaultRounding.set(rounding, configOriginFile)
return nil
},
value: func(c Config) (string, configOrigin) {
result := ""
c.DefaultRounding.Unwrap(func(r service.Rounding) {
result = r.ToString()
})
return result, c.DefaultRounding.origin
},
Help: Help{
Summary: "The default value that shall be used for rounding input times via the `--round` flag, e.g. in `klog start --round 15m`.",
Value: "The config property must be one of: `5m`, `10m`, `15m`, `30m`, `60m`.",
Default: "If absent/empty, klog doesn’t round input times.",
},
}, {
Name: "default_should_total",
reader: func(value string, config *Config) error {
value = strings.TrimSuffix(value, "!")
d, err := klog.NewDurationFromString(value)
if err != nil {
return err
}
config.DefaultShouldTotal.set(klog.NewShouldTotal(0, d.InMinutes()), configOriginFile)
return nil
},
value: func(c Config) (string, configOrigin) {
result := ""
c.DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) {
result = s.ToString()
})
return result, c.DefaultShouldTotal.origin
},
Help: Help{
Summary: "The default duration value that shall be used as should-total when creating new records, e.g. in `klog create --should '8h!'`.",
Value: "The config property must be a duration followed by an exclamation mark. Examples: `8h!`, `6h30m!`.",
Default: "If absent/empty, klog doesn’t set a should-total on new records.",
},
}, {
Name: "date_format",
reader: func(value string, config *Config) error {
useDashes := true
if value == "YYYY-MM-DD" {
useDashes = true
} else if value == "YYYY/MM/DD" {
useDashes = false
} else {
return errors.New("The value must be `YYYY-MM-DD` or `YYYY/MM/DD`")
}
config.DateUseDashes.set(useDashes, configOriginFile)
return nil
},
value: func(c Config) (string, configOrigin) {
result := ""
c.DateUseDashes.Unwrap(func(d bool) {
if d {
result = "YYYY-MM-DD"
} else {
result = "YYYY/MM/DD"
}
})
return result, c.DateUseDashes.origin
},
Help: Help{
Summary: "The preferred date notation for klog to use when adding a new record to a target file, i.e. whether it uses dashes (as in `2022-03-24`) or slashes (as in `2022/03/24`) as delimiter.",
Value: "The config property must be either `YYYY-MM-DD` or `YYYY/MM/DD`.",
Default: "If absent/empty, klog automatically tries to be consistent with what is used in the target file; in doubt, it defaults to the YYYY-MM-DD format.",
},
}, {
Name: "time_convention",
reader: func(value string, config *Config) error {
use24HourClock := true
if value == "24h" {
use24HourClock = true
} else if value == "12h" {
use24HourClock = false
} else {
return errors.New("The value must be `24h` or `12h`")
}
config.TimeUse24HourClock.set(use24HourClock, configOriginFile)
return nil
},
value: func(c Config) (string, configOrigin) {
result := ""
c.TimeUse24HourClock.Unwrap(func(t bool) {
if t {
result = "24h"
} else {
result = "12h"
}
})
return result, c.TimeUse24HourClock.origin
},
Help: Help{
Summary: "The preferred time convention for klog to use when adding a new time range entry to a target file, i.e. whether it uses the 24-hour clock (as in `13:00`) or the 12-hour clock (as in `1:00pm`).",
Value: "The config property must be either `24h` or `12h`.",
Default: "If absent/empty, klog automatically tries to be consistent with what is used in the target file; in doubt, it defaults to the 24-hour clock format.",
},
}, {
Name: "no_warnings",
reader: func(value string, config *Config) error {
sanitizedString := strings.ReplaceAll(value, " ", "")
warningConfigs := strings.Split(sanitizedString, ",")
disabledCheckers := service.NewDisabledCheckers()
for _, c := range warningConfigs {
if _, nameExists := disabledCheckers[c]; !nameExists {
return errors.New(
"The value must be a valid warning name, such as `UNCLOSED_OPEN_RANGE`, got: " + c + ".",
)
}
disabledCheckers[c] = true
}
config.NoWarnings.set(disabledCheckers, configOriginFile)
return nil
},
value: func(c Config) (string, configOrigin) {
result := ""
c.NoWarnings.Unwrap(func(warningConfigs service.DisabledCheckers) {
keys := make([]string, 0, len(warningConfigs))
for k, optedOut := range warningConfigs {
if optedOut {
keys = append(keys, k)
}
}
sort.Strings(keys)
result = strings.Join(keys, ", ")
})
return result, c.NoWarnings.origin
},
Help: Help{
Summary: "Whether klog should suppress warnings when processing files.",
Value: "The config property must be one of: `UNCLOSED_OPEN_RANGE` (for unclosed open ranges in past records), `FUTURE_ENTRIES` (for records/entries in the future), `OVERLAPPING_RANGES` (for time ranges that overlap), `MORE_THAN_24H` (if there is a record with more than 24h total). Multiple values must be separated by a comma, e.g.: `UNCLOSED_OPEN_RANGE, MORE_THAN_24H`.",
Default: "If absent/empty, klog prints all available warnings.",
},
},
}
type Help struct {
Summary string
Default string
Value string
}
type ConfigFileEntries[T any] struct {
Name string
Help Help
reader func(string, *Config) error
value func(Config) (string, configOrigin)
}
func (e ConfigFileEntries[T]) Value(c Config) string {
v, o := e.value(c)
if o == configOriginFile {
return v
}
return ""
}
func (e FromConfigFile) Apply(config *Config) Error {
data, lErr := genie.Parse(e.FileContents)
if lErr != nil {
return NewError(
"Invalid config file",
lErr.Error(),
nil,
)
}
for _, entry := range CONFIG_FILE_ENTRIES {
key := entry.Name
value := data.Get(key)
if value == "" {
continue
}
rErr := entry.reader(value, config)
if rErr != nil {
return NewError(
"Invalid config file",
"The value for the `"+key+"` setting is not valid: "+entry.Help.Value,
rErr,
)
}
}
return nil
}
07070100000055000081A40000000000000000000000016863F92F000024F4000000000000000000000000000000000000002100000000klog-6.6/klog/app/config_test.gopackage app
import (
"testing"
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/service"
"github.com/stretchr/testify/assert"
)
func createMockConfigFromEnv(vs map[string]string) FromEnvVars {
return FromEnvVars{GetVar: func(n string) string {
return vs[n]
}}
}
func TestCreatesNewDefaultConfig(t *testing.T) {
c := NewDefaultConfig(tf.COLOUR_THEME_NO_COLOUR)
assert.Equal(t, c.IsDebug.Value(), false)
assert.Equal(t, c.Editor.UnwrapOr(""), "")
assert.Equal(t, c.CpuKernels.Value(), 1)
assert.Equal(t, c.ColourScheme.Value(), tf.COLOUR_THEME_NO_COLOUR)
isRoundingSet := false
c.DefaultRounding.Unwrap(func(_ service.Rounding) {
isRoundingSet = true
})
assert.False(t, isRoundingSet)
isShouldTotalSet := false
c.DefaultShouldTotal.Unwrap(func(_ klog.ShouldTotal) {
isShouldTotalSet = true
})
assert.False(t, isShouldTotalSet)
isNoWarningsSet := false
c.NoWarnings.Unwrap(func(_ service.DisabledCheckers) {
isNoWarningsSet = true
})
assert.False(t, isNoWarningsSet)
}
func TestSetsParamsMetadataIsHandledCorrectly(t *testing.T) {
{
c := NewDefaultConfig(tf.COLOUR_THEME_NO_COLOUR)
assert.Equal(t, c.IsDebug.Value(), false)
}
{
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{
"KLOG_DEBUG": "1",
}),
FromConfigFile{""},
)
assert.Equal(t, c.IsDebug.Value(), true)
}
}
func TestSetsParamsFromEnv(t *testing.T) {
t.Run("Read plain environment variables.", func(t *testing.T) {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{
"EDITOR": "subl",
"KLOG_DEBUG": "1",
"NO_COLOR": "1",
}),
FromConfigFile{""},
)
assert.Equal(t, c.IsDebug.Value(), true)
assert.Equal(t, c.ColourScheme.Value(), tf.COLOUR_THEME_NO_COLOUR)
assert.Equal(t, c.Editor.UnwrapOr(""), "subl")
})
t.Run("`$EDITOR` env variable trumps `editor` setting from config file.", func(t *testing.T) {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{
"EDITOR": "subl",
}),
FromConfigFile{"editor = vi"},
)
assert.Equal(t, "subl", c.Editor.UnwrapOr(""))
})
t.Run("`$NO_COLOR` env variable trumps `colour_scheme = dark` from config file.", func(t *testing.T) {
// This is important, otherwise you wouldn’t be able to override the colour scheme
// e.g. for programmatic usage of klog.
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{
"NO_COLOR": "1",
}),
FromConfigFile{"colour_scheme = dark"},
)
assert.Equal(t, tf.COLOUR_THEME_NO_COLOUR, c.ColourScheme.Value())
})
}
func TestSetsColourSchemeParamFromConfigFile(t *testing.T) {
for _, x := range []struct {
cfg string
exp tf.ColourTheme
}{
{`colour_scheme = dark`, tf.COLOUR_THEME_DARK},
{`colour_scheme = light`, tf.COLOUR_THEME_LIGHT},
{`colour_scheme = basic`, tf.COLOUR_THEME_BASIC},
{`colour_scheme = no_colour`, tf.COLOUR_THEME_NO_COLOUR},
} {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{x.cfg},
)
assert.Equal(t, x.exp, c.ColourScheme.Value())
}
}
func TestSetsDefaultRoundingParamFromConfigFile(t *testing.T) {
for _, x := range []struct {
cfg string
exp int
}{
{`default_rounding = 5m`, 5},
{`default_rounding = 10m`, 10},
{`default_rounding = 15m`, 15},
{`default_rounding = 30m`, 30},
{`default_rounding = 60m`, 60},
} {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{x.cfg},
)
var value int
c.DefaultRounding.Unwrap(func(r service.Rounding) {
value = r.ToInt()
})
assert.Equal(t, x.exp, value)
}
}
func TestSetsDefaultShouldTotalParamFromConfigFile(t *testing.T) {
for _, x := range []struct {
cfg string
exp string
}{
{`default_should_total = 8h30m!`, "8h30m!"},
} {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{x.cfg},
)
var value string
c.DefaultShouldTotal.Unwrap(func(s klog.ShouldTotal) {
value = s.ToString()
})
assert.Equal(t, x.exp, value)
}
}
func TestSetsDateFormatParamFromConfigFile(t *testing.T) {
for _, x := range []struct {
cfg string
exp bool
}{
{`date_format = YYYY-MM-DD`, true},
{`date_format = YYYY/MM/DD`, false},
} {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{x.cfg},
)
var value bool
c.DateUseDashes.Unwrap(func(s bool) {
value = s
})
assert.Equal(t, x.exp, value)
}
}
func TestSetTimeFormatParamFromConfigFile(t *testing.T) {
for _, x := range []struct {
cfg string
exp bool
}{
{`time_convention = 24h`, true},
{`time_convention = 12h`, false},
} {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{x.cfg},
)
var value bool
c.TimeUse24HourClock.Unwrap(func(s bool) {
value = s
})
assert.Equal(t, x.exp, value)
}
}
func TestNoWarningsParamFromConfigFile(t *testing.T) {
for _, x := range []struct {
cfg string
exp service.DisabledCheckers
}{
// Single value
{`no_warnings = MORE_THAN_24H`, func() service.DisabledCheckers {
dc := service.NewDisabledCheckers()
dc["MORE_THAN_24H"] = true
return dc
}()},
// Multiple values (sorted alphabetically)
{`no_warnings = MORE_THAN_24H, OVERLAPPING_RANGES`, func() service.DisabledCheckers {
dc := service.NewDisabledCheckers()
dc["MORE_THAN_24H"] = true
dc["OVERLAPPING_RANGES"] = true
return dc
}()},
// Multiple values with additional whitespace
{`no_warnings = MORE_THAN_24H , OVERLAPPING_RANGES `, func() service.DisabledCheckers {
dc := service.NewDisabledCheckers()
dc["MORE_THAN_24H"] = true
dc["OVERLAPPING_RANGES"] = true
return dc
}()},
} {
c, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{x.cfg},
)
var value service.DisabledCheckers
c.NoWarnings.Unwrap(func(s service.DisabledCheckers) {
value = s
})
assert.Equal(t, x.exp, value)
}
}
func TestSerialisesConfigFile(t *testing.T) {
for _, tml := range []string{`
editor =
colour_scheme =
default_rounding =
default_should_total =
date_format =
time_convention =
no_warnings =
`, `
editor =
colour_scheme = light
default_rounding =
default_should_total =
date_format = YYYY/MM/DD
time_convention =
no_warnings = FUTURE_ENTRIES
`, `
editor = subl
colour_scheme = dark
default_rounding = 15m
default_should_total = 8h!
date_format = YYYY-MM-DD
time_convention = 24h
no_warnings = MORE_THAN_24H, OVERLAPPING_RANGES
`} {
cfg, _ := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{tml},
)
serialisedFile := "\n"
for _, e := range CONFIG_FILE_ENTRIES {
serialisedFile += e.Name + " = " + e.Value(cfg) + "\n"
}
assert.Equal(t, serialisedFile, tml)
}
}
func TestIgnoresUnknownPropertiesInConfigFile(t *testing.T) {
for _, tml := range []string{`
unknown_property = 1
what_is_this = true
`,
} {
_, err := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{tml},
)
assert.Nil(t, err)
}
}
func TestIgnoresEmptyConfigFileOrEmptyParameters(t *testing.T) {
for _, tml := range []string{
``,
`editor = `,
`colour_scheme = `,
`default_rounding =`,
`default_should_total = `,
`date_format = `,
`time_convention = `,
`no_warnings = `,
} {
_, err := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{tml},
)
assert.Nil(t, err)
}
}
func TestRejectsInvalidConfigFile(t *testing.T) {
for _, tml := range []string{
`default_rounding = true`, // Wrong type
`default_rounding = 25m`, // Invalid value
`default_rounding = 15M`, // Malformed value
`colour_scheme = true`, // Wrong type
`colour_scheme = yellow`, // Invalid value
`colour_scheme = DARK`, // Malformed value
`default_should_total = [true, false]`, // Wrong type
`default_should_total = 15`, // Invalid value
`default_should_total = 8H`, // Malformed value
`date_format = [true, false]`, // Wrong type
`date_format = YYYY.MM.DD`, // Invalid value
`date_format = yyyy-mm-dd`, // Malformed value
`time_convention = [true, false]`, // Wrong type
`time_convention = 2h`, // Invalid value
`time_convention = 24H`, // Malformed value
`no_warnings = [OVERLAPPING_RANGES, MORE_THAN_24H]`, // Wrong type
`no_warnings = yes`, // Invalid value
`no_warnings = overlapping_ranges`, // Malformed value
} {
_, err := NewConfig(
FromDeterminedValues{NumCpus: 1},
createMockConfigFromEnv(map[string]string{}),
FromConfigFile{tml},
)
assert.Error(t, err)
}
}
07070100000056000081A40000000000000000000000016863F92F00002606000000000000000000000000000000000000001D00000000klog-6.6/klog/app/context.go/*
Package app contains the functionality that is related to the application layer.
This includes all code for the command line interface and the procedures to
interact with the runtime environment.
*/
package app
import (
"bufio"
"fmt"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app/cli/command"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/reconciling"
"github.com/jotaen/klog/klog/parser/txt"
"os"
"os/exec"
gotime "time"
)
// FileOrBookmarkName is either a file name or a bookmark name
// as specified as argument on the command line.
type FileOrBookmarkName string
const (
BOOKMARKS_FILE_NAME = "bookmarks.json"
CONFIG_FILE_NAME = "config.ini"
)
// Context is a representation of the runtime environment of klog.
// The commands carry out all side effects via this interface.
type Context interface {
// Print prints to stdout.
Print(string)
// ReadLine reads user input from stdin.
ReadLine() (string, Error)
// KlogConfigFolder returns the path of the klog config folder.
KlogConfigFolder() File
// Meta returns miscellaneous meta information.
Meta() Meta
// ReadInputs retrieves all input from the given file or bookmark names.
ReadInputs(...FileOrBookmarkName) ([]klog.Record, Error)
// RetrieveTargetFile returns the desired file, requiring that there is exactly one.
RetrieveTargetFile(fileArg FileOrBookmarkName) (FileWithContents, Error)
// ReconcileFile applies one or more reconcile handlers to a file and saves it.
ReconcileFile(FileOrBookmarkName, []reconciling.Creator, ...reconciling.Reconcile) (*reconciling.Result, Error)
// Now returns the current timestamp.
Now() gotime.Time
// ReadBookmarks returns all configured bookmarks of the user.
ReadBookmarks() (BookmarksCollection, Error)
// ManipulateBookmarks saves a modified bookmark collection.
ManipulateBookmarks(func(BookmarksCollection) Error) Error
// Execute attempts to run a command on the system.
Execute(command.Command) Error
// Editors returns commands to launch a text editor on the system.
// - The string is a user-specified command, if specified.
// - The command list is a prioritised list of predefined commands.
Editors() (string, []command.Command)
// FileExplorers returns commands to launch a file explorer on the system.
FileExplorers() []command.Command
// Serialise returns the current styler + serialiser according to the user’s
// style preferences.
Serialise() (tf.Styler, TextSerialiser)
// ConfigureSerialisation changes serialisation properties.
ConfigureSerialisation(func(tf.Styler, bool) (tf.Styler, bool))
// Debug takes a void function that is only executed in debug mode.
Debug(func())
// Config returns the current preferences.
Config() Config
}
// Meta holds miscellaneous information about the klog binary.
type Meta struct {
// Specification contains the file format specification in full text.
Specification string
// License contains the license text.
License string
// Version contains the release version, e.g. `v2.7`.
Version string
// SrcHash contains the hash of the sources that the binary was built from.
SrcHash string
}
// NewContext creates a new Context object.
func NewContext(klogFolder File, meta Meta, styler tf.Styler, cfg Config) Context {
parserEngine := parser.NewSerialParser()
if cfg.CpuKernels.Value() > 1 {
parserEngine = parser.NewParallelParser(cfg.CpuKernels.Value())
}
return &context{
klogFolder,
parserEngine,
styler,
NewSerialiser(styler, false),
meta,
cfg,
}
}
type context struct {
klogFolder File
parser parser.Parser
styler tf.Styler
serialiser TextSerialiser
meta Meta
config Config
}
func (ctx *context) Print(text string) {
fmt.Print(text)
}
func (ctx *context) ReadLine() (string, Error) {
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
input := scanner.Text()
return input, nil
}
return "", NewErrorWithCode(
IO_ERROR,
"Cannot process input",
"Reading from stdin failed",
nil,
)
}
func (ctx *context) KlogConfigFolder() File {
return ctx.klogFolder
}
func (ctx *context) Meta() Meta {
return ctx.meta
}
func (ctx *context) ReadInputs(fileArgs ...FileOrBookmarkName) ([]klog.Record, Error) {
bc, bErr := ctx.ReadBookmarks()
if bErr != nil {
return nil, bErr
}
files, rErr := retrieveFirst([]Retriever{
(&StdinRetriever{ReadStdin}).Retrieve,
(&FileRetriever{ReadFile, bc}).Retrieve,
}, fileArgs...)
if rErr != nil {
return nil, rErr
}
if len(files) == 0 {
return nil, NewErrorWithCode(
NO_INPUT_ERROR,
"No input given",
"Please do one of the following:\n"+
" a) specify one or multiple file names or bookmark names\n"+
" b) pipe file contents via stdin\n"+
" c) set a default bookmark to read from",
nil,
)
}
var allRecords []klog.Record
var allErrors []txt.Error
for _, f := range files {
records, _, errs := ctx.parser.Parse(f.Contents())
for _, e := range errs {
allErrors = append(allErrors, e.SetOrigin(f.Path()))
}
allRecords = append(allRecords, records...)
}
if len(allErrors) > 0 {
return nil, NewParserErrors(allErrors)
}
return allRecords, nil
}
func (ctx *context) RetrieveTargetFile(fileArg FileOrBookmarkName) (FileWithContents, Error) {
bc, err := ctx.ReadBookmarks()
if err != nil {
return nil, err
}
inputs, err := (&FileRetriever{ReadFile, bc}).Retrieve(fileArg)
if err != nil {
return nil, err
}
if len(inputs) == 0 {
return nil, NewErrorWithCode(
NO_TARGET_FILE,
"No file specified",
"Either specify a file name or bookmark name, or set a default bookmark",
nil,
)
}
return inputs[0], nil
}
func (ctx *context) ReconcileFile(fileArg FileOrBookmarkName, creators []reconciling.Creator, reconcile ...reconciling.Reconcile) (*reconciling.Result, Error) {
target, err := ctx.RetrieveTargetFile(fileArg)
if err != nil {
return nil, err
}
records, blocks, errs := ctx.parser.Parse(target.Contents())
for i, e := range errs {
errs[i] = e.SetOrigin(target.Path())
}
if errs != nil {
return nil, NewParserErrors(errs)
}
result, aErr := ApplyReconciler(records, blocks, creators, reconcile...)
if aErr != nil {
return nil, aErr
}
wErr := WriteToFile(target, result.AllSerialised)
if wErr != nil {
return nil, wErr
}
return result, nil
}
func ApplyReconciler(records []klog.Record, blocks []txt.Block, creators []reconciling.Creator, reconcile ...reconciling.Reconcile) (*reconciling.Result, Error) {
reconciler := func() *reconciling.Reconciler {
for _, createReconciler := range creators {
// Both the creator and the created reconciler might be nil,
// to indicate it’s not eligible.
if createReconciler == nil {
continue
}
r := createReconciler(records, blocks)
if r != nil {
return r
}
}
return nil
}()
if reconciler == nil {
return nil, NewErrorWithCode(
LOGICAL_ERROR,
"No such record",
"Please create or specify a record for this operation",
nil,
)
}
for _, r := range reconcile {
err := r(reconciler)
if err != nil {
return nil, NewErrorWithCode(
LOGICAL_ERROR,
"Manipulation failed",
err.Error(),
err,
)
}
}
result, err := reconciler.MakeResult()
if err != nil {
return nil, NewErrorWithCode(
LOGICAL_ERROR,
"Manipulation failed",
err.Error(),
err,
)
}
return result, nil
}
func (ctx *context) Now() gotime.Time {
return gotime.Now()
}
func (ctx *context) initialiseKlogFolder() Error {
klogFolder := ctx.KlogConfigFolder()
err := os.MkdirAll(klogFolder.Path(), 0700)
if err != nil {
return NewError(
"Unable to initialise klog config folder",
"Please create a klog config folder manually",
err,
)
}
return nil
}
func (ctx *context) ReadBookmarks() (BookmarksCollection, Error) {
bookmarksDatabase, err := ReadFile(ctx.bookmarkDatabasePath())
if err != nil {
if os.IsNotExist(err.Original()) {
// An absent bookmarks file is equivalent to an empty one.
return NewEmptyBookmarksCollection(), nil
}
return nil, err
}
return NewBookmarksCollectionFromJson(bookmarksDatabase)
}
func (ctx *context) ManipulateBookmarks(manipulate func(BookmarksCollection) Error) Error {
bc, bErr := ctx.ReadBookmarks()
if bErr != nil {
return bErr
}
mErr := manipulate(bc)
if mErr != nil {
return mErr
}
iErr := ctx.initialiseKlogFolder()
if iErr != nil {
return iErr
}
return WriteToFile(ctx.bookmarkDatabasePath(), bc.ToJson())
}
func (ctx *context) bookmarkDatabasePath() File {
return Join(ctx.KlogConfigFolder(), BOOKMARKS_FILE_NAME)
}
func (ctx *context) Execute(cmd command.Command) Error {
c := exec.Command(cmd.Bin, cmd.Args...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
err := c.Run()
if err == nil {
return nil
}
return NewError(
"Failed to run command",
"The command exited with non-zero status",
err,
)
}
func (ctx *context) Editors() (string, []command.Command) {
configuredEditor := ""
ctx.config.Editor.Unwrap(func(s string) {
configuredEditor = s
})
return configuredEditor, POTENTIAL_EDITORS
}
func (ctx *context) FileExplorers() []command.Command {
return POTENTIAL_FILE_EXLORERS
}
func (ctx *context) Serialise() (tf.Styler, TextSerialiser) {
return ctx.styler, ctx.serialiser
}
func (ctx *context) ConfigureSerialisation(fn func(tf.Styler, bool) (tf.Styler, bool)) {
styler, decimalDuration := fn(ctx.styler, ctx.serialiser.DecimalDuration)
ctx.styler = styler
ctx.serialiser = NewSerialiser(styler, decimalDuration)
}
func (ctx *context) Debug(task func()) {
if ctx.config.IsDebug.Value() {
task()
}
}
func (ctx *context) Config() Config {
return ctx.config
}
07070100000057000081A40000000000000000000000016863F92F00000A5E000000000000000000000000000000000000001B00000000klog-6.6/klog/app/error.gopackage app
import (
"fmt"
"github.com/jotaen/klog/klog/parser/txt"
)
type Code int
const (
// GENERAL_ERROR should be used for generic, otherwise unspecified errors.
GENERAL_ERROR Code = iota + 1
// NO_INPUT_ERROR should be used if no input was specified.
NO_INPUT_ERROR
// NO_TARGET_FILE should be used if no target file was specified.
NO_TARGET_FILE
// IO_ERROR should be used for errors during I/O processes.
IO_ERROR
// CONFIG_ERROR should be used for config-folder-related problems.
CONFIG_ERROR
// NO_SUCH_BOOKMARK_ERROR should be used if the specified an unknown bookmark name.
NO_SUCH_BOOKMARK_ERROR
// NO_SUCH_FILE should be used if the specified file does not exit.
NO_SUCH_FILE
// LOGICAL_ERROR should be used syntax or logical violations.
LOGICAL_ERROR
)
// ToInt returns the numeric value of the error. This is typically used as exit code.
func (c Code) ToInt() int {
return int(c)
}
// Error is a representation of an application error.
type Error interface {
// Error returns the error message.
Error() string
Is(error) bool
// Details returns additional details, such as a hint how to solve the problem.
Details() string
// Original returns the original underlying error, if it exists.
Original() error
// Code returns the error code.
Code() Code
}
type AppError struct {
code Code
message string
details string
original error
}
func NewError(message string, details string, original error) Error {
return NewErrorWithCode(GENERAL_ERROR, message, details, original)
}
func NewErrorWithCode(code Code, message string, details string, original error) Error {
return AppError{code, message, details, original}
}
func (e AppError) Error() string {
return e.message
}
func (e AppError) Is(err error) bool {
_, ok := err.(AppError)
return ok
}
func (e AppError) Details() string {
return e.details
}
func (e AppError) Original() error {
return e.original
}
func (e AppError) Code() Code {
return e.code
}
type ParserErrors interface {
Error
All() []txt.Error
}
type parserErrors struct {
errors []txt.Error
}
func NewParserErrors(errs []txt.Error) ParserErrors {
return parserErrors{errs}
}
func (pe parserErrors) Error() string {
return fmt.Sprintf("%d parsing error(s)", len(pe.errors))
}
func (e parserErrors) Is(err error) bool {
_, ok := err.(parserErrors)
return ok
}
func (pe parserErrors) Details() string {
return fmt.Sprintf("%d parsing error(s)", len(pe.errors))
}
func (pe parserErrors) Original() error {
return nil
}
func (pe parserErrors) Code() Code {
return LOGICAL_ERROR
}
func (pe parserErrors) All() []txt.Error {
return pe.errors
}
07070100000058000081A40000000000000000000000016863F92F000010CD000000000000000000000000000000000000001A00000000klog-6.6/klog/app/file.gopackage app
import (
"io"
"os"
"path/filepath"
)
// File is a descriptor for a file.
// The file is not guaranteed to actually exist on disk.
type File interface {
// Name returns the file name.
Name() string
// Location returns the path of the folder, where the file resides.
Location() string
// Path returns the path of the file.
Path() string
}
// FileWithContents is like File, but with the file contents attached to it.
type FileWithContents interface {
File
// Contents returns the contents of the file.
Contents() string
}
// NewFile creates a new File object from an absolute or relative path.
// It returns an error if the given path cannot be resolved.
func NewFile(path ...string) (File, Error) {
fullPath := filepath.Join(path...)
absolutePath, err := filepath.Abs(fullPath)
if err != nil {
return nil, NewErrorWithCode(
IO_ERROR,
"Invalid file path",
"Location: "+fullPath,
err,
)
}
return NewFileOrPanic(absolutePath), nil
}
// NewFileOrPanic creates a new File object. It panics, if the path is not absolute.
func NewFileOrPanic(absolutePath string) File {
if !filepath.IsAbs(absolutePath) {
panic("Not an absolute path: " + absolutePath)
}
return &fileWithPath{absolutePath}
}
func NewFileWithContents(path string, contents string) (FileWithContents, Error) {
file, err := NewFile(path)
if err != nil {
return nil, err
}
return &fileWithContents{file, contents}, nil
}
type fileWithPath struct {
absolutePath string
}
type fileWithContents struct {
File
contents string
}
func (f *fileWithPath) Name() string {
return filepath.Base(f.absolutePath)
}
func (f *fileWithPath) Location() string {
return filepath.Dir(f.absolutePath)
}
func (f *fileWithPath) Path() string {
return f.absolutePath
}
func (f *fileWithContents) Contents() string {
return f.contents
}
func Join(f File, fileOrFolderName string) File {
return NewFileOrPanic(filepath.Join(f.Path(), fileOrFolderName))
}
// IsAbs checks whether the given path is absolute.
func IsAbs(path string) bool {
return filepath.IsAbs(path)
}
// ReadFile reads the contents of a file from disk and returns it as string.
// It returns an error if the file doesn’t exit or cannot be read.
func ReadFile(source File) (string, Error) {
contents, err := os.ReadFile(source.Path())
if err != nil {
if os.IsNotExist(err) {
return "", NewErrorWithCode(
NO_SUCH_FILE,
"No such file",
"Location: "+source.Path(),
err,
)
}
return "", NewErrorWithCode(
IO_ERROR,
"Cannot read file",
"Location: "+source.Path(),
err,
)
}
return string(contents), nil
}
// WriteToFile saves contents in a file on disk.
// It returns an error if the file cannot be written.
func WriteToFile(target File, contents string) Error {
err := os.WriteFile(target.Path(), []byte(contents), 0644)
if err != nil {
return NewErrorWithCode(
IO_ERROR,
"Cannot write to file",
"Location: "+target.Path(),
err,
)
}
return nil
}
// CreateEmptyFile creates a new file on disk.
// It returns an error if the file already exists, or if the file cannot be
// created.
func CreateEmptyFile(file File) Error {
if _, sErr := os.Stat(file.Path()); !os.IsNotExist(sErr) {
return NewErrorWithCode(
GENERAL_ERROR,
"Cannot create file",
"There is already a file at that location",
sErr,
)
}
// Note: `os.Create` would truncate the file if it already exists.
_, cErr := os.Create(file.Path())
if cErr != nil {
return NewErrorWithCode(
GENERAL_ERROR,
"Cannot create file",
"Please check the file name and permissions",
cErr,
)
}
return nil
}
// ReadStdin reads the entire input from stdin and returns it as string.
// It returns an error if stdin cannot be accessed, or if reading from it fails.
func ReadStdin() (string, Error) {
stat, err := os.Stdin.Stat()
if err != nil {
return "", NewErrorWithCode(
IO_ERROR,
"Cannot read from Stdin",
"Cannot open Stdin stream to check for input",
err,
)
}
if (stat.Mode() & os.ModeCharDevice) != 0 {
return "", nil
}
bytes, err := io.ReadAll(os.Stdin)
if err != nil {
return "", NewErrorWithCode(
IO_ERROR,
"Error while reading from Stdin",
"An error occurred while processing the input stream",
err,
)
}
return string(bytes), nil
}
07070100000059000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001700000000klog-6.6/klog/app/main0707010000005A000081A40000000000000000000000016863F92F00000F07000000000000000000000000000000000000001E00000000klog-6.6/klog/app/main/cli.go/*
Package klog is the entry point of the command line tool.
*/
package klog
import (
"errors"
"github.com/alecthomas/kong"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/app"
"github.com/jotaen/klog/klog/app/cli"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/app/cli/util"
"github.com/jotaen/klog/klog/service"
"github.com/jotaen/klog/klog/service/period"
kongcompletion "github.com/jotaen/kong-completion"
"reflect"
)
func Run(homeDir app.File, meta app.Meta, config app.Config, args []string) (int, error) {
kongApp, nErr := kong.New(
&cli.Cli{},
kong.Name("klog"),
kong.Description((&cli.Default{}).Help()),
func() kong.Option {
datePrototype, _ := klog.NewDate(1, 1, 1)
return kong.TypeMapper(reflect.TypeOf(&datePrototype).Elem(), dateDecoder())
}(),
func() kong.Option {
timePrototype, _ := klog.NewTime(0, 0)
return kong.TypeMapper(reflect.TypeOf(&timePrototype).Elem(), timeDecoder())
}(),
func() kong.Option {
shouldTotalPrototype := klog.NewShouldTotal(0, 0)
return kong.TypeMapper(reflect.TypeOf(&shouldTotalPrototype).Elem(), shouldTotalDecoder())
}(),
func() kong.Option {
someSinceDate, _ := klog.NewDate(1, 1, 1)
someUntilDate, _ := klog.NewDate(2, 2, 2)
p := period.NewPeriod(someSinceDate, someUntilDate)
return kong.TypeMapper(reflect.TypeOf(&p).Elem(), periodDecoder())
}(),
func() kong.Option {
f, _ := service.NewRounding(30)
return kong.TypeMapper(reflect.TypeOf(&f).Elem(), roundingDecoder())
}(),
func() kong.Option {
t := klog.NewTagOrPanic("test", "")
return kong.TypeMapper(reflect.TypeOf(&t).Elem(), tagDecoder())
}(),
func() kong.Option {
s, _ := klog.NewRecordSummary("test")
return kong.TypeMapper(reflect.TypeOf(&s).Elem(), recordSummaryDecoder())
}(),
func() kong.Option {
s, _ := klog.NewEntrySummary("test")
return kong.TypeMapper(reflect.TypeOf(&s).Elem(), entrySummaryDecoder())
}(),
func() kong.Option {
t := service.ENTRY_TYPE_DURATION
return kong.TypeMapper(reflect.TypeOf(&t).Elem(), entryTypeDecoder())
}(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
NoExpandSubcommands: true,
WrapUpperBound: 80,
}),
)
if nErr != nil {
// This code branch is not expected to be invoked in practice. If it were to
// happen, that most likely indicates a bug in the app setup.
return app.GENERAL_ERROR.ToInt(), errors.New("Internal error: " + nErr.Error())
}
styler := tf.NewStyler(config.ColourScheme.Value())
ctx := app.NewContext(
homeDir,
meta,
styler,
config,
)
// When klog is invoked by shell completion (specifically, when the
// bash-specific COMP_LINE environment variable is set), the
// kongplete.Complete function generates a list of possible completions,
// prints them one per line to stdout, and then exits the program early.
kongcompletion.Register(
kongApp,
kongcompletion.WithPredictors(CompletionPredictors(ctx)),
kongcompletion.WithFlagOverrides(util.FilterArgsCompletionOverrides),
)
kongCtx, cErr := kongApp.Parse(args)
if cErr != nil {
return app.GENERAL_ERROR.ToInt(), errors.New("Invocation error: " + cErr.Error())
}
kongCtx.BindTo(ctx, (*app.Context)(nil))
rErr := kongCtx.Run()
parserErrors := app.NewParserErrors(nil)
appError := app.NewError("", "", nil)
switch {
case rErr == nil:
return 0, nil
case errors.As(rErr, &parserErrors):
return parserErrors.Code().ToInt(), util.PrettifyParsingError(parserErrors, styler)
case errors.As(rErr, &appError):
return appError.Code().ToInt(), util.PrettifyAppError(appError, config.IsDebug.Value())
default:
// This is just a fallback clause; this code branch is not expected to be
// invoked in practice.
return app.GENERAL_ERROR.ToInt(), errors.New("Error: " + rErr.Error())
}
}
0707010000005B000081A40000000000000000000000016863F92F0000342B000000000000000000000000000000000000002300000000klog-6.6/klog/app/main/cli_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
func TestHandleInputFiles(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\nSome stuff\n\t1h\n",
"foo.klg": "2021-03-02\n 2h #foo",
},
}).execute(t,
invocation{
args: []string{"print", "test.klg", "foo.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "2020-01-01"), out)
assert.True(t, strings.Contains(out, "2021-03-02"), out)
}},
invocation{
args: []string{"tags", "foo.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "#foo 2h"), out)
}},
)
}
func TestHandlesInvocationErrors(t *testing.T) {
(&Env{
files: map[string]string{},
}).execute(t,
invocation{
args: []string{"print", "--foo"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "Invocation error: unknown flag --foo"), out)
}},
)
}
func TestPrintAppErrors(t *testing.T) {
(&Env{
files: map[string]string{
"invalid.klg": "2020-01-01asdf",
"valid.klg": "2020-01-01",
},
}).execute(t,
invocation{
args: []string{"print", "invalid.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 8, code)
assert.True(t, strings.Contains(out, "[SYNTAX ERROR] in line 1 of file"), out)
assert.True(t, strings.Contains(out, "invalid.klg"), out)
assert.True(t, strings.Contains(out, "2020-01-01asdf"), out)
assert.True(t, strings.Contains(out, "^^^^^^^^^^^^^^"), out)
assert.True(t, strings.Contains(out, "Invalid date"), out)
}},
invocation{
args: []string{"start", "valid.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
}},
invocation{
args: []string{"start", "valid.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 8, code)
assert.True(t, strings.Contains(out, "Error: Manipulation failed"), out)
assert.True(t, strings.Contains(out, "There is already an open range in this record"), out)
}},
)
}
func TestConfigureAndUseBookmark(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\nSome stuff\n\t1h7m\n",
},
}).execute(t,
invocation{
args: []string{"bookmarks", "set", "test.klg", "tst"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "Created new bookmark"), out)
assert.True(t, strings.Contains(out, "@tst"), out)
assert.True(t, strings.Contains(out, "test.klg"), out)
}},
invocation{
args: []string{"bookmarks", "set", "test.klg", "tst"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "Changed bookmark"), out)
assert.True(t, strings.Contains(out, "@tst"), out)
}},
invocation{
args: []string{"bookmarks", "list"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "@tst"), out)
}},
invocation{
args: []string{"total", "@tst"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h7m"), out)
}},
)
}
func TestCreateBookmarkTargetFileOnDemand(t *testing.T) {
(&Env{
files: map[string]string{},
}).execute(t,
invocation{
args: []string{"bookmarks", "set", "--create", "test.klg", "tst"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "Created new bookmark and created target file:"), out)
assert.True(t, strings.Contains(out, "@tst"), out)
assert.True(t, strings.Contains(out, "test.klg"), out)
}},
invocation{
args: []string{"bookmarks", "set", "--create", "test.klg", "tst"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "Error: Cannot create file"), out)
assert.True(t, strings.Contains(out, "There is already a file at that location"), out)
}},
)
}
func TestWriteToFile(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\nSome stuff\n\t1h\n",
},
}).execute(t,
invocation{
args: []string{"track", "--date", "2020-01-01", "30m", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
}},
invocation{
args: []string{"total", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h30m"), out)
assert.True(t, strings.Contains(out, "1 record"), out)
}},
)
}
func TestDecodesDate(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\nSome stuff\n\t1h7m\n",
},
}).execute(t,
invocation{
args: []string{"total", "--date", "2020-1-1", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`2020-1-1` is not a valid date"), out)
}},
invocation{
args: []string{"total", "--date", "2020-01-01", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h7m"), out)
}},
)
}
func TestDecodesTime(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\n\t9:00-?\n",
},
}).execute(t,
invocation{
args: []string{"stop", "--date", "2020-01-01", "--time", "1:0", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`1:0` is not a valid time"), out)
}},
invocation{
args: []string{"stop", "--date", "2020-01-01", "--time", "10:00", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "9:00-10:00"), out)
}},
invocation{
args: []string{"total", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
)
}
func TestDecodesShouldTotal(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "",
},
}).execute(t,
invocation{
args: []string{"create", "--date", "2020-01-01", "--should", "asdf", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`asdf` is not a valid should total"), out)
}},
invocation{
args: []string{"create", "--date", "2020-01-01", "--should", "5h1m!", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "5h1m!"), out)
}},
invocation{
args: []string{"total", "--diff", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "5h1m!"), out)
}},
)
}
func TestDecodesPeriod(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2000-01-05\n\t1h\n\n2000-05-24\n\t1h\n",
},
}).execute(t,
invocation{
args: []string{"total", "--period", "2000", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "2h"), out)
}},
invocation{
args: []string{"total", "--period", "2000-01", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
invocation{
args: []string{"total", "--period", "2000-Q1", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
invocation{
args: []string{"total", "--period", "2000-W21", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
invocation{
args: []string{"total", "--period", "foo", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`foo` is not a valid period"), out)
}},
)
}
func TestDecodesRounding(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01",
},
}).execute(t,
invocation{
args: []string{"start", "--round", "asdf", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`asdf` is not a valid rounding value"), out)
}},
invocation{
args: []string{"start", "--round", "30m", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "- ?"), out)
}},
)
}
func TestDecodesTags(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\n#foo\n\n2020-01-02\n\t1h #bar=1",
},
}).execute(t,
invocation{
args: []string{"print", "--tag", "asdf=asdf=asdf", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`asdf=asdf=asdf` is not a valid tag"), out)
}},
invocation{
args: []string{"print", "--tag", "foo&bar", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "`foo&bar` is not a valid tag"), out)
}},
invocation{
args: []string{"print", "--tag", "foo", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "#foo"), out)
}},
invocation{
args: []string{"print", "--tag", "bar=1", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "#bar=1"), out)
}},
invocation{
args: []string{"print", "--tag", "#bar='1'", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "#bar=1"), out)
}},
)
}
func TestDecodesRecordSummary(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\nTest.",
},
}).execute(t,
invocation{
args: []string{"create", "--summary", "Foo", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "Foo"), out)
}},
invocation{
args: []string{"create", "--summary", "Foo\nBar", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "Foo\nBar"), out)
}},
invocation{
args: []string{"create", "--summary", "Foo\n\nBar", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "A record summary cannot contain blank lines"), out)
}},
invocation{
args: []string{"create", "--summary", "Foo\n Bar", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "A record summary cannot contain blank lines"), out)
}},
)
}
func TestDecodesEntryType(t *testing.T) {
(&Env{
files: map[string]string{
"test.klg": "2020-01-01\n\t1h\n\t9:00-12:00",
},
}).execute(t,
invocation{
args: []string{"total", "--entry-type", "duration", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
invocation{
args: []string{"total", "--entry-type", "DURATION", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
invocation{
args: []string{"total", "--entry-type", "duration-positive", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "1h"), out)
}},
invocation{
args: []string{"total", "--entry-type", "duration-negative", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "0m"), out)
}},
invocation{
args: []string{"total", "--entry-type", "open_range", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "0m"), out)
}},
invocation{
args: []string{"total", "--entry-type", "open-range", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "0m"), out)
}},
invocation{
args: []string{"total", "--entry-type", "range", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 0, code)
assert.True(t, strings.Contains(out, "3h"), out)
}},
invocation{
args: []string{"total", "--entry-type", "asdf", "test.klg"},
test: func(t *testing.T, code int, out string) {
assert.Equal(t, 1, code)
assert.True(t, strings.Contains(out, "is not a valid entry type"), out)
}},
)
}
0707010000005C000081A40000000000000000000000016863F92F0000033C000000000000000000000000000000000000003000000000klog-6.6/klog/app/main/completion_predictors.gopackage klog
import (
"github.com/jotaen/klog/klog/app"
"github.com/posener/complete"
)
func predictBookmarks(ctx app.Context) complete.Predictor {
thunk := func() []string {
names := make([]string, 0)
bookmarksCollection, err := ctx.ReadBookmarks()
if err != nil {
return names
}
for _, bookmark := range bookmarksCollection.All() {
names = append(names, bookmark.Name().ValuePretty())
}
return names
}
return complete.PredictFunc(func(a complete.Args) []string { return thunk() })
}
func CompletionPredictors(ctx app.Context) map[string]complete.Predictor {
return map[string]complete.Predictor{
"file": complete.PredictFiles("*.klg"),
"bookmark": predictBookmarks(ctx),
"file_or_bookmark": complete.PredictOr(complete.PredictFiles("*.klg"), predictBookmarks(ctx)),
}
}
0707010000005D000081A40000000000000000000000016863F92F0000168F000000000000000000000000000000000000002200000000klog-6.6/klog/app/main/decoder.gopackage klog
import (
"errors"
"github.com/alecthomas/kong"
klog "github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/service"
"github.com/jotaen/klog/klog/service/period"
"reflect"
"strings"
)
func dateDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("date", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid date")
}
d, err := klog.NewDateFromString(value)
if err != nil {
return errors.New("`" + value + "` is not a valid date")
}
target.Set(reflect.ValueOf(d))
return nil
}
}
func timeDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("time", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid time")
}
t, err := klog.NewTimeFromString(value)
if err != nil {
return errors.New("`" + value + "` is not a valid time")
}
target.Set(reflect.ValueOf(t))
return nil
}
}
func shouldTotalDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("should", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid should-total duration")
}
valueAsDuration := strings.TrimSuffix(value, "!")
duration, err := klog.NewDurationFromString(valueAsDuration)
if err != nil {
return errors.New("`" + value + "` is not a valid should total")
}
shouldTotal := klog.NewShouldTotal(0, duration.InMinutes())
target.Set(reflect.ValueOf(shouldTotal))
return nil
}
}
func periodDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("period", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid period")
}
p, err := period.NewPeriodFromPatternString(value)
if err != nil {
return errors.New("`" + value + "` is not a valid period")
}
target.Set(reflect.ValueOf(p))
return nil
}
}
func roundingDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("rounder", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid rounding value")
}
r, err := service.NewRoundingFromString(value)
if err != nil {
return errors.New("`" + value + "` is not a valid rounding value")
}
target.Set(reflect.ValueOf(r))
return nil
}
}
func tagDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("tag", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid tag")
}
t, err := klog.NewTagFromString(value)
if err != nil {
return errors.New("`" + value + "` is not a valid tag")
}
target.Set(reflect.ValueOf(t))
return nil
}
}
func recordSummaryDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("recordSummary", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid record summary")
}
// Normalize potential double-escaping (from CLI)
value = strings.ReplaceAll(value, "\\n", "\n")
summary, sErr := klog.NewRecordSummary(strings.Split(value, "\n")...)
if sErr != nil {
return errors.New("A record summary cannot contain blank lines, and none of its lines can start with whitespace characters")
}
target.Set(reflect.ValueOf(summary))
return nil
}
}
func entrySummaryDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("entrySummary", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid record summary")
}
// Normalize potential double-escaping (from CLI)
value = strings.ReplaceAll(value, "\\n", "\n")
// When passing entries like `-45m` the leading dash must be escaped
// by the user, otherwise it would be treated like a flag. Therefore, we
// have to remove the potential escaping backslash. Examples:
// - `\\-45m` (unquoted, with double-escape)
// - `'\-45m'` (quoted, with single-escape)
if strings.HasPrefix(value, "\\-") {
value = strings.TrimPrefix(value, "\\")
} else if strings.HasPrefix(value, "\\\\-") {
value = strings.TrimPrefix(value, "\\\\")
}
summary, sErr := klog.NewEntrySummary(strings.Split(value, "\n")...)
if sErr != nil {
return errors.New("An entry summary cannot contain blank lines")
}
target.Set(reflect.ValueOf(summary))
return nil
}
}
func entryTypeDecoder() kong.MapperFunc {
return func(ctx *kong.DecodeContext, target reflect.Value) error {
var value string
if err := ctx.Scan.PopValueInto("entryType", &value); err != nil {
return err
}
if value == "" {
return errors.New("Please provide a valid entry type")
}
ts := map[service.EntryType]bool{
service.ENTRY_TYPE_RANGE: true,
service.ENTRY_TYPE_OPEN_RANGE: true,
service.ENTRY_TYPE_DURATION: true,
service.ENTRY_TYPE_POSITIVE_DURATION: true,
service.ENTRY_TYPE_NEGATIVE_DURATION: true,
}
t := service.EntryType(strings.ReplaceAll(strings.ToUpper(value), "-", "_"))
if ok := ts[t]; !ok {
return errors.New("`" + value + "` is not a valid entry type")
}
target.Set(reflect.ValueOf(t))
return nil
}
}
0707010000005E000081A40000000000000000000000016863F92F00000647000000000000000000000000000000000000002800000000klog-6.6/klog/app/main/testutil_test.gopackage klog
import (
"github.com/jotaen/klog/klog/app"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/stretchr/testify/require"
"io"
"os"
"strings"
"testing"
)
type Env struct {
files map[string]string
}
type invocation struct {
args []string
test func(t *testing.T, code int, out string)
}
func (e *Env) execute(t *testing.T, is ...invocation) {
// Create temp directory and change work dir to it.
tmpDir, tErr := os.MkdirTemp("", "")
assertNil(tErr)
cErr := os.Chdir(tmpDir)
assertNil(cErr)
// Write out all files from `Env`.
for name, contents := range e.files {
err := os.WriteFile(name, []byte(contents), 0644)
assertNil(err)
}
// Capture “old” stdout, so that we can restore later.
oldStdout := os.Stdout
// Run all commands one after the other.
for _, invoke := range is {
r, w, _ := os.Pipe()
os.Stdout = w
config := app.NewDefaultConfig(tf.COLOUR_THEME_NO_COLOUR)
code, runErr := Run(app.NewFileOrPanic(tmpDir), app.Meta{
Specification: "[Specification text]",
License: "[License text]",
Version: "v0.0",
SrcHash: "abc1234",
}, config, invoke.args)
_ = w.Close()
t.Run(strings.Join(invoke.args, "__"), func(t *testing.T) {
if runErr != nil {
require.NotEqual(t, 0, code, "App returned error, but exit code was 0")
} else {
out, _ := io.ReadAll(r)
invoke.test(t, code, tf.StripAllAnsiSequences(string(out)))
}
})
}
// Clean up temp dir.
rErr := os.RemoveAll(tmpDir)
assertNil(rErr)
os.Stdout = oldStdout
}
func assertNil(e error) {
if e != nil {
panic(e)
}
}
0707010000005F000081A40000000000000000000000016863F92F00000BB2000000000000000000000000000000000000001F00000000klog-6.6/klog/app/retriever.gopackage app
import (
"errors"
"strings"
)
// Retriever is the function interface for retrieving the input data from
// various kinds of sources.
type Retriever func(fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error)
type FileRetriever struct {
readFile func(File) (string, Error)
bookmarks BookmarksCollection
}
// Retrieve retrieves the contents from files or bookmarks. If no arguments were
// specified, it tries to read from the default bookmark.
func (retriever *FileRetriever) Retrieve(fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) {
fileArgs = removeBlankEntries(fileArgs...)
if len(fileArgs) == 0 {
defaultBookmark := retriever.bookmarks.Default()
if defaultBookmark != nil {
fileArgs = []FileOrBookmarkName{
FileOrBookmarkName(defaultBookmark.Target().Path()),
}
}
}
var results []FileWithContents
var errs []string
for _, arg := range fileArgs {
argValue := string(arg)
path, pathErr := (func() (string, error) {
if IsValidBookmarkName(argValue) {
b := retriever.bookmarks.Get(NewName(argValue))
if b == nil {
return argValue, errors.New("No such bookmark")
}
return b.Target().Path(), nil
}
return argValue, nil
})()
if pathErr != nil {
errs = append(errs, pathErr.Error()+": "+argValue)
continue
}
file, fErr := NewFile(path)
if fErr != nil {
errs = append(errs, "Invalid file path: "+path)
}
content, readErr := retriever.readFile(file)
if readErr != nil {
errs = append(errs, readErr.Error()+": "+file.Path())
continue
}
results = append(results, &fileWithContents{file, content})
}
if len(errs) > 0 {
return nil, NewErrorWithCode(
IO_ERROR,
"Cannot retrieve files",
strings.Join(errs, "\n"),
nil,
)
}
return results, nil
}
type StdinRetriever struct {
readStdin func() (string, Error)
}
// Retrieve retrieves the content from stdin. It only returns something if no
// file or bookmark names were specified explicitly.
func (retriever *StdinRetriever) Retrieve(fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) {
fileArgs = removeBlankEntries(fileArgs...)
if len(fileArgs) > 0 {
return nil, nil
}
stdin, err := retriever.readStdin()
if err != nil {
return nil, err
}
if stdin == "" {
return nil, nil
}
return []FileWithContents{&fileWithContents{
File: &fileWithPath{""},
contents: stdin,
}}, nil
}
func removeBlankEntries(fileArgs ...FileOrBookmarkName) []FileOrBookmarkName {
var result []FileOrBookmarkName
for _, f := range fileArgs {
if strings.TrimLeft(string(f), " ") == "" {
continue
}
result = append(result, f)
}
return result
}
// retrieveFirst returns the result from the first Retriever that yields something.
func retrieveFirst(rs []Retriever, fileArgs ...FileOrBookmarkName) ([]FileWithContents, Error) {
for _, r := range rs {
files, err := r(fileArgs...)
if err != nil {
return nil, err
}
if len(files) > 0 {
return files, nil
}
}
return nil, nil
}
07070100000060000081A40000000000000000000000016863F92F00000A16000000000000000000000000000000000000002400000000klog-6.6/klog/app/retriever_test.gopackage app
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
type MockFs map[string]bool
func (fs MockFs) readFile(source File) (string, Error) {
if fs[source.Path()] {
return source.Path(), nil
}
return "", NewError("", source.Path(), nil)
}
func TestFileRetrieverResolvesFilesAndBookmarks(t *testing.T) {
bc := NewEmptyBookmarksCollection()
bc.Set(NewBookmark("foo", NewFileOrPanic("/foo.klg")))
files, err := (&FileRetriever{
MockFs{"/asdf.klg": true, "/foo.klg": true}.readFile,
bc,
}).Retrieve("/asdf.klg", "@foo")
require.Nil(t, err)
require.Len(t, files, 2)
assert.Equal(t, "/asdf.klg", files[0].Path())
assert.Equal(t, "/foo.klg", files[1].Path())
}
func TestReturnsErrorIfBookmarksOrFilesAreInvalid(t *testing.T) {
bc := NewEmptyBookmarksCollection()
bc.Set(NewBookmark("foo", NewFileOrPanic("/foo.klg")))
files, err := (&FileRetriever{
MockFs{}.readFile,
bc,
}).Retrieve("/asdf.klg", "@foo", "@bar")
require.Nil(t, files)
require.Error(t, err)
assert.Contains(t, err.Details(), "/asdf.klg")
assert.Contains(t, err.Details(), "/foo.klg")
assert.Contains(t, err.Details(), "@bar")
}
func TestFallsBackToDefaultBookmark(t *testing.T) {
bc := NewEmptyBookmarksCollection()
bc.Set(NewDefaultBookmark(NewFileOrPanic("/foo.klg")))
retriever := &FileRetriever{
MockFs{"/foo.klg": true}.readFile,
bc,
}
for _, f := range []func() ([]FileWithContents, Error){
func() ([]FileWithContents, Error) { return retriever.Retrieve() },
func() ([]FileWithContents, Error) { return retriever.Retrieve("") },
func() ([]FileWithContents, Error) { return retriever.Retrieve("", " ") },
} {
files, err := f()
require.Nil(t, err)
require.Len(t, files, 1)
assert.Equal(t, "/foo.klg", files[0].Path())
}
}
func TestReturnsStdinInput(t *testing.T) {
retriever := &StdinRetriever{
func() (string, Error) { return "2021-01-01", nil },
}
for _, f := range []func() ([]FileWithContents, Error){
func() ([]FileWithContents, Error) { return retriever.Retrieve() },
func() ([]FileWithContents, Error) { return retriever.Retrieve("") },
func() ([]FileWithContents, Error) { return retriever.Retrieve("", " ") },
} {
files, err := f()
require.Nil(t, err)
require.Len(t, files, 1)
require.Equal(t, "", files[0].Path())
assert.Equal(t, "2021-01-01", files[0].Contents())
}
}
func TestBailsOutIfFileArgsGiven(t *testing.T) {
files, err := (&StdinRetriever{
func() (string, Error) { return "", nil },
}).Retrieve("foo.klg")
require.Nil(t, err)
require.Nil(t, files)
}
07070100000061000081A40000000000000000000000016863F92F00000056000000000000000000000000000000000000001900000000klog-6.6/klog/app/sys.gopackage app
type KlogFolder struct {
BasePathEnvVar string
Location string
}
07070100000062000081A40000000000000000000000016863F92F00000239000000000000000000000000000000000000002000000000klog-6.6/klog/app/sys_darwin.go//go:build darwin
package app
import (
"github.com/jotaen/klog/klog/app/cli/command"
)
var POTENTIAL_EDITORS = []command.Command{
command.New("vim", nil),
command.New("vi", nil),
command.New("nano", nil),
command.New("pico", nil),
command.New("open", []string{"-a", "TextEdit"}),
}
var POTENTIAL_FILE_EXLORERS = []command.Command{
command.New("open", nil),
}
var KLOG_CONFIG_FOLDER = []KlogFolder{
{"KLOG_CONFIG_HOME", ""},
{"XDG_CONFIG_HOME", "klog"},
{"HOME", ".klog"},
}
func (kf KlogFolder) EnvVarSymbol() string {
return "$" + kf.BasePathEnvVar
}
07070100000063000081A40000000000000000000000016863F92F00000211000000000000000000000000000000000000001F00000000klog-6.6/klog/app/sys_linux.go//go:build linux
package app
import (
"github.com/jotaen/klog/klog/app/cli/command"
)
var POTENTIAL_EDITORS = []command.Command{
command.New("vim", nil),
command.New("vi", nil),
command.New("nano", nil),
command.New("pico", nil),
}
var POTENTIAL_FILE_EXLORERS = []command.Command{
command.New("xdg-open", nil),
}
var KLOG_CONFIG_FOLDER = []KlogFolder{
{"KLOG_CONFIG_HOME", ""},
{"XDG_CONFIG_HOME", "klog"},
{"HOME", ".config/klog"},
}
func (kf KlogFolder) EnvVarSymbol() string {
return "$" + kf.BasePathEnvVar
}
07070100000064000081A40000000000000000000000016863F92F0000041F000000000000000000000000000000000000002100000000klog-6.6/klog/app/sys_windows.go//go:build windows
package app
import (
"github.com/jotaen/klog/klog/app/cli/command"
"os"
"syscall"
"unsafe"
)
var POTENTIAL_EDITORS = []command.Command{
command.New("notepad", nil),
}
var POTENTIAL_FILE_EXLORERS = []command.Command{
command.New("cmd.exe", []string{"/C", "start"}),
}
var KLOG_CONFIG_FOLDER = []KlogFolder{
{"KLOG_CONFIG_HOME", ""},
{"AppData", "klog"},
}
func (kf KlogFolder) EnvVarSymbol() string {
return "%" + kf.BasePathEnvVar + "%"
}
func init() {
enableAnsiEscapeSequences()
}
func enableAnsiEscapeSequences() {
const enableVirtualTerminalProcessing = 0x0004
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetConsoleMode = kernel32.NewProc("GetConsoleMode")
procSetConsoleMode = kernel32.NewProc("SetConsoleMode")
)
var mode uint32
procGetConsoleMode.Call(os.Stdout.Fd(), uintptr(unsafe.Pointer(&mode)))
if (mode & enableVirtualTerminalProcessing) != enableVirtualTerminalProcessing {
procSetConsoleMode.Call(os.Stdout.Fd(), uintptr(mode|enableVirtualTerminalProcessing))
}
}
07070100000065000081A40000000000000000000000016863F92F00000904000000000000000000000000000000000000002500000000klog-6.6/klog/app/text_serialiser.gopackage app
import (
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser"
"strconv"
"strings"
)
// TextSerialiser is a specialised parser.Serialiser implementation for the terminal.
type TextSerialiser struct {
DecimalDuration bool
Styler tf.Styler
}
func NewSerialiser(styler tf.Styler, decimal bool) TextSerialiser {
return TextSerialiser{
DecimalDuration: decimal,
Styler: styler,
}
}
func (cs TextSerialiser) duration(d klog.Duration, withSign bool) string {
if cs.DecimalDuration {
return strconv.Itoa(d.InMinutes())
}
if withSign {
return d.ToStringWithSign()
}
return d.ToString()
}
func (cs TextSerialiser) Date(d klog.Date) string {
return cs.Styler.Props(tf.StyleProps{Color: tf.TEXT, IsUnderlined: true}).Format(d.ToString())
}
func (cs TextSerialiser) ShouldTotal(d klog.Duration) string {
return cs.Styler.Props(tf.StyleProps{Color: tf.PURPLE}).Format(cs.duration(d, false))
}
func (cs TextSerialiser) Summary(s parser.SummaryText) string {
txt := s.ToString()
summaryStyler := cs.Styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED})
txt = klog.HashTagPattern.ReplaceAllStringFunc(txt, func(h string) string {
return cs.Styler.Props(tf.StyleProps{Color: tf.TEXT_SUBDUED, IsBold: true}).FormatAndRestore(
h, summaryStyler,
)
})
return summaryStyler.Format(txt)
}
func (cs TextSerialiser) Range(r klog.Range) string {
return cs.Styler.Props(tf.StyleProps{Color: tf.BLUE_DARK}).Format(r.ToString())
}
func (cs TextSerialiser) OpenRange(or klog.OpenRange) string {
return cs.Styler.Props(tf.StyleProps{Color: tf.BLUE_LIGHT}).Format(or.ToString())
}
func (cs TextSerialiser) Duration(d klog.Duration) string {
var c tf.Colour = tf.GREEN
if strings.HasPrefix(d.ToStringWithSign(), "-") {
c = tf.RED
}
return cs.Styler.Props(tf.StyleProps{Color: c}).Format(cs.duration(d, false))
}
func (cs TextSerialiser) SignedDuration(d klog.Duration) string {
var c tf.Colour = tf.GREEN
if strings.HasPrefix(d.ToStringWithSign(), "-") {
c = tf.RED
}
return cs.Styler.Props(tf.StyleProps{Color: c}).Format(cs.duration(d, true))
}
func (cs TextSerialiser) Time(t klog.Time) string {
return cs.Styler.Props(tf.StyleProps{Color: tf.BLUE_LIGHT}).Format(t.ToString())
}
07070100000066000081A40000000000000000000000016863F92F0000105E000000000000000000000000000000000000002A00000000klog-6.6/klog/app/text_serialiser_test.gopackage app
import (
"github.com/jotaen/klog/klog"
tf "github.com/jotaen/klog/klog/app/cli/terminalformat"
"github.com/jotaen/klog/klog/parser"
"github.com/stretchr/testify/assert"
"testing"
)
var serialiser = NewSerialiser(tf.NewStyler(tf.COLOUR_THEME_NO_COLOUR), false)
func TestSerialiseNoRecordsToEmptyString(t *testing.T) {
text := parser.SerialiseRecords(serialiser, []klog.Record{}...).ToString()
assert.Equal(t, "", text)
}
func TestSerialiseEndsWithNewlineIfContainsContent(t *testing.T) {
text := parser.SerialiseRecords(serialiser, klog.NewRecord(klog.Ɀ_Date_(2020, 01, 15))).ToString()
lastChar := []rune(text)[len(text)-1]
assert.Equal(t, '\n', lastChar)
}
func TestSerialiseRecordWithMinimalRecord(t *testing.T) {
text := parser.SerialiseRecords(serialiser, klog.NewRecord(klog.Ɀ_Date_(2020, 01, 15))).ToString()
assert.Equal(t, `2020-01-15
`, text)
}
func TestSerialiseRecordWithCompleteRecord(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 01, 15))
r.SetShouldTotal(klog.NewDuration(7, 30))
r.SetSummary(klog.Ɀ_RecordSummary_("This is a", "multiline summary"))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(8, 00), klog.Ɀ_Time_(12, 15)), klog.Ɀ_EntrySummary_("Foo"))
r.AddDuration(klog.NewDuration(2, 15), klog.Ɀ_EntrySummary_("Bar", "asdf"))
r.AddDuration(klog.NewDuration(0, 0), klog.Ɀ_EntrySummary_("", "Summary text...", "...more text...", " ....preceding whitespace is ok"))
_ = r.Start(klog.NewOpenRange(klog.Ɀ_Time_(14, 38)), klog.Ɀ_EntrySummary_("Baz"))
r.AddDuration(klog.NewDuration(-1, -51), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 23), klog.Ɀ_Time_(4, 3)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(22, 0), klog.Ɀ_TimeTomorrow_(0, 1)), nil)
text := parser.SerialiseRecords(serialiser, r).ToString()
assert.Equal(t, `2020-01-15 (7h30m!)
This is a
multiline summary
8:00 - 12:15 Foo
2h15m Bar
asdf
0m
Summary text...
...more text...
....preceding whitespace is ok
14:38 - ? Baz
-1h51m
<23:23 - 4:03
22:00 - 0:01>
`, text)
}
func TestSerialiseMultipleRecords(t *testing.T) {
text := parser.SerialiseRecords(serialiser, []klog.Record{
klog.NewRecord(klog.Ɀ_Date_(2020, 01, 15)),
klog.NewRecord(klog.Ɀ_Date_(2020, 01, 20)),
}...).ToString()
assert.Equal(t, `2020-01-15
2020-01-20
`, text)
}
func TestParseAndSerialiseCycle(t *testing.T) {
for _, txt := range texts {
p := parser.NewSerialParser()
rs, _, _ := p.Parse(txt)
s := parser.SerialiseRecords(serialiser, rs...).ToString()
assert.Equal(t, txt, s)
}
}
var texts = []string{
// Empty document.
``,
// Minimal document.
`2015-05-14
`,
// Preserves non-canonical formatting variants.
`2015/11/28
+1h
2:00am-3:12pm
12:00 - ????????????????
`,
// Non-ASCII characters.
`2000-01-01
日本語を母語とする大和民族が国民のほとんどを占める。自然地理的には、
ユーラシア大陸の東に位置しており、環太平洋火山帯を構成する。
島嶼国であり、領土が海に囲まれているため地続きの国境は存在しない。
日本列島は本州、北海道、九州、四国、沖縄島(以上本土)
も含めて6852の島を有する。
1h 🙂🥸🤠👍🏽
2018-01-05
मुख्य #रूपमा काम
10:00-12:30
बगैचा खन्नुहोस्
1:00am-3:00pm
कर #घोषणा
`,
// Longer document with all kinds of variants.
`1999-05-31 (8h30m!)
Summary that consists of multiple
lines and contains a #tag as well.
5h30m This and that
-2h Something else
+12m
0m
+0m
-0m
<18:00 - 4:00 Foo
Bar
19:00 - 20:00
Baz
Bar
19:00 - 20:00
20:01 - 0:15>
1:00am - 3:12pm
7:00 - ?
2000-02-12
<18:00-4:00
12:00-??????????
2018-01-04 (3m!)
1h Домашня робота 🏡...
2h Сьогодні я дзвонив
Дімі і складав плани
2018-01-06
+3h sázet květiny
14:00 - ? jít na #procházku, vynést
odpadky, #přines noviny
`,
}
07070100000067000081A40000000000000000000000016863F92F00001213000000000000000000000000000000000000001600000000klog-6.6/klog/date.gopackage klog
import (
"cloud.google.com/go/civil"
"errors"
"fmt"
"math"
"regexp"
"strings"
gotime "time"
)
// Date represents a day in the gregorian calendar.
type Date interface {
// Year returns the year as number, e.g. `2004`.
Year() int
// Month returns the month as number, e.g. `3` for March.
Month() int
// Day returns the day as number, e.g. `21`.
Day() int
// Weekday returns the day of the week, starting from Monday = 1.
Weekday() int
// Quarter returns the quarter that the date is in, e.g. `2` for `2010-04-15`.
Quarter() int
// WeekNumber returns the number of the week and the year that the number refers to.
// Note: the year of the week number might be different from the year of the Date!
WeekNumber() (int, int)
// IsEqualTo checks whether two dates are the same.
IsEqualTo(Date) bool
// IsAfterOrEqual checks whether the given date occurs afterwards or at the same date.
IsAfterOrEqual(Date) bool
// PlusDays adds a number of days to the date. It doesn’t modify
// the original object.
PlusDays(int) Date
// ToString serialises the date, e.g. `2017-04-23`.
ToString() string
// ToStringWithFormat serialises the date according to the given format.
ToStringWithFormat(DateFormat) string
// Format returns the current formatting.
Format() DateFormat
}
// DateFormat contains the formatting options for the Date.
type DateFormat struct {
UseDashes bool
}
// DefaultDateFormat returns the canonical date format, as recommended by the spec.
func DefaultDateFormat() DateFormat {
return DateFormat{UseDashes: true}
}
type date struct {
year int
month int
day int
format DateFormat
}
var datePattern = regexp.MustCompile(`^(\d{4})[-/](\d{2})[-/](\d{2})$`)
func NewDate(year int, month int, day int) (Date, error) {
cd := civil.Date{
Year: year,
Month: gotime.Month(month),
Day: day,
}
return civil2Date(cd, DefaultDateFormat())
}
func NewDateFromString(yyyymmdd string) (Date, error) {
match := datePattern.FindStringSubmatch(yyyymmdd)
if len(match) != 4 || match[1] == "0" || match[2] == "0" || match[3] == "0" {
return nil, errors.New("MALFORMED_DATE")
}
if c := strings.Count(yyyymmdd, "-"); c == 1 { // `-` and `/` mixed
return nil, errors.New("MALFORMED_DATE")
}
cd, err := civil.ParseDate(match[1] + "-" + match[2] + "-" + match[3])
if err != nil || !cd.IsValid() {
return nil, errors.New("UNREPRESENTABLE_DATE")
}
return civil2Date(cd, DateFormat{UseDashes: strings.Contains(yyyymmdd, "-")})
}
func NewDateFromGo(t gotime.Time) Date {
d, err := NewDate(t.Year(), int(t.Month()), t.Day())
if err != nil {
// This can/should never occur
panic("Illegal date")
}
return d
}
func civil2Date(cd civil.Date, format DateFormat) (Date, error) {
if !cd.IsValid() {
return nil, errors.New("UNREPRESENTABLE_DATE")
}
if cd.Year < 0 || cd.Year > 9999 {
// A year greater than 9999 cannot be serialised according to YYYY-MM-DD.
return nil, errors.New("UNREPRESENTABLE_DATE")
}
return &date{
year: cd.Year,
month: int(cd.Month),
day: cd.Day,
format: format,
}, nil
}
func date2Civil(d *date) civil.Date {
return civil.Date{
Year: d.year,
Month: gotime.Month(d.month),
Day: d.day,
}
}
func (d *date) ToString() string {
separator := "-"
if !d.format.UseDashes {
separator = "/"
}
return fmt.Sprintf("%04d%s%02d%s%02d", d.year, separator, d.month, separator, d.day)
}
func (d *date) Year() int {
return d.year
}
func (d *date) Month() int {
return d.month
}
func (d *date) Day() int {
return d.day
}
func (d *date) Weekday() int {
x := int(date2Civil(d).In(gotime.UTC).Weekday())
if x == 0 {
return 7
}
return x
}
func (d *date) Quarter() int {
quarter := math.Ceil(float64(d.Month()) / 3)
return int(quarter)
}
func (d *date) WeekNumber() (int, int) {
return date2Civil(d).In(gotime.UTC).ISOWeek()
}
func (d *date) IsEqualTo(otherDate Date) bool {
return d.Year() == otherDate.Year() && d.Month() == otherDate.Month() && d.Day() == otherDate.Day()
}
func (d *date) IsAfterOrEqual(otherDate Date) bool {
if d.Year() != otherDate.Year() {
return d.Year() >= otherDate.Year()
}
if d.Month() != otherDate.Month() {
return d.Month() >= otherDate.Month()
}
return d.Day() >= otherDate.Day()
}
func (d *date) PlusDays(dayIncrement int) Date {
cd := date2Civil(d).AddDays(dayIncrement)
newDate, err := civil2Date(cd, d.format)
if err != nil {
panic(err)
}
return newDate
}
func (d *date) ToStringWithFormat(f DateFormat) string {
nDate := *d
nDate.format = f
return nDate.ToString()
}
func (d *date) Format() DateFormat {
return d.format
}
07070100000068000081A40000000000000000000000016863F92F00001702000000000000000000000000000000000000001B00000000klog-6.6/klog/date_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestRecognisesValidDate(t *testing.T) {
d, err := NewDate(2005, 4, 15)
assert.Nil(t, err)
assert.Equal(t, 2005, d.Year())
assert.Equal(t, 4, d.Month())
assert.Equal(t, 15, d.Day())
assert.Equal(t, 2, d.Quarter())
year, week := d.WeekNumber()
assert.Equal(t, 2005, year)
assert.Equal(t, 15, week)
}
func TestBoundaries(t *testing.T) {
_, firstErr := NewDate(0000, 01, 01)
assert.Nil(t, firstErr)
_, lastErr := NewDate(9999, 12, 31)
assert.Nil(t, lastErr)
}
func TestReconWithDate(t *testing.T) {
d, _ := NewDate(2005, 12, 31)
assert.Equal(t, Ɀ_Date_(2006, 1, 1), d.PlusDays(1))
assert.Equal(t, Ɀ_Date_(2006, 2, 1), d.PlusDays(32))
assert.Equal(t, Ɀ_Date_(2005, 12, 30), d.PlusDays(-1))
}
func TestPlusDaysAccountsForLeapYear(t *testing.T) {
d, _ := NewDate(2020, 2, 28)
assert.Equal(t, Ɀ_Date_(2020, 2, 29), d.PlusDays(1))
}
func TestDetectsUnrepresentableDates(t *testing.T) {
for _, dateProvider := range []func() (Date, error){
func() (Date, error) { return NewDate(2005, 13, 15) }, // Month too large
func() (Date, error) { return NewDate(2005, 0, 15) }, // Month too small
func() (Date, error) { return NewDate(2005, -1, 15) }, // Month too small
func() (Date, error) { return NewDate(2005, 1, 32) }, // Day too big
func() (Date, error) { return NewDate(2005, 2, 30) }, // Day too big
func() (Date, error) { return NewDate(2005, 2, 0) }, // Day too small
func() (Date, error) { return NewDate(2005, 2, -1) }, // Day too small
func() (Date, error) { return NewDate(10000, 2, 30) }, // Year too big
func() (Date, error) { return NewDate(-1, 2, 30) }, // Year too small
} {
invalidDate, err := dateProvider()
assert.Nil(t, invalidDate)
assert.EqualError(t, err, "UNREPRESENTABLE_DATE")
}
}
func TestSerialiseDate(t *testing.T) {
d := Ɀ_Date_(2005, 12, 31)
assert.Equal(t, "2005-12-31", d.ToString())
assert.Equal(t, DateFormat{UseDashes: true}, d.Format())
assert.Equal(t, "2005-12-31", d.ToStringWithFormat(DateFormat{UseDashes: true}))
assert.Equal(t, "2005/12/31", d.ToStringWithFormat(DateFormat{UseDashes: false}))
}
func TestSerialiseDatePadsLeadingZeros(t *testing.T) {
d := Ɀ_Date_(2005, 3, 9)
assert.Equal(t, "2005-03-09", d.ToString())
}
func TestParseDateWithDashes(t *testing.T) {
d, err := NewDateFromString("1856-10-22")
assert.Nil(t, err)
should, _ := NewDate(1856, 10, 22)
assert.Equal(t, d, should)
assert.Equal(t, DateFormat{UseDashes: true}, should.Format())
}
func TestEquality(t *testing.T) {
a := Ɀ_Date_(2005, 1, 1)
b := Ɀ_Date_(2005, 1, 1)
c := Ɀ_Date_(1982, 12, 31)
assert.True(t, a.IsEqualTo(b))
assert.False(t, a.IsEqualTo(c))
assert.False(t, b.IsEqualTo(c))
}
func TestComparison(t *testing.T) {
a := Ɀ_Date_(2005, 3, 15)
b := Ɀ_Date_(2005, 3, 15)
c := Ɀ_Date_(2005, 3, 16)
d := Ɀ_Date_(2004, 3, 16)
e := Ɀ_Date_(2005, 4, 1)
assert.True(t, b.IsAfterOrEqual(a))
assert.True(t, c.IsAfterOrEqual(a))
assert.True(t, a.IsAfterOrEqual(d))
assert.True(t, e.IsAfterOrEqual(c))
}
func TestParseDateWithSlashes(t *testing.T) {
original := "1856/10/22"
d, err := NewDateFromString(original)
assert.Nil(t, err)
should, _ := NewDate(1856, 10, 22)
assert.True(t, should.IsEqualTo(d))
assert.Equal(t, original, d.ToString())
assert.Equal(t, DateFormat{UseDashes: false}, d.Format())
}
func TestParseDateFailsIfMalformed(t *testing.T) {
for _, s := range []string{
"1856-1-2",
"1856/01-02",
"20-12-12",
"asdf",
"01.01.2000",
"⠃⠚⠚⠚-⠁⠃-⠚⠛", // Braille digits
"二〇〇〇-一二-〇四", // Japanese digits
"᠒᠐᠐᠐-᠑᠒-᠐᠗", // Mongolean digits
} {
d, err := NewDateFromString(s)
assert.Nil(t, d)
assert.EqualError(t, err, "MALFORMED_DATE")
}
}
func TestCalculateWeekday(t *testing.T) {
for _, d := range []struct {
d Date
w int
}{
{Ɀ_Date_(2021, 01, 15), 5},
{Ɀ_Date_(2021, 01, 16), 6},
{Ɀ_Date_(2021, 01, 17), 7}, // Sunday
{Ɀ_Date_(2021, 01, 18), 1},
{Ɀ_Date_(2021, 01, 19), 2},
{Ɀ_Date_(2021, 01, 20), 3},
{Ɀ_Date_(2021, 01, 21), 4},
{Ɀ_Date_(2021, 01, 22), 5},
} {
assert.Equal(t, d.w, d.d.Weekday())
}
}
func TestCalculateQuarter(t *testing.T) {
assert.Equal(t, 1, Ɀ_Date_(2021, 1, 1).Quarter())
assert.Equal(t, 1, Ɀ_Date_(2021, 2, 12).Quarter())
assert.Equal(t, 1, Ɀ_Date_(2021, 3, 31).Quarter())
assert.Equal(t, 2, Ɀ_Date_(2021, 4, 1).Quarter())
assert.Equal(t, 2, Ɀ_Date_(2021, 4, 4).Quarter())
assert.Equal(t, 2, Ɀ_Date_(2021, 6, 30).Quarter())
assert.Equal(t, 3, Ɀ_Date_(2021, 7, 1).Quarter())
assert.Equal(t, 3, Ɀ_Date_(2021, 7, 22).Quarter())
assert.Equal(t, 3, Ɀ_Date_(2021, 9, 30).Quarter())
assert.Equal(t, 4, Ɀ_Date_(2021, 10, 1).Quarter())
assert.Equal(t, 4, Ɀ_Date_(2021, 12, 2).Quarter())
assert.Equal(t, 4, Ɀ_Date_(2021, 12, 31).Quarter())
}
func TestCalculateWeekNumber(t *testing.T) {
{
year, week := Ɀ_Date_(2021, 1, 1).WeekNumber()
assert.Equal(t, 2020, year)
assert.Equal(t, 53, week)
}
{
year, week := Ɀ_Date_(2021, 1, 3).WeekNumber()
assert.Equal(t, 2020, year)
assert.Equal(t, 53, week)
}
{
year, week := Ɀ_Date_(2021, 1, 4).WeekNumber()
assert.Equal(t, 2021, year)
assert.Equal(t, 1, week)
}
{
year, week := Ɀ_Date_(2021, 1, 10).WeekNumber()
assert.Equal(t, 2021, year)
assert.Equal(t, 1, week)
}
{
year, week := Ɀ_Date_(2021, 1, 11).WeekNumber()
assert.Equal(t, 2021, year)
assert.Equal(t, 2, week)
}
{
year, week := Ɀ_Date_(2021, 8, 17).WeekNumber()
assert.Equal(t, 2021, year)
assert.Equal(t, 33, week)
}
{
year, week := Ɀ_Date_(2021, 12, 31).WeekNumber()
assert.Equal(t, 2021, year)
assert.Equal(t, 52, week)
}
{
year, week := Ɀ_Date_(2022, 1, 1).WeekNumber()
assert.Equal(t, 2021, year)
assert.Equal(t, 52, week)
}
}
07070100000069000081A40000000000000000000000016863F92F00000F5C000000000000000000000000000000000000001A00000000klog-6.6/klog/duration.gopackage klog
import (
"errors"
"fmt"
"regexp"
"strconv"
"github.com/jotaen/safemath/safemath"
)
// Duration represents a time span.
type Duration interface {
InMinutes() int
// Plus adds up two durations and returns a new duration.
// It doesn’t alter the original duration object.
Plus(Duration) Duration
// Minus subtracts the second from the first duration.
// It doesn’t alter the original duration object.
Minus(Duration) Duration
// ToString serialises the duration. If the duration is negative,
// the value is preceded by a `-`. E.g. `45m` or `-2h15m`.
ToString() string
// ToStringWithSign serialises the duration. In contrast to `ToString`
// it also precedes positive values with a `+`. If the duration is `0`,
// no sign will be added. E.g. `-45m` or `0` or `+6h`.
ToStringWithSign() string
}
// DurationFormat contains the formatting options for a Duration.
type DurationFormat struct {
// ForcePlus indicates whether to enforce a `+` for positive values (including 0)
ForcePlus bool
// ZeroSign indicates what sign a value of `0` should have:
// `0` means no sign (default), `1` means `+`, `-1` means `-`.
ZeroSign int
}
// DefaultDurationFormat returns the canonical duration format, as recommended by the spec.
func DefaultDurationFormat() DurationFormat {
return DurationFormat{ForcePlus: false, ZeroSign: 0}
}
func NewDuration(amountHours int, amountMinutes int) Duration {
return NewDurationWithFormat(amountHours, amountMinutes, DefaultDurationFormat())
}
func NewDurationWithFormat(amountHours int, amountMinutes int, format DurationFormat) Duration {
hoursToMins, err1 := safemath.Multiply(amountHours, 60)
totalMins, err2 := safemath.Add(hoursToMins, amountMinutes)
if err1 != nil || err2 != nil {
panic("Integer overflow")
}
return &duration{minutes: totalMins, format: format}
}
type duration struct {
minutes int
format DurationFormat
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func (d duration) InMinutes() int {
return d.minutes
}
func (d duration) Plus(additional Duration) Duration {
mins, err := safemath.Add(d.InMinutes(), additional.InMinutes())
if err != nil {
panic("Integer overflow")
}
return NewDuration(0, mins)
}
func (d duration) Minus(deductible Duration) Duration {
return d.Plus(NewDuration(0, deductible.InMinutes()*-1))
}
func (d duration) ToString() string {
if d.minutes == 0 {
sign := ""
if d.format.ZeroSign < 0 {
sign = "-"
} else if d.format.ZeroSign > 0 {
sign = "+"
}
return sign + "0m"
}
hours := abs(d.minutes / 60)
minutes := abs(d.minutes % 60)
result := ""
if d.minutes < 0 {
result += "-"
} else if d.format.ForcePlus {
result += "+"
}
if hours > 0 {
result += fmt.Sprintf("%dh", hours)
}
if minutes > 0 {
result += fmt.Sprintf("%dm", minutes)
}
return result
}
func (d duration) ToStringWithSign() string {
s := d.ToString()
if d.minutes > 0 {
return "+" + s
}
return s
}
var durationPattern = regexp.MustCompile(`^([-+])?((\d+)h)?((\d+)m)?$`)
func NewDurationFromString(hhmm string) (Duration, error) {
match := durationPattern.FindStringSubmatch(hhmm)
if match == nil {
return nil, errors.New("MALFORMED_DURATION")
}
sign := 1
if match[1] == "-" {
sign = -1
}
format := DefaultDurationFormat()
if match[1] == "+" {
format.ForcePlus = true
}
if match[3] == "" && match[5] == "" {
return nil, errors.New("MALFORMED_DURATION")
}
amountOfHours, a1Err := strconv.Atoi(match[3])
if match[3] != "" && a1Err != nil {
panic(a1Err)
}
amountOfMinutes, a2Err := strconv.Atoi(match[5])
if match[5] != "" && a2Err != nil {
panic(a2Err)
}
if match[3] != "" && amountOfMinutes >= 60 {
return nil, errors.New("UNREPRESENTABLE_DURATION")
}
if amountOfHours == 0 && amountOfMinutes == 0 && match[1] != "" {
format.ZeroSign = sign
}
return NewDurationWithFormat(sign*amountOfHours, sign*amountOfMinutes, format), nil
}
0707010000006A000081A40000000000000000000000016863F92F00001B52000000000000000000000000000000000000001F00000000klog-6.6/klog/duration_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestSerialiseDurationOnlyWithMeaningfulValues(t *testing.T) {
assert.Equal(t, "0m", NewDuration(0, 0).ToString())
assert.Equal(t, "1m", NewDuration(0, 1).ToString())
assert.Equal(t, "34m", NewDuration(0, 34).ToString())
assert.Equal(t, "59m", NewDuration(0, 59).ToString())
assert.Equal(t, "1h", NewDuration(1, 0).ToString())
assert.Equal(t, "15h", NewDuration(15, 0).ToString())
assert.Equal(t, "15h3m", NewDuration(15, 3).ToString())
assert.Equal(t, "265h45m", NewDuration(265, 45).ToString())
assert.Equal(t, "4716278h48m", NewDuration(4716278, 48).ToString())
assert.Equal(t, "153722867280912930h7m", NewDuration(0, 9223372036854775807).ToString())
}
func TestSerialiseDurationWithoutLeadingZeros(t *testing.T) {
assert.Equal(t, "2h6m", NewDuration(2, 6).ToString())
}
func TestSerialiseDurationOfNegativeValues(t *testing.T) {
assert.Equal(t, "-2h4m", NewDuration(-2, -4).ToString())
assert.Equal(t, "-3h18m", NewDuration(-3, -18).ToString())
assert.Equal(t, "-812747h", NewDuration(-812747, 0).ToString())
assert.Equal(t, "-18m", NewDuration(0, -18).ToString())
assert.Equal(t, "-153722867280912930h7m", NewDuration(0, -9223372036854775807).ToString())
}
func TestSerialiseDurationWithSign(t *testing.T) {
// Zero is neutral by default:
assert.Equal(t, "0m", NewDuration(0, 0).ToStringWithSign())
// Positive values:
assert.Equal(t, "+3h18m", NewDuration(3, 18).ToStringWithSign())
assert.Equal(t, "+3h", NewDuration(3, 0).ToStringWithSign())
assert.Equal(t, "+18m", NewDuration(0, 18).ToStringWithSign())
// Negative values:
assert.Equal(t, "-3h18m", NewDuration(-3, -18).ToStringWithSign())
assert.Equal(t, "-3h", NewDuration(-3, 0).ToStringWithSign())
assert.Equal(t, "-18m", NewDuration(0, -18).ToStringWithSign())
}
func TestSerialisePreservesOriginalFormatting(t *testing.T) {
for _, x := range []string{
"0m",
"+0m",
"-0m",
"15m",
"+15m",
"-15m",
} {
neutralZero, _ := NewDurationFromString(x)
assert.Equal(t, x, neutralZero.ToString())
}
}
func TestNormaliseDurationsWhenSerialising(t *testing.T) {
assert.Equal(t, "2h", NewDuration(0, 120).ToString())
assert.Equal(t, "2h30m", NewDuration(0, 150).ToString())
d, _ := NewDurationFromString("120m")
assert.Equal(t, "2h", d.ToString())
}
func TestParsingDurationWithHoursAndMinutes(t *testing.T) {
d, err := NewDurationFromString("2h6m")
assert.Nil(t, err)
assert.Equal(t, NewDuration(2, 6), d)
}
func TestParsingDurationWithHourValueOnly(t *testing.T) {
for _, d := range []struct {
text string
expect Duration
}{
{"0h", NewDuration(0, 0)},
{"1h", NewDuration(1, 0)},
{"13h", NewDuration(13, 0)},
{"9882187612h", NewDuration(9882187612, 0)},
{"13h0m", NewDuration(13, 0)},
} {
duration, err := NewDurationFromString(d.text)
assert.Nil(t, err)
assert.Equal(t, d.expect, duration)
}
}
func TestParsingDurationWithMinuteValueOnly(t *testing.T) {
for _, d := range []struct {
text string
expect Duration
}{
{"1m", NewDuration(0, 1)},
{"48m", NewDuration(0, 48)},
{"59m", NewDuration(0, 59)},
{"0h48m", NewDuration(0, 48)},
// Minutes >60 are okay if there is no hour part present
{"60m", NewDuration(1, 0)},
{"120m", NewDuration(2, 0)},
{"568721940327m", NewDuration(0, 568721940327)},
} {
duration, err := NewDurationFromString(d.text)
assert.Nil(t, err)
assert.Equal(t, d.expect, duration)
}
}
func TestParsingNegativeDuration(t *testing.T) {
duration, err := NewDurationFromString("-2h5m")
assert.Nil(t, err)
assert.Equal(t, NewDuration(-2, -5), duration)
}
func TestParsingExplicitlyPositiveDuration(t *testing.T) {
duration, err := NewDurationFromString("+2h5m")
assert.Nil(t, err)
assert.Equal(t, NewDurationWithFormat(2, 5, DurationFormat{ForcePlus: true}), duration)
assert.Equal(t, "+2h5m", duration.ToString())
}
func TestParsingWithLeadingZeros(t *testing.T) {
for _, d := range []string{
"000009h00000000001m",
"9h001m",
"09h1m",
} {
duration, err := NewDurationFromString(d)
assert.Nil(t, err)
assert.Equal(t, NewDuration(9, 1), duration)
}
}
func TestParsingFailsWithInvalidValue(t *testing.T) {
for _, d := range []string{
"",
"1h 11m",
"asdf",
"6h asdf",
"qwer 30m",
"⠙⠛m", // Braille digits
"四二h", // Japanese digits
"᠒h᠐᠒m", // Mongolean digits
} {
duration, err := NewDurationFromString(d)
assert.EqualError(t, err, "MALFORMED_DURATION")
assert.Equal(t, nil, duration)
}
}
func TestParsingFailsWithMinutesGreaterThan60WhenHourPartPresent(t *testing.T) {
for _, d := range []string{
"1h60m",
"0h60m",
"8h1653m",
"-8h1653m",
} {
duration, err := NewDurationFromString(d)
assert.EqualError(t, err, "UNREPRESENTABLE_DURATION")
assert.Equal(t, nil, duration)
}
}
func TestParsingDurationWithMaxValue(t *testing.T) {
t.Run("max", func(t *testing.T) {
d, err := NewDurationFromString("9223372036854775807m")
require.Nil(t, err)
assert.Equal(t, NewDuration(0, 9223372036854775807), d)
})
t.Run("max", func(t *testing.T) {
d, err := NewDurationFromString("153722867280912930h7m")
require.Nil(t, err)
assert.Equal(t, NewDuration(153722867280912930, 7), d)
})
t.Run("min", func(t *testing.T) {
d, err := NewDurationFromString("-9223372036854775807m")
require.Nil(t, err)
assert.Equal(t, NewDuration(0, -9223372036854775807), d)
})
t.Run("max", func(t *testing.T) {
d, err := NewDurationFromString("-153722867280912930h7m")
require.Nil(t, err)
assert.Equal(t, NewDuration(-153722867280912930, -7), d)
})
}
func TestParsingDurationTooBigToRepresent(t *testing.T) {
for _, d := range []string{
"9223372036854775808m",
"-9223372036854775808m",
"9223372036854775808h",
"-9223372036854775808h",
"153722867280912930h08m",
"-153722867280912930h08m",
} {
assert.Panics(t, func() {
_, _ = NewDurationFromString(d)
}, d)
}
}
func TestDurationPlusMinus(t *testing.T) {
for _, d := range []struct {
sum Duration
expect int
}{
{NewDuration(0, 0).Plus(NewDuration(0, 0)), 0},
{NewDuration(0, 0).Plus(NewDuration(0, 1)), 1},
{NewDuration(0, 0).Plus(NewDuration(1, 2)), 62},
{NewDuration(1382, 9278).Plus(NewDuration(4718, 5010)), 380288},
{NewDuration(0, 9223372036854775806).Plus(NewDuration(0, 1)), 9223372036854775807},
{NewDuration(0, 0).Plus(NewDuration(0, -9223372036854775807)), -9223372036854775807},
{NewDuration(0, 0).Minus(NewDuration(0, 0)), 0},
{NewDuration(0, 0).Minus(NewDuration(0, 1)), -1},
{NewDuration(0, 0).Minus(NewDuration(1, 2)), -62},
{NewDuration(1382, 9278).Minus(NewDuration(4718, 5010)), -195892},
} {
assert.Equal(t, d.sum.InMinutes(), d.expect)
}
}
func TestPanicsIfAdditionOverflows(t *testing.T) {
assert.Panics(t, func() {
NewDuration(0, 9223372036854775807).Plus(NewDuration(0, 1))
})
assert.Panics(t, func() {
NewDuration(0, -9223372036854775807).Plus(NewDuration(0, -1))
})
}
0707010000006B000081A40000000000000000000000016863F92F000004B9000000000000000000000000000000000000001700000000klog-6.6/klog/entry.gopackage klog
// Entry is a time value and an associated entry summary.
// A time value can be a Range, a Duration, or an OpenRange.
type Entry struct {
value any
summary EntrySummary
}
func NewEntryFromDuration(value Duration, summary EntrySummary) Entry {
return Entry{value, summary}
}
func NewEntryFromRange(value Range, summary EntrySummary) Entry {
return Entry{value, summary}
}
func NewEntryFromOpenRange(value OpenRange, summary EntrySummary) Entry {
return Entry{value, summary}
}
func (e *Entry) Summary() EntrySummary {
return e.summary
}
// Unbox converts the underlying time value.
func Unbox[TargetT any](e *Entry, r func(Range) TargetT, d func(Duration) TargetT, o func(OpenRange) TargetT) TargetT {
switch x := e.value.(type) {
case Range:
return r(x)
case Duration:
return d(x)
case OpenRange:
return o(x)
}
panic("Incomplete switch statement")
}
// Duration returns the duration value of the underlying time value.
func (e *Entry) Duration() Duration {
return Unbox[Duration](e,
func(r Range) Duration { return r.Duration() },
func(d Duration) Duration { return NewDuration(0, d.InMinutes()) },
func(o OpenRange) Duration { return NewDuration(0, 0) },
)
}
0707010000006C000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001500000000klog-6.6/klog/parser0707010000006D000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001C00000000klog-6.6/klog/parser/engine0707010000006E000081A40000000000000000000000016863F92F00000530000000000000000000000000000000000000001F00000000klog-6.6/klog/parser/engine.gopackage parser
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser/engine"
"github.com/jotaen/klog/klog/parser/txt"
)
// Parser parses a text into a list of Record datastructures. On success, it returns
// the parsed records. Otherwise, it returns all encountered parser errors.
type Parser interface {
// Parse parses records from a string. It returns them along with the
// respective blocks. Those two arrays have the same length.
// Errors are reported via the last error array. In this case, the records
// and blocks are nil. Note, one record can produce multiple errors,
// so the length of the error array doesn’t say anything about the number
// of records.
Parse(string) ([]klog.Record, []txt.Block, []txt.Error)
}
// NewSerialParser returns a new parser, which processes the input text
// serially, i.e. one after the other.
func NewSerialParser() Parser {
return serialParser
}
// NewParallelParser returns a new parser, which processes the input text
// in parallel. The parsing result is the same as with the serial parser.
func NewParallelParser(numberOfWorkers int) Parser {
return engine.ParallelBatchParser[klog.Record]{
SerialParser: serialParser,
NumberOfWorkers: numberOfWorkers,
}
}
var serialParser = engine.SerialParser[klog.Record]{
ParseOne: parse,
}
0707010000006F000081A40000000000000000000000016863F92F0000109F000000000000000000000000000000000000002800000000klog-6.6/klog/parser/engine/parallel.gopackage engine
import (
"github.com/jotaen/klog/klog/parser/txt"
"math"
"sync"
"unicode/utf8"
)
type ParallelBatchParser[T any] struct {
SerialParser[T]
NumberOfWorkers int
}
type batchResult[T any] struct {
index int
headText string
values []T
tailText string
blocks []txt.Block
errs [][]txt.Error
}
func (p ParallelBatchParser[T]) Parse(text string) ([]T, []txt.Block, []txt.Error) {
if p.NumberOfWorkers <= 0 {
panic("Illegal number of workers")
}
batches := splitIntoChunks(text, p.NumberOfWorkers)
allResults := p.processAsync(batches, func(batchIndex int, batchText string) batchResult[T] {
result := batchResult[T]{batchIndex, "", nil, "", nil, nil}
if len(batchText) == 0 {
return result
}
_, headBytesConsumed := txt.ParseBlock(batchText, 1)
result.headText = batchText[:headBytesConsumed]
if len(batchText) == headBytesConsumed { // The entire batchText was a single block
return result
}
batchText = batchText[headBytesConsumed:]
values, blocks, bytesConsumed, errs, _ := p.SerialParser.mapParse(batchText)
if len(blocks) == 0 { // The remainder was empty or all blank
result.tailText = batchText
} else { // The remainder was more than one block
result.values = values[:len(values)-1]
result.blocks = blocks[:len(blocks)-1]
result.errs = errs[:len(errs)-1]
result.tailText = batchText[bytesConsumed-countBytes(blocks[len(blocks)-1]):]
}
return result
})
// Process remainders and flatten results.
var allValues []T
var allBlocks []txt.Block
var allErrs []txt.Error
carryText := ""
for _, result := range allResults {
carryText += result.headText
if len(result.blocks) > 0 {
carryValues, carryBlocks, _, carryErrs, hasErrors := p.SerialParser.mapParse(carryText)
allValues = append(allValues, carryValues...)
allBlocks = append(allBlocks, carryBlocks...)
if hasErrors {
allErrs = append(allErrs, flatten(carryErrs)...)
}
carryText = ""
allValues = append(allValues, result.values...)
allBlocks = append(allBlocks, result.blocks...)
allErrs = append(allErrs, flatten(result.errs)...)
}
carryText += result.tailText
}
carryValues, carryBlocks, _, carryErrs, hasErrors := p.SerialParser.mapParse(carryText)
allValues = append(allValues, carryValues...)
allBlocks = append(allBlocks, carryBlocks...)
lineCount := 0
for _, b := range allBlocks {
b.SetPrecedingLineCount(lineCount)
lineCount += len(b.Lines())
}
if hasErrors {
allErrs = append(allErrs, flatten(carryErrs)...)
}
if len(allErrs) > 0 {
return nil, nil, allErrs
}
return allValues, allBlocks, nil
}
func (p ParallelBatchParser[T]) processAsync(batches []string, work func(int, string) batchResult[T]) []batchResult[T] {
wg := &sync.WaitGroup{}
wg.Add(len(batches))
resultChannel := make(chan batchResult[T])
for i, b := range batches {
go func(batchIndex int, batchText string) {
defer wg.Done()
result := work(batchIndex, batchText)
resultChannel <- result
}(i, b)
}
// Wait for workers to finish.
go func() {
wg.Wait()
close(resultChannel)
}()
// Collect results.
allResults := make([]batchResult[T], len(batches))
for result := range resultChannel {
allResults[result.index] = result
}
return allResults
}
// splitIntoChunks divides a string into n substrings of roughly equal byte-size
// (not character-count). The chunk’s byte size might differ slightly: (a) because
// the last chunk contains the remainder, which will probably be smaller, and (b)
// because the chunks are never divided in between UTF-8 code points.
func splitIntoChunks(txt string, numberOfBatches int) []string {
batchByteSize := int(math.Ceil(float64(len(txt)) / float64(numberOfBatches)))
batches := make([]string, numberOfBatches)
pointer := 0
for i := 0; i < numberOfBatches; i++ {
nextPointer := pointer + batchByteSize
for nextPointer < len(txt) && !utf8.RuneStart(txt[nextPointer]) {
nextPointer++
}
if nextPointer > len(txt) {
batches[i] = txt[pointer:]
break
} else {
batches[i] = txt[pointer:nextPointer]
}
pointer = nextPointer
}
return batches
}
func countBytes(b txt.Block) int {
result := 0
for _, l := range b.Lines() {
result += len(l.Original())
}
return result
}
07070100000070000081A40000000000000000000000016863F92F00000984000000000000000000000000000000000000002D00000000klog-6.6/klog/parser/engine/parallel_test.gopackage engine
import (
"github.com/jotaen/klog/klog/parser/txt"
"github.com/stretchr/testify/assert"
"testing"
)
var identityParser = ParallelBatchParser[string]{
SerialParser: SerialParser[string]{
ParseOne: func(b txt.Block) (string, []txt.Error) {
original := ""
for _, l := range b.Lines() {
original += l.Original()
}
return original, nil
},
},
NumberOfWorkers: 100,
}
func TestParallelParserDoesNotMessUpBatchOrder(t *testing.T) {
// The mock parser has 100 workers, so the batch size will be 1 char per worker.
// The serial parser is basically an identity function, so it returns the input
// text of the block, i.e. that one char per worker. The parallel parser is now
// expected to re-construct the original order of the input after batching.
// If it wouldn’t do that, the return text would be messed up, e.g. `7369285014`
// instead of `1234567890`.
val, _, _ := identityParser.Parse("1234567890")
assert.Equal(t, []string{"1234567890"}, val)
}
func TestParallelParser(t *testing.T) {
for _, x := range []struct {
txt string
chunks int
exp []string
}{
// Small ASCII strings:
{"Hello", 1, []string{"Hello"}},
{"Hello", 2, []string{"Hel", "lo"}},
{"Hello", 3, []string{"He", "ll", "o"}},
{"Hello", 4, []string{"He", "ll", "o", ""}},
{"Hello", 5, []string{"H", "e", "l", "l", "o"}},
{"Hello", 6, []string{"H", "e", "l", "l", "o", ""}},
{"Hello", 8, []string{"H", "e", "l", "l", "o", "", "", ""}},
// Larger ASCII strings:
{"abcdefghijklmnopqrstuvwxyz", 3, []string{"abcdefghi", "jklmnopqr", "stuvwxyz"}},
{"abcdefghijklmnopqrstuvwxyz", 13, []string{"ab", "cd", "ef", "gh", "ij", "kl", "mn", "op", "qr", "st", "uv", "wx", "yz"}},
// UTF-8 strings: (reminder: the chunks are supposed to have similar byte-size, not character-count!)
{"藤本太郎喜左衛門将時能", 4, []string{"藤本太", "郎喜左", "衛門将", "時能"}},
{"藤本太郎喜左衛門将時能", 11, []string{"藤", "本", "太", "郎", "喜", "左", "衛", "門", "将", "時", "能"}},
{"藤😀abcdef©½, ★Test🤡äß©•¥üöπგამარჯობა", 3, []string{"藤😀abcdef©½, ★Tes", "t🤡äß©•¥üöπგ", "ამარჯობა"}},
} {
chunks := splitIntoChunks(x.txt, x.chunks)
assert.Equal(t, x.exp, chunks)
val, _, errs := identityParser.Parse(x.txt)
assert.Nil(t, errs)
assert.Equal(t, []string{x.txt}, val)
}
}
07070100000071000081A40000000000000000000000016863F92F0000051D000000000000000000000000000000000000002600000000klog-6.6/klog/parser/engine/serial.gopackage engine
import "github.com/jotaen/klog/klog/parser/txt"
type SerialParser[T any] struct {
ParseOne func(txt.Block) (T, []txt.Error)
}
func (p SerialParser[T]) Parse(text string) ([]T, []txt.Block, []txt.Error) {
ts, blocks, _, errs, hasErrors := p.mapParse(text)
if hasErrors {
return nil, nil, flatten[txt.Error](errs)
}
return ts, blocks, nil
}
// mapParse parses the text. All 3 return arrays have the same arity, and the last
// bool indicates whether any errors occurred.
func (p SerialParser[T]) mapParse(text string) ([]T, []txt.Block, int, [][]txt.Error, bool) {
var ts []T
var blocks []txt.Block
var errs [][]txt.Error
totalBytesConsumed := 0
totalLines := 0
hasErrors := false
for {
block, bytesConsumed := txt.ParseBlock(text[totalBytesConsumed:], totalLines)
if bytesConsumed == 0 || block == nil {
break
}
totalLines += len(block.Lines())
totalBytesConsumed += bytesConsumed
t, err := p.ParseOne(block)
ts = append(ts, t)
blocks = append(blocks, block)
errs = append(errs, err)
if err != nil {
hasErrors = true
}
}
return ts, blocks, totalBytesConsumed, errs, hasErrors
}
func flatten[T any](xss [][]T) []T {
var result []T
for _, xs := range xss {
if len(xs) == 0 {
continue
}
result = append(result, xs...)
}
return result
}
07070100000072000081A40000000000000000000000016863F92F00000BFF000000000000000000000000000000000000001E00000000klog-6.6/klog/parser/error.gopackage parser
import "github.com/jotaen/klog/klog/parser/txt"
type HumanError struct {
code string
title string
details string
}
func (e HumanError) New(b txt.Block, line int, start int, length int) txt.Error {
return txt.NewError(b, line, start, length, e.code, e.title, e.details)
}
func ErrorInvalidDate() HumanError {
return HumanError{
"ErrorInvalidDate",
"Invalid date",
"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.",
}
}
func ErrorIllegalIndentation() HumanError {
return HumanError{
"ErrorIllegalIndentation",
"Unexpected indentation",
"Please correct the indentation of this line. Indentation must be 2-4 spaces or one tab. " +
"You cannot mix different indentation styles within the same record.",
}
}
func ErrorMalformedShouldTotal() HumanError {
return HumanError{
"ErrorMalformedShouldTotal",
"Malformed should-total time",
"Please review the syntax of the should-total time. " +
"Valid examples for it would be: (8h!) or (4h30m!) or (45m!)",
}
}
func ErrorUnrecognisedProperty() HumanError {
return HumanError{
"ErrorUnrecognisedProperty",
"Unrecognised should-total value",
"The highlighted value is not recognised. " +
"The should-total must be a time duration suffixed with an " +
"exclamation mark, e.g. 5h15m! or 8h!",
}
}
func ErrorMalformedPropertiesSyntax() HumanError {
return HumanError{
"ErrorMalformedPropertiesSyntax",
"Malformed should-total time",
"The should-total cannot be empty and it must be " +
"surrounded by parenthesis on both sides",
}
}
func ErrorUnrecognisedTextInHeadline() HumanError {
return HumanError{
"ErrorUnrecognisedTextInHeadline",
"Malformed headline",
"The highlighted text in the headline is not recognised. " +
"Please make sure to surround the should-total with parentheses, e.g.: (5h!) " +
"You generally cannot put arbitrary text into the headline.",
}
}
func ErrorMalformedSummary() HumanError {
return HumanError{
"ErrorMalformedSummary",
"Malformed summary",
"Summary lines cannot start with blank characters, such as non-breaking spaces.",
}
}
func ErrorMalformedEntry() HumanError {
return HumanError{
"ErrorMalformedEntry",
"Malformed entry",
"Please review the syntax of the entry. " +
"It must start with a duration or a time range. " +
"Valid examples would be: 3h20m or 8:00-10:00 or 8:00-? " +
"or <23:00-6:00 or 18:00-0:30>",
}
}
func ErrorDuplicateOpenRange() HumanError {
return HumanError{
"ErrorDuplicateOpenRange",
"Duplicate entry",
"Please make sure that there is only " +
"one open (unclosed) time range in this record.",
}
}
func ErrorIllegalRange() HumanError {
return HumanError{
"ErrorIllegalRange",
"Invalid date range",
"Please make sure that both time values appear in chronological order. " +
"If you want a time to be associated with an adjacent day you can use angle brackets " +
"to shift the time by one day: <23:00-6:00 or 18:00-0:30>",
}
}
07070100000073000081A40000000000000000000000016863F92F00000171000000000000000000000000000000000000002300000000klog-6.6/klog/parser/error_test.gopackage parser
import (
"github.com/jotaen/klog/klog/parser/txt"
)
type errData struct {
id string
lineNr int
pos int
len int
}
func (e HumanError) toErrData(lineNr int, pos int, len int) errData {
return errData{e.code, lineNr, pos, len}
}
func toErrData(e txt.Error) errData {
return errData{e.Code(), e.LineNumber(), e.Position(), e.Length()}
}
07070100000074000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001A00000000klog-6.6/klog/parser/json07070100000075000081A40000000000000000000000016863F92F00000C68000000000000000000000000000000000000002800000000klog-6.6/klog/parser/json/serialiser.go/*
Package json contains the logic of serialising Record’s as JSON.
*/
package json
import (
"bytes"
"encoding/json"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/txt"
"github.com/jotaen/klog/klog/service"
"sort"
"strings"
)
// ToJson serialises records into their JSON representation. The output
// structure is RecordView at the top level.
func ToJson(rs []klog.Record, errs []txt.Error, prettyPrint bool) string {
envelop := func() Envelop {
if errs == nil {
return Envelop{
Records: toRecordViews(rs),
Errors: nil,
}
} else {
return Envelop{
Records: nil,
Errors: toErrorViews(errs),
}
}
}()
buffer := new(bytes.Buffer)
enc := json.NewEncoder(buffer)
if prettyPrint {
enc.SetIndent("", " ")
}
enc.SetEscapeHTML(false)
err := enc.Encode(&envelop)
if err != nil {
panic(err) // This should never happen
}
return strings.TrimRight(buffer.String(), "\n")
}
func toRecordViews(rs []klog.Record) []RecordView {
result := []RecordView{}
for _, r := range rs {
total := service.Total(r)
should := r.ShouldTotal()
diff := service.Diff(should, total)
v := RecordView{
Date: r.Date().ToString(),
Summary: parser.SummaryText(r.Summary()).ToString(),
Total: total.ToString(),
TotalMins: total.InMinutes(),
ShouldTotal: should.ToString(),
ShouldTotalMins: should.InMinutes(),
Diff: diff.ToStringWithSign(),
DiffMins: diff.InMinutes(),
Tags: toTagViews(r.Summary().Tags()),
Entries: toEntryViews(r.Entries()),
}
result = append(result, v)
}
return result
}
func toTagViews(ts *klog.TagSet) []string {
result := ts.ToStrings()
if result == nil {
return []string{}
}
sort.Slice(result, func(i, j int) bool {
return result[i] < result[j]
})
return result
}
func toEntryViews(es []klog.Entry) []any {
views := []any{}
for _, e := range es {
base := EntryView{
Summary: parser.SummaryText(e.Summary()).ToString(),
Tags: toTagViews(e.Summary().Tags()),
Total: e.Duration().ToString(),
TotalMins: e.Duration().InMinutes(),
}
view := klog.Unbox(&e, func(r klog.Range) any {
base.Type = "range"
return RangeView{
OpenRangeView: OpenRangeView{
EntryView: base,
Start: r.Start().ToString(),
StartMins: r.Start().MidnightOffset().InMinutes(),
},
End: r.End().ToString(),
EndMins: r.End().MidnightOffset().InMinutes(),
}
}, func(d klog.Duration) any {
base.Type = "duration"
return base
}, func(o klog.OpenRange) any {
base.Type = "open_range"
return OpenRangeView{
EntryView: base,
Start: o.Start().ToString(),
StartMins: o.Start().MidnightOffset().InMinutes(),
}
})
views = append(views, view)
}
return views
}
func toErrorViews(errs []txt.Error) []ErrorView {
var result []ErrorView
for _, e := range errs {
result = append(result, ErrorView{
Line: e.LineNumber(),
Column: e.Column(),
Length: e.Length(),
Title: e.Title(),
Details: e.Details(),
File: e.Origin(),
})
}
return result
}
07070100000076000081A40000000000000000000000016863F92F00000D15000000000000000000000000000000000000002D00000000klog-6.6/klog/parser/json/serialiser_test.gopackage json
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/txt"
"github.com/stretchr/testify/assert"
"testing"
)
func TestSerialiseEmptyRecords(t *testing.T) {
json := ToJson([]klog.Record{}, nil, false)
assert.Equal(t, `{"records":[],"errors":null}`, json)
}
func TestSerialiseEmptyArrayIfNoErrors(t *testing.T) {
json := ToJson(nil, nil, false)
assert.Equal(t, `{"records":[],"errors":null}`, json)
}
func TestSerialisePrettyPrinted(t *testing.T) {
json := ToJson(nil, nil, true)
assert.Equal(t, `{
"records": [],
"errors": null
}`, json)
}
func TestSerialiseMinimalRecord(t *testing.T) {
json := ToJson(func() []klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2000, 12, 31))
return []klog.Record{r}
}(), nil, false)
assert.Equal(t, `{"records":[{`+
`"date":"2000-12-31",`+
`"summary":"",`+
`"total":"0m",`+
`"total_mins":0,`+
`"should_total":"0m",`+
`"should_total_mins":0,`+
`"diff":"0m",`+
`"diff_mins":0,`+
`"tags":[],`+
`"entries":[]`+
`}],"errors":null}`, json)
}
func TestSerialiseFullBlownRecord(t *testing.T) {
json := ToJson(func() []klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2000, 12, 31))
r.SetSummary(klog.Ɀ_RecordSummary_("Hello #World", "What’s up?"))
r.SetShouldTotal(klog.NewDuration(7, 30))
r.AddDuration(klog.NewDuration(2, 3), klog.Ɀ_EntrySummary_("#some #thing"))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 44), klog.Ɀ_Time_(5, 23)), nil)
r.Start(klog.NewOpenRange(klog.Ɀ_TimeTomorrow_(0, 28)), klog.Ɀ_EntrySummary_("Started #todo=nr4", "still on #it"))
return []klog.Record{r}
}(), nil, false)
assert.Equal(t, `{"records":[{`+
`"date":"2000-12-31",`+
`"summary":"Hello #World\nWhat’s up?",`+
`"total":"7h42m",`+
`"total_mins":462,`+
`"should_total":"7h30m!",`+
`"should_total_mins":450,`+
`"diff":"+12m",`+
`"diff_mins":12,`+
`"tags":["#world"],`+
`"entries":[{`+
`"type":"duration",`+
`"summary":"#some #thing",`+
`"tags":["#some","#thing"],`+
`"total":"2h3m",`+
`"total_mins":123`+
`},{`+
`"type":"range",`+
`"summary":"",`+
`"tags":[],`+
`"total":"5h39m",`+
`"total_mins":339,`+
`"start":"<23:44",`+
`"start_mins":-16,`+
`"end":"5:23",`+
`"end_mins":323`+
`},{`+
`"type":"open_range",`+
`"summary":"Started #todo=nr4\nstill on #it",`+
`"tags":["#it","#todo=nr4"],`+
`"total":"0m",`+
`"total_mins":0,`+
`"start":"0:28>",`+
`"start_mins":1468`+
`}]`+
`}],"errors":null}`, json)
}
func TestSerialiseParserErrors(t *testing.T) {
block, _ := txt.ParseBlock("2018-99-99\n asdf", 6)
json := ToJson(nil, []txt.Error{
parser.ErrorInvalidDate().New(block, 0, 0, 10),
parser.ErrorMalformedSummary().New(block, 1, 3, 5).SetOrigin("/a/b/c/file.klg"),
}, false)
assert.Equal(t, `{"records":null,"errors":[{`+
`"line":7,`+
`"column":1,`+
`"length":10,`+
`"title":"Invalid date",`+
`"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.",`+
`"file":""`+
`},{`+
`"line":8,`+
`"column":4,`+
`"length":5,`+
`"title":"Malformed summary",`+
`"details":"Summary lines cannot start with blank characters, such as non-breaking spaces.",`+
`"file":"/a/b/c/file.klg"`+
`}]}`, json)
}
07070100000077000081A40000000000000000000000016863F92F000006B1000000000000000000000000000000000000002200000000klog-6.6/klog/parser/json/view.gopackage json
// Envelop is the top level data structure of the JSON output.
// It contains two nodes, `records` and `errors`, one of which is always `null`.
type Envelop struct {
Records []RecordView `json:"records"`
Errors []ErrorView `json:"errors"`
}
// RecordView is the JSON representation of a record.
// It also contains some evaluation data, such as the total time.
type RecordView struct {
Date string `json:"date"`
Summary string `json:"summary"`
Total string `json:"total"`
TotalMins int `json:"total_mins"`
ShouldTotal string `json:"should_total"`
ShouldTotalMins int `json:"should_total_mins"`
Diff string `json:"diff"`
DiffMins int `json:"diff_mins"`
Tags []string `json:"tags"`
Entries []any `json:"entries"`
}
// EntryView is the JSON representation of an entry.
type EntryView struct {
// Type is one of `range`, `duration`, or `open_range`.
Type string `json:"type"`
Summary string `json:"summary"`
// Tags is a list of all tags that the entry summary contains.
Tags []string `json:"tags"`
Total string `json:"total"`
TotalMins int `json:"total_mins"`
}
type OpenRangeView struct {
EntryView
Start string `json:"start"`
StartMins int `json:"start_mins"`
}
type RangeView struct {
OpenRangeView
End string `json:"end"`
EndMins int `json:"end_mins"`
}
// ErrorView is the JSON representation of a parsing error.
type ErrorView struct {
Line int `json:"line"`
Column int `json:"column"`
Length int `json:"length"`
Title string `json:"title"`
Details string `json:"details"`
File string `json:"file"`
}
07070100000078000081A40000000000000000000000016863F92F0000247B000000000000000000000000000000000000001F00000000klog-6.6/klog/parser/parser.go/*
Package parser contains the logic how to convert Record objects from and to plain text.
*/
package parser
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser/txt"
)
func parse(block txt.Block) (klog.Record, []txt.Error) {
lines, initialLineOffset, _ := block.SignificantLines()
initialLineCount := len(lines) // Capture current value
nr := func(lines []txt.Line) int {
return initialLineOffset + initialLineCount - len(lines)
}
var errs []txt.Error
// ========== HEADLINE ==========
record := func() klog.Record {
headline := txt.NewParseable(lines[0], 0)
// There is no leading whitespace allowed in the headline.
if txt.IsSpaceOrTab(headline.Peek()) {
errs = append(errs, ErrorIllegalIndentation().New(block, nr(lines), 0, headline.Length()))
return nil
}
// Parse the date.
dateText, _ := headline.PeekUntil(txt.IsSpaceOrTab)
rDate, dErr := klog.NewDateFromString(dateText.ToString())
if dErr != nil {
errs = append(errs, ErrorInvalidDate().New(block, nr(lines), headline.PointerPosition, dateText.Length()))
return nil
}
headline.Advance(dateText.Length())
headline.SkipWhile(txt.IsSpaceOrTab)
r := klog.NewRecord(rDate)
// Check if there is a should-total set, and if so, parse it.
if headline.Peek() == '(' {
headline.Advance(1) // '('
headline.SkipWhile(txt.IsSpaceOrTab)
allPropsText, hasClosingParenthesis := headline.PeekUntil(txt.Is(')'))
if !hasClosingParenthesis {
errs = append(errs, ErrorMalformedPropertiesSyntax().New(block, nr(lines), headline.Length(), 1))
return r
}
if allPropsText.Length() == 0 {
errs = append(errs, ErrorMalformedPropertiesSyntax().New(block, nr(lines), headline.PointerPosition, 1))
return r
}
shouldTotalText, hasExclamationMark := headline.PeekUntil(txt.Is('!'))
if !hasExclamationMark {
errs = append(errs, ErrorUnrecognisedProperty().New(block, nr(lines), headline.PointerPosition, shouldTotalText.Length()-1))
return r
}
shouldTotal, sErr := klog.NewDurationFromString(shouldTotalText.ToString())
if sErr != nil {
errs = append(errs, ErrorMalformedShouldTotal().New(block, nr(lines), headline.PointerPosition, shouldTotalText.Length()))
return r
}
r.SetShouldTotal(shouldTotal)
headline.Advance(shouldTotalText.Length())
headline.Advance(1) // '!'
headline.SkipWhile(txt.IsSpaceOrTab)
// Make sure there is no other text between the braces.
if headline.Peek() != ')' {
errs = append(errs, ErrorUnrecognisedProperty().New(block, nr(lines), headline.PointerPosition, headline.RemainingLength()-1))
return r
}
headline.Advance(1) // ')'
}
// Make sure there is no other text left in the headline.
headline.SkipWhile(txt.IsSpaceOrTab)
if headline.RemainingLength() > 0 {
errs = append(errs, ErrorUnrecognisedTextInHeadline().New(block, nr(lines), headline.PointerPosition, headline.RemainingLength()))
}
return r
}()
lines = lines[1:]
if record == nil {
// In case there was an error, generate dummy record to ensure that we have something to
// work with during parsing. That allows us to continue even if there are errors early on.
dummyDate, _ := klog.NewDate(0, 0, 0)
record = klog.NewRecord(dummyDate)
}
var indentator *txt.Indentator
// ========== SUMMARY LINES ==========
for _, l := range lines {
indentator = txt.NewIndentator(txt.Indentations, lines[0])
if indentator != nil {
break
}
summary := txt.NewParseable(l, 0)
newSummary, sErr := klog.NewRecordSummary(append(record.Summary(), summary.ToString())...)
if sErr != nil {
errs = append(errs, ErrorMalformedSummary().New(block, nr(lines), 0, summary.Length()))
}
lines = lines[1:]
record.SetSummary(newSummary)
}
// ========== ENTRIES ==========
for len(lines) > 0 {
l := lines[0]
if indentator == nil {
// We should never make it here if the indentation could not be determined.
panic("Could not detect indentation")
}
// Check for correct indentation.
entry := indentator.NewIndentedParseable(l, 1)
if entry == nil || txt.IsSpaceOrTab(entry.Peek()) {
errs = append(errs, ErrorIllegalIndentation().New(block, nr(lines), 0, len(l.Text)))
break
}
// Parse entry value.
createEntry, evErr := func() (func(klog.EntrySummary) txt.Error, txt.Error) {
// Try to interpret the entry value as duration.
durationCandidate, _ := entry.PeekUntil(txt.IsSpaceOrTab)
duration, dErr := klog.NewDurationFromString(durationCandidate.ToString())
if dErr == nil {
entry.Advance(durationCandidate.Length())
return func(s klog.EntrySummary) txt.Error {
record.AddDuration(duration, s)
return nil
}, nil
}
// If the entry value isn’t a duration, it must be the start time of a range.
startCandidate, _ := entry.PeekUntil(txt.Is('-', ' '))
if startCandidate.Length() == 0 {
// Handle case where `-` appears right at the beginning of the line.
firstToken, _ := entry.PeekUntil(txt.IsSpaceOrTab)
return nil, ErrorMalformedEntry().New(block, nr(lines), entry.PointerPosition, firstToken.Length())
}
start, t1Err := klog.NewTimeFromString(startCandidate.ToString())
if t1Err != nil {
return nil, ErrorMalformedEntry().New(block, nr(lines), entry.PointerPosition, startCandidate.Length())
}
entryStartPosition := startCandidate.PointerPosition
entry.Advance(startCandidate.Length())
entryStartPositionEnds := entry.PointerPosition
entry.SkipWhile(txt.Is(' '))
hasRangeSpacesAroundDash := true
if entryStartPositionEnds == entry.PointerPosition {
hasRangeSpacesAroundDash = false
}
if entry.Peek() != '-' {
return nil, ErrorMalformedEntry().New(block, nr(lines), entry.PointerPosition, 1)
}
entry.Advance(1) // '-'
entry.SkipWhile(txt.Is(' '))
// Check whether the range is open-ended.
if entry.Peek() == '?' {
entry.Advance(1)
placeholderRepetition, _ := entry.PeekUntil(txt.IsSpaceOrTab)
// The placeholder can appear multiple times.
for _, p := range placeholderRepetition.Chars {
if p != '?' {
return nil, ErrorMalformedEntry().New(block, nr(lines), entry.PointerPosition, placeholderRepetition.Length())
}
}
entry.Advance(placeholderRepetition.Length())
lineNr := nr(lines) // Capture state of `line` at time of function creation.
return func(s klog.EntrySummary) txt.Error {
or := klog.NewOpenRangeWithFormat(start, klog.OpenRangeFormat{
UseSpacesAroundDash: hasRangeSpacesAroundDash,
AdditionalPlaceholderChars: placeholderRepetition.Length(),
})
sErr := record.Start(or, s)
if sErr != nil {
return ErrorDuplicateOpenRange().New(block, lineNr, entryStartPosition, entry.PointerPosition-entryStartPosition)
}
return nil
}, nil
}
// Ultimately, the entry can only be a regular range.
endCandidate, _ := entry.PeekUntil(txt.IsSpaceOrTab)
if endCandidate.Length() == 0 {
return nil, ErrorMalformedEntry().New(block, nr(lines), entry.PointerPosition, 1)
}
end, t2Err := klog.NewTimeFromString(endCandidate.ToString())
if t2Err != nil {
return nil, ErrorMalformedEntry().New(block, nr(lines), entry.PointerPosition, endCandidate.Length())
}
entry.Advance(endCandidate.Length())
timeRange, rErr := klog.NewRangeWithFormat(start, end, klog.RangeFormat{UseSpacesAroundDash: hasRangeSpacesAroundDash})
if rErr != nil {
return nil, ErrorIllegalRange().New(block, nr(lines), entryStartPosition, entry.PointerPosition-entryStartPosition)
}
return func(s klog.EntrySummary) txt.Error {
record.AddRange(timeRange, s)
return nil
}, nil
}()
lines = lines[1:]
// Check for error while parsing the entry value.
if evErr != nil {
errs = append(errs, evErr)
continue
}
// Parse entry summary.
entrySummary, esErr := func() (klog.EntrySummary, txt.Error) {
var result klog.EntrySummary
// Parse first line of entry summary.
if txt.IsSpaceOrTab(entry.Peek()) {
entry.Advance(1)
summaryText := entry.Remainder()
firstLine, sErr := klog.NewEntrySummary(summaryText.ToString())
if sErr != nil {
return nil, ErrorMalformedSummary().New(block, nr(lines), 0, summaryText.Length())
}
result = firstLine
} else {
result, _ = klog.NewEntrySummary("")
}
// Parse subsequent lines of multiline entry summary.
for len(lines) > 0 {
nextEntrySummaryLine := indentator.NewIndentedParseable(lines[0], 2)
if nextEntrySummaryLine == nil {
break
}
lines = lines[1:]
additionalText, _ := nextEntrySummaryLine.PeekUntil(func(_ rune) bool {
return false // Move forward until end of line
})
newEntrySummary, sErr := klog.NewEntrySummary(append(result, additionalText.ToString())...)
if sErr != nil {
return nil, ErrorMalformedSummary().New(block, nr(lines), 0, nextEntrySummaryLine.Length())
}
result = newEntrySummary
}
return result, nil
}()
// Check for error while parsing the entry summary.
if esErr != nil {
errs = append(errs, esErr)
continue
}
// Check for error when eventually applying the entry.
eErr := createEntry(entrySummary)
if eErr != nil {
errs = append(errs, eErr)
}
}
if len(errs) > 0 {
return nil, errs
}
return record, nil
}
07070100000079000081A40000000000000000000000016863F92F00004C68000000000000000000000000000000000000002400000000klog-6.6/klog/parser/parser_test.gopackage parser
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
var parsers = []Parser{
NewSerialParser(),
NewParallelParser(1),
NewParallelParser(2),
NewParallelParser(4),
NewParallelParser(8),
NewParallelParser(15),
NewParallelParser(50),
}
func TestParseMinimalDocument(t *testing.T) {
text := `2000-01-01`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 1)
assert.Equal(t, klog.Ɀ_Date_(2000, 1, 1), rs[0].Date())
}
}
func TestParseMultipleRecords(t *testing.T) {
text := `
1999-05-31
1999-06-03
1h
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 2)
assert.Equal(t, klog.Ɀ_Date_(1999, 5, 31), rs[0].Date())
assert.Len(t, rs[0].Entries(), 0)
assert.Equal(t, klog.Ɀ_Date_(1999, 6, 3), rs[1].Date())
assert.Len(t, rs[1].Entries(), 1)
}
}
func TestParseCompleteRecord(t *testing.T) {
text := `
1970-08-29 (8h15m!)
Record summary with
multiple lines of text
1h
1h1m Duration with summary
1h2m Duration with
multiline summary
8:00-9:30
9:00-10:31 Range with summary
10:00-11:32 Range with multiple
lines of
summary text
11:00-?
Open range
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 1)
r := rs[0]
assert.Equal(t, klog.Ɀ_Date_(1970, 8, 29), r.Date())
assert.Equal(t, klog.Ɀ_RecordSummary_("Record summary with", "multiple lines of text"), r.Summary())
assert.Equal(t, klog.NewDuration(8, 15).InMinutes(), r.ShouldTotal().InMinutes())
assert.Len(t, r.Entries(), 7)
assert.Equal(t, klog.NewDuration(1, 0).InMinutes(), r.Entries()[0].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_(""), r.Entries()[0].Summary())
assert.Equal(t, klog.NewDuration(1, 1).InMinutes(), r.Entries()[1].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_("Duration with summary"), r.Entries()[1].Summary())
assert.Equal(t, klog.NewDuration(1, 2).InMinutes(), r.Entries()[2].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_("Duration with", "multiline summary"), r.Entries()[2].Summary())
assert.Equal(t, klog.NewDuration(1, 30).InMinutes(), r.Entries()[3].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_(""), r.Entries()[3].Summary())
assert.Equal(t, klog.NewDuration(1, 31).InMinutes(), r.Entries()[4].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_("Range with summary"), r.Entries()[4].Summary())
assert.Equal(t, klog.NewDuration(1, 32).InMinutes(), r.Entries()[5].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_("Range with multiple", "lines of", " summary text"), r.Entries()[5].Summary())
assert.Equal(t, klog.NewDuration(0, 0).InMinutes(), r.Entries()[6].Duration().InMinutes())
assert.Equal(t, klog.Ɀ_EntrySummary_("", "Open range"), r.Entries()[6].Summary())
}
}
func TestParseEmptyOrBlankDocument(t *testing.T) {
for _, text := range []string{
"",
" ",
"\n\n\n\n\n",
"\n\t \n \n ",
} {
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 0)
}
}
}
func TestParseWindowsAndUnixLineEndings(t *testing.T) {
text := "2000-01-01\r\n\r\n2000-01-02\n\n2000-01-03"
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 3)
}
}
func TestParseUtf8Document(t *testing.T) {
text := `
2018-01-04
1h Домашня робота 🏡...
2h Сьогодні я дзвонив
Дімі і складав плани
2018-01-05
मुख्य रूपमा काम
10:00-12:30 बगैचा खन्नुहोस्
13:00-15:00 कर घोषणा
2018-01-06
3h sázet květiny
14:00-? jít na procházku, vynést
odpadky, přines noviny
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 3)
}
}
func TestParseMultipleRecordsWhenBlankLineContainsWhitespace(t *testing.T) {
text := "2018-01-01\n 1h\n" + " \n" + "2019-01-01\n \n2019-01-02"
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 3)
}
}
func TestParseAlternativeFormatting(t *testing.T) {
text := `
1999/05/31
8:00-13:00
1999-05-31
8:00am-1:00pm
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 2)
assert.True(t, rs[0].Date().IsEqualTo(rs[1].Date()))
assert.Equal(t, rs[0].Entries()[0].Duration(), rs[1].Entries()[0].Duration())
}
}
func TestAcceptTabOrSpacesAsIndentation(t *testing.T) {
for _, x := range []string{
"2000-01-01\n\t8h",
"2000-01-01\n\t8h\n\t15m",
"2000-05-31\n 6h",
"2000-05-31\n 6h\n 20m",
"2000-05-31\n 6h",
"2000-05-31\n 6h",
} {
for _, p := range parsers {
rs, _, errs := p.Parse(x)
require.Nil(t, errs)
require.Len(t, rs, 1)
}
}
}
func TestParseDocumentSucceedsWithCorrectEntryValues(t *testing.T) {
for _, test := range []struct {
text string
expectEntry any
}{
// Durations
{"1234-12-12\n\t5h", klog.NewDuration(5, 0)},
{"1234-12-12\n\t2m", klog.NewDuration(0, 2)},
{"1234-12-12\n\t2h30m", klog.NewDuration(2, 30)},
// Durations with sign
{"1234-12-12\n\t+5h", klog.Ɀ_ForceSign_(klog.NewDuration(5, 0))},
{"1234-12-12\n\t+2h30m", klog.Ɀ_ForceSign_(klog.NewDuration(2, 30))},
{"1234-12-12\n\t+2m", klog.Ɀ_ForceSign_(klog.NewDuration(0, 2))},
{"1234-12-12\n\t-5h", klog.NewDuration(-5, -0)},
{"1234-12-12\n\t-2h30m", klog.NewDuration(-2, -30)},
{"1234-12-12\n\t-2m", klog.NewDuration(-0, -2)},
// Ranges
{"1234-12-12\n\t3:05 - 11:59", klog.Ɀ_Range_(klog.Ɀ_Time_(3, 5), klog.Ɀ_Time_(11, 59))},
{"1234-12-12\n\t22:00 - 24:00", klog.Ɀ_Range_(klog.Ɀ_Time_(22, 0), klog.Ɀ_TimeTomorrow_(0, 0))},
{"1234-12-12\n\t9:00am - 1:43pm", klog.Ɀ_Range_(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(9, 00)), klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(13, 43)))},
{"1234-12-12\n\t9:00am-1:43pm", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(9, 00)), klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(13, 43))))},
{"1234-12-12\n\t9:00am-9:05", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(9, 00)), klog.Ɀ_Time_(9, 05)))},
// Ranges with shifted times
{"1234-12-12\n\t9:00am-8:12am>", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(9, 00)), klog.Ɀ_IsAmPm_(klog.Ɀ_TimeTomorrow_(8, 12))))},
{"1234-12-12\n\t<22:00 - <24:00", klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(22, 0), klog.Ɀ_Time_(0, 0))},
{"1234-12-12\n\t<23:30 - 0:10", klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 30), klog.Ɀ_Time_(0, 10))},
{"1234-12-12\n\t22:17 - 1:00>", klog.Ɀ_Range_(klog.Ɀ_Time_(22, 17), klog.Ɀ_TimeTomorrow_(1, 00))},
{"1234-12-12\n\t22:17 - 1:00>", klog.Ɀ_Range_(klog.Ɀ_Time_(22, 17), klog.Ɀ_TimeTomorrow_(1, 00))},
{"1234-12-12\n\t<23:00-1:00>", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 00), klog.Ɀ_TimeTomorrow_(1, 00)))},
{"1234-12-12\n\t<23:00-<23:10", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 00), klog.Ɀ_TimeYesterday_(23, 10)))},
{"1234-12-12\n\t12:01>-13:59>", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_TimeTomorrow_(12, 01), klog.Ɀ_TimeTomorrow_(13, 59)))},
// Open ranges
{"1234-12-12\n\t12:01 - ?", klog.NewOpenRange(klog.Ɀ_Time_(12, 1))},
{"1234-12-12\n\t6:45pm - ?", klog.NewOpenRange(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(18, 45)))},
{"1234-12-12\n\t6:45pm - ?", klog.NewOpenRange(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(18, 45)))},
{"1234-12-12\n\t18:45 - ???", klog.Ɀ_QuestionMarks_(klog.NewOpenRange(klog.Ɀ_Time_(18, 45)), 2)},
{"1234-12-12\n\t<3:12-??????", klog.Ɀ_QuestionMarks_(klog.Ɀ_NoSpacesO_(klog.NewOpenRange(klog.Ɀ_TimeYesterday_(3, 12))), 5)},
} {
for _, p := range parsers {
rs, _, errs := p.Parse(test.text)
require.Nil(t, errs, test.text)
require.Len(t, rs, 1, test.text)
require.Len(t, rs[0].Entries(), 1, test.text)
value := klog.Unbox(&rs[0].Entries()[0],
func(r klog.Range) any { return r },
func(d klog.Duration) any { return d },
func(o klog.OpenRange) any { return o },
)
assert.Equal(t, test.expectEntry, value, test.text)
}
}
}
func TestParsesDocumentsWithEntrySummaries(t *testing.T) {
for _, test := range []struct {
text string
expectEntry any
expectSummary klog.EntrySummary
}{
// Single line entries
{"1234-12-12\n\t5h Some remark", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark")},
{"1234-12-12\n\t3:05 - 11:59 Did this and that", klog.Ɀ_Range_(klog.Ɀ_Time_(3, 5), klog.Ɀ_Time_(11, 59)), klog.Ɀ_EntrySummary_("Did this and that")},
{"1234-12-12\n\t9:00am-8:12am> Things", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(9, 00)), klog.Ɀ_IsAmPm_(klog.Ɀ_TimeTomorrow_(8, 12)))), klog.Ɀ_EntrySummary_("Things")},
{"1234-12-12\n\t18:45 - ? Just started something", klog.NewOpenRange(klog.Ɀ_Time_(18, 45)), klog.Ɀ_EntrySummary_("Just started something")},
{"1234-12-12\n\t5h Some remark", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_(" Some remark")},
{"1234-12-12\n\t5h\tSome remark", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark")},
{"1234-12-12\n\t9:00am-9:05 Mixed styles", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(9, 00)), klog.Ɀ_Time_(9, 05))), klog.Ɀ_EntrySummary_("Mixed styles")},
{"1234-12-12\n\t3:05 - 11:59\tFoo", klog.Ɀ_Range_(klog.Ɀ_Time_(3, 5), klog.Ɀ_Time_(11, 59)), klog.Ɀ_EntrySummary_("Foo")},
{"1234-12-12\n\t<22:00 - <24:00\tFoo", klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(22, 0), klog.Ɀ_Time_(0, 0)), klog.Ɀ_EntrySummary_("Foo")},
{"1234-12-12\n\t22:00 - 24:00\tFoo", klog.Ɀ_Range_(klog.Ɀ_Time_(22, 0), klog.Ɀ_TimeTomorrow_(0, 0)), klog.Ɀ_EntrySummary_("Foo")},
{"1234-12-12\n\t18:45 - ??? ASDF", klog.Ɀ_QuestionMarks_(klog.NewOpenRange(klog.Ɀ_Time_(18, 45)), 2), klog.Ɀ_EntrySummary_(" ASDF")},
{"1234-12-12\n\t18:45 - ?\tFoo", klog.NewOpenRange(klog.Ɀ_Time_(18, 45)), klog.Ɀ_EntrySummary_("Foo")},
// Multiline-summary entries
{"1234-12-12\n\t5h Some remark\n\t\twith more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark", "with more text")},
{"1234-12-12\n\t8:00-9:00 Some remark\n\t\twith more text", klog.Ɀ_NoSpaces_(klog.Ɀ_Range_(klog.Ɀ_Time_(8, 00), klog.Ɀ_Time_(9, 00))), klog.Ɀ_EntrySummary_("Some remark", "with more text")},
{"1234-12-12\n\t5h Some remark\n\t\twith\n\t\tmore\n\t\ttext", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark", "with", "more", "text")},
{"1234-12-12\n 5h Some remark\n with more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark", "with more text")},
{"1234-12-12\n 5h Some remark\n with more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark", "with more text")},
{"1234-12-12\n 5h Some remark\n with more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark", "with more text")},
{"1234-12-12\n 5h Some remark\n with\n more\n text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("Some remark", "with", "more", "text")},
// Multiline-summary entries where first summary line is empty
{"1234-12-12\n\t5h\n\t\twith more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("", "with more text")},
{"1234-12-12\n\t5h \n\t\twith more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("", "with more text")},
{"1234-12-12\n\t5h \n\t\twith more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_(" ", "with more text")},
{"1234-12-12\n\t5h\n\t\t with more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("", " with more text")},
{"1234-12-12\n\t5h\n\t\t\twith more text", klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("", "\twith more text")},
} {
for _, p := range parsers {
rs, _, errs := p.Parse(test.text)
require.Nil(t, errs, test.text)
require.Len(t, rs, 1, test.text)
require.Len(t, rs[0].Entries(), 1, test.text)
value := klog.Unbox(&rs[0].Entries()[0],
func(r klog.Range) any { return r },
func(d klog.Duration) any { return d },
func(o klog.OpenRange) any { return o },
)
assert.Equal(t, test.expectEntry, value, test.text)
assert.Equal(t, test.expectSummary, rs[0].Entries()[0].Summary(), test.text)
}
}
}
func TestMalformedRecord(t *testing.T) {
text := `
1999-05-31
5h30m This and that
Why is there a summary at the end?
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, rs)
require.NotNil(t, errs)
require.Len(t, errs, 1)
assert.Equal(t, ErrorIllegalIndentation().toErrData(4, 0, 34), toErrData(errs[0]))
}
}
func TestReportErrorsInHeadline(t *testing.T) {
for _, test := range []struct {
text string
expect errData
}{
{"Hello 123", ErrorInvalidDate().toErrData(1, 0, 5)},
{" 2020-01-01", ErrorIllegalIndentation().toErrData(1, 0, 11)},
{" 2020-01-01", ErrorIllegalIndentation().toErrData(1, 0, 13)},
{"2020-01-01 ()", ErrorMalformedPropertiesSyntax().toErrData(1, 12, 1)},
{"2020-01-01 (asdf)", ErrorUnrecognisedProperty().toErrData(1, 12, 4)},
{"2020-01-01 (asdf!)", ErrorMalformedShouldTotal().toErrData(1, 12, 4)},
{"2020-01-01 5h30m!", ErrorUnrecognisedTextInHeadline().toErrData(1, 11, 6)},
{"2020-01-01 (5h30m!", ErrorMalformedPropertiesSyntax().toErrData(1, 18, 1)},
{"2020-01-01 (", ErrorMalformedPropertiesSyntax().toErrData(1, 12, 1)},
{"2020-01-01 (5h!) foo", ErrorUnrecognisedTextInHeadline().toErrData(1, 17, 3)},
{"2020-01-01 (5h! asdf)", ErrorUnrecognisedProperty().toErrData(1, 16, 4)},
{"2020-01-01 (5h!!!)", ErrorUnrecognisedProperty().toErrData(1, 15, 2)},
} {
for _, p := range parsers {
rs, _, errs := p.Parse(test.text)
require.Nil(t, rs)
require.NotNil(t, errs)
require.Len(t, errs, 1)
assert.Equal(t, test.expect, toErrData(errs[0]), test.text)
}
}
}
func TestReportErrorsInSummary(t *testing.T) {
text := `
2020-01-01
This is a summary that contains
whitespace at the beginning of the line.
That is not allowed.
Other kinds of blank characters are not allowed there neither.
And neither are fake blank lines:
End.
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, rs)
require.NotNil(t, errs)
require.Len(t, errs, 4)
assert.Equal(t, ErrorMalformedSummary().toErrData(4, 0, 41), toErrData(errs[0]))
assert.Equal(t, ErrorMalformedSummary().toErrData(6, 0, 63), toErrData(errs[1]))
assert.Equal(t, ErrorMalformedSummary().toErrData(7, 0, 34), toErrData(errs[2]))
assert.Equal(t, ErrorMalformedSummary().toErrData(8, 0, 4), toErrData(errs[3]))
}
}
func TestReportErrorsIfIndentationIsIncorrect(t *testing.T) {
for _, test := range []struct {
text string
expect errData
}{
// To few characters (that’s actually a malformed summary, though):
{"2020-01-01\n 8h", ErrorMalformedSummary().toErrData(2, 0, 3)},
// Not exactly one indentation level:
{"2020-01-01\n\t 8h", ErrorIllegalIndentation().toErrData(2, 0, 4)},
{"2020-01-01\n\t\t8h", ErrorIllegalIndentation().toErrData(2, 0, 4)},
{"2020-01-01\n 8h", ErrorIllegalIndentation().toErrData(2, 0, 7)},
// Mixed styles for entries within one record:
{"2020-01-01\n 8h\n\t2h", ErrorIllegalIndentation().toErrData(3, 0, 3)},
{"2020-01-01\n 8h\n\t2h", ErrorIllegalIndentation().toErrData(3, 0, 3)},
{"2020-01-01\n\t8h\n 2h", ErrorIllegalIndentation().toErrData(3, 0, 6)},
{"2020-01-01\n\t8h\n 2h", ErrorIllegalIndentation().toErrData(3, 0, 4)},
{"2020-01-01\n 8h\n 2h", ErrorIllegalIndentation().toErrData(3, 0, 4)},
{"2020-01-01\n 8h\n 2h", ErrorIllegalIndentation().toErrData(3, 0, 5)},
// Mixed styles for entry summaries within one record:
{"2020-01-01\n 8h Foo\n\tbar baz", ErrorIllegalIndentation().toErrData(3, 0, 8)},
{"2020-01-01\n 8h Foo\n bar baz", ErrorIllegalIndentation().toErrData(3, 0, 14)},
{"2020-01-01\n 8h Foo\n bar baz", ErrorIllegalIndentation().toErrData(3, 0, 13)},
{"2020-01-01\n 8h Foo\n \tbar baz", ErrorIllegalIndentation().toErrData(3, 0, 12)},
{"2020-01-01\n 8h Foo\n bar baz", ErrorIllegalIndentation().toErrData(3, 0, 12)},
{"2020-01-01\n 8h Foo\n bar baz", ErrorIllegalIndentation().toErrData(3, 0, 10)},
{"2020-01-01\n 8h\n 8h Foo\n bar baz", ErrorIllegalIndentation().toErrData(4, 0, 10)},
} {
for _, p := range parsers {
rs, _, errs := p.Parse(test.text)
require.Nil(t, rs, test.text)
require.NotNil(t, errs, test.text)
require.Len(t, errs, 1, test.text)
assert.Equal(t, test.expect, toErrData(errs[0]), test.text)
}
}
}
func TestAcceptMixingIndentationStylesAcrossDifferentRecords(t *testing.T) {
text := `
2020-01-01
4h This is two spaces
2h So is this
2020-01-02
6h This is 4 spaces
2020-01-03
12m This is a tab
`
for _, p := range parsers {
rs, _, errs := p.Parse(text)
require.Nil(t, errs)
require.Len(t, rs, 3)
}
}
func TestReportErrorsInEntries(t *testing.T) {
for _, test := range []struct {
text string
expect errData
}{
// Malformed syntax
{"2020-01-01\n\t5h1", ErrorMalformedEntry().toErrData(2, 1, 3)},
{"2020-01-01\n\tasdf Test 123", ErrorMalformedEntry().toErrData(2, 1, 4)},
{"2020-01-01\n\t15:30", ErrorMalformedEntry().toErrData(2, 6, 1)},
{"2020-01-01\n\t08:00-", ErrorMalformedEntry().toErrData(2, 7, 1)},
{"2020-01-01\n\t08:00-asdf", ErrorMalformedEntry().toErrData(2, 7, 4)},
{"2020-01-01\n\t08:00 - ?asdf", ErrorMalformedEntry().toErrData(2, 10, 4)},
{"2020-01-01\n\t-18:00", ErrorMalformedEntry().toErrData(2, 1, 6)},
{"2020-01-01\n\t5h Test\n\t15:30 Foo Bar Baz", ErrorMalformedEntry().toErrData(3, 7, 1)},
{"2020-01-01\n\t5h Hello\n\t\tFoo\n\t15:30 Foo Bar Baz", ErrorMalformedEntry().toErrData(4, 7, 1)},
{"2020-01-01\n\t12:76 - 13:00", ErrorMalformedEntry().toErrData(2, 1, 5)},
{"2020-01-01\n\t12:00 - 44:00", ErrorMalformedEntry().toErrData(2, 9, 5)},
{"2020-01-01\n\t23:00> - 25:61>", ErrorMalformedEntry().toErrData(2, 10, 6)},
{"2020-01-01\n\t12:00> - 24:00>", ErrorMalformedEntry().toErrData(2, 10, 6)},
// Logical errors
{"2020-01-01\n\t08:00- ?\n\t09:00 - ?", ErrorDuplicateOpenRange().toErrData(3, 1, 9)},
{"2020-01-01\n\t15:00 - 14:00", ErrorIllegalRange().toErrData(2, 1, 13)},
{"2020-01-01\n\t15:00 - 14:00", ErrorIllegalRange().toErrData(2, 1, 13)},
} {
for _, p := range parsers {
rs, _, errs := p.Parse(test.text)
require.Nil(t, rs, test.text)
require.NotNil(t, errs, test.text)
require.Len(t, errs, 1, test.text)
assert.Equal(t, test.expect, toErrData(errs[0]), test.text)
}
}
}
func TestParseLongDocumentWithMultipleErrors(t *testing.T) {
text := `
2019-08-15
16:00-19:41 Something
20:02-?
2019-08-16
Entry without value
8h
-12m Break
2019-08-17 (8h)
Record summary
11:00-?
Open range
2019-08-38
What date is this?!?
`
for _, p := range parsers {
_, _, errs := p.Parse(text)
require.Len(t, errs, 4)
assert.Equal(t, ErrorMalformedEntry().toErrData(7, 4, 5), toErrData(errs[0]))
assert.Equal(t, ErrorUnrecognisedProperty().toErrData(11, 12, 2), toErrData(errs[1]))
assert.Equal(t, ErrorIllegalIndentation().toErrData(14, 0, 16), toErrData(errs[2]))
assert.Equal(t, ErrorInvalidDate().toErrData(17, 0, 10), toErrData(errs[3]))
}
}
0707010000007A000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000002100000000klog-6.6/klog/parser/reconciling0707010000007B000081A40000000000000000000000016863F92F00000159000000000000000000000000000000000000003100000000klog-6.6/klog/parser/reconciling/append_entry.gopackage reconciling
import "github.com/jotaen/klog/klog"
// AppendEntry adds a new entry to the end of the record.
// `newEntry` must include the entry value at the beginning of its first line.
func (r *Reconciler) AppendEntry(newEntry klog.EntrySummary) error {
r.insert(r.lastLinePointer, toMultilineEntryTexts("", newEntry))
return nil
}
0707010000007C000081A40000000000000000000000016863F92F00000E64000000000000000000000000000000000000003600000000klog-6.6/klog/parser/reconciling/append_entry_test.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func assertResult(t *testing.T, r *Reconciler) *Result {
result, err := r.MakeResult()
require.Nil(t, err)
return result
}
func TestReconcilerAddsNewlyCreatedEntryAtEndOfFile(t *testing.T) {
original := "\n2018-01-01\n 1h"
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2018, 1, 1))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("16:00-17:00"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
1h
16:00-17:00
`, result.AllSerialised)
}
func TestReconcilerAddsNewEntryInTheMiddleOfFile(t *testing.T) {
original := `
2018-01-01
1h
2018-01-02
Hello World
1h
1h45m Multiline...
....entry summary
2018-01-03
5h
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2018, 1, 2))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("2h30m"))
require.Nil(t, err)
result := assertResult(t, reconciler)
require.NotNil(t, result)
require.Equal(t, 150, result.Record.Entries()[2].Duration().InMinutes())
require.Equal(t, 315, service.Total(result.Record).InMinutes())
assert.Equal(t, `
2018-01-01
1h
2018-01-02
Hello World
1h
1h45m Multiline...
....entry summary
2h30m
2018-01-03
5h
`, result.AllSerialised)
}
func TestReconcilerCanHandleUTF8Input(t *testing.T) {
original := "\n2018-01-01\n家事\n 1h ランドリー"
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2018, 1, 1))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("20m 掃除機"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
家事
1h ランドリー
20m 掃除機
`, result.AllSerialised)
}
func TestReconcilerSplitsUpSummaryText(t *testing.T) {
original := "\n2018-01-01\n 1h"
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2018, 1, 1))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("2h This is a", "multiline summary"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
1h
2h This is a
multiline summary
`, result.AllSerialised)
}
func TestReconcilerStartsSummaryTextOnNextLine(t *testing.T) {
original := "\n2018-01-01\n 1h"
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2018, 1, 1))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("2h", "Some activity"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
1h
2h
Some activity
`, result.AllSerialised)
}
func TestReconcilerRejectsInvalidEntry(t *testing.T) {
original := "2018-01-01\n"
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2018, 1, 1))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("this is not valid entry text"))
assert.Nil(t, err) // This doesn’t produce an error yet, but calling `MakingResult` will!
result, rErr := reconciler.MakeResult()
assert.Error(t, rErr)
assert.Nil(t, result)
}
0707010000007D000081A40000000000000000000000016863F92F00000455000000000000000000000000000000000000003500000000klog-6.6/klog/parser/reconciling/close_open_range.gopackage reconciling
import (
"errors"
"github.com/jotaen/klog/klog"
"regexp"
)
// CloseOpenRange tries to close the open time range.
func (r *Reconciler) CloseOpenRange(endTime klog.Time, format ReformatDirective[klog.TimeFormat], additionalSummary klog.EntrySummary) error {
openRangeEntryIndex := r.findOpenRangeIndex()
if openRangeEntryIndex == -1 {
return errors.New("No open time range")
}
eErr := r.Record.EndOpenRange(endTime)
if eErr != nil {
return errors.New("Start and end time must be in chronological order")
}
// Replace question mark with end time.
openRangeValueLineIndex := r.lastLinePointer - countLines(r.Record.Entries()[openRangeEntryIndex:])
endTimeValue := endTime.ToString()
format.apply(r.style.timeFormat(), func(f klog.TimeFormat) {
endTimeValue = endTime.ToStringWithFormat(f)
})
r.lines[openRangeValueLineIndex].Text = regexp.MustCompile(`^(.*?)\?+(.*)$`).
ReplaceAllString(
r.lines[openRangeValueLineIndex].Text,
"${1}"+endTimeValue+"${2}",
)
r.concatenateSummary(openRangeEntryIndex, openRangeValueLineIndex, additionalSummary)
return nil
}
0707010000007E000081A40000000000000000000000016863F92F00001610000000000000000000000000000000000000003A00000000klog-6.6/klog/parser/reconciling/close_open_range_test.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestReconcilerClosesOpenRange(t *testing.T) {
original := `
2010-04-27
15:00 - ??
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2010, 4, 27)
atTime := klog.Ɀ_Time_(15, 30)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
15:00 - 15:30
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithNewSummary(t *testing.T) {
original := `
2018-01-01
15:00 - ?
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(15, 22)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("Finished."))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
15:00 - 15:22 Finished.
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithNewMultilineSummary(t *testing.T) {
original := `
2018-01-01
15:00 - ?
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(15, 22)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("", "Finished."))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
15:00 - 15:22
Finished.
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithExtendingSummary(t *testing.T) {
original := `
2018-01-01
1h Multiline...
...entry summary
15:00-??? Will this close?
I hope so.
2m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(16, 42)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("Yes!"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
1h Multiline...
...entry summary
15:00-16:42 Will this close?
I hope so. Yes!
2m
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithUTF8Summary(t *testing.T) {
original := `
2018-01-01
Arbeiten rund um’s Haus… 🏡
15:00 - ? Blümchen 🌼 planzen
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(16, 15)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("🪴"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
Arbeiten rund um’s Haus… 🏡
15:00 - 16:15 Blümchen 🌼 planzen 🪴
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithExtendingSummaryOnNextLine(t *testing.T) {
original := `
2018-01-01
16:00-? Started...
-45m break
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(18, 01)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("", "Stopped."))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
16:00-18:01 Started...
Stopped.
-45m break
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeDetectsStyle(t *testing.T) {
original := `
2010-04-27
3:00pm - ??
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2010, 4, 27)
atTime := klog.Ɀ_Time_(15, 30)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, ReformatAutoStyle[klog.TimeFormat](), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - 3:30pm
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithExplicitOverrideStyle(t *testing.T) {
original := `
2010-04-27
3:00pm - ??
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2010, 4, 27)
atTime := klog.Ɀ_Time_(15, 30)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, ReformatExplicitly[klog.TimeFormat](klog.TimeFormat{Use24HourClock: true}), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - 15:30
`, result.AllSerialised)
}
func TestReconcilerClosesOpenRangeWithOwnStyle(t *testing.T) {
original := `
2010-04-27
3:00pm - ??
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2010, 4, 27)
atTime := klog.Ɀ_Time_(15, 30) // Not an am/pm time!
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.CloseOpenRange(atTime, NoReformat[klog.TimeFormat](), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - 15:30
`, result.AllSerialised)
}
0707010000007F000081A40000000000000000000000016863F92F00000DA5000000000000000000000000000000000000002C00000000klog-6.6/klog/parser/reconciling/creator.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser/txt"
)
// Creator is a function interface for creating a new reconciler.
type Creator func([]klog.Record, []txt.Block) *Reconciler
type AdditionalData struct {
ShouldTotal klog.ShouldTotal
Summary klog.RecordSummary
}
// NewReconcilerForNewRecord is a reconciler creator for a new record at a given date and
// with the given parameters.
func NewReconcilerForNewRecord(atDate klog.Date, format ReformatDirective[klog.DateFormat], ad AdditionalData) Creator {
return func(rs []klog.Record, bs []txt.Block) *Reconciler {
record := klog.NewRecord(atDate)
if ad.ShouldTotal != nil {
record.SetShouldTotal(ad.ShouldTotal)
}
if ad.Summary != nil {
record.SetSummary(ad.Summary)
}
reconciler := &Reconciler{
Record: record,
recordPointer: -1,
lastLinePointer: -1,
style: elect(*defaultStyle(), rs, bs),
lines: flatten(bs),
}
dateValue := atDate.ToString()
format.apply(reconciler.style.dateFormat(), func(f klog.DateFormat) {
dateValue = atDate.ToStringWithFormat(f)
})
recordText := func() []insertableText {
result := dateValue
if ad.ShouldTotal != nil {
result += " (" + ad.ShouldTotal.ToString() + ")"
}
return []insertableText{{result, 0}}
}()
for _, s := range ad.Summary {
recordText = append(recordText, insertableText{s, 0})
}
newRecordLines, insertPointer, lastLineOffset, newRecordIndex := func() ([]insertableText, int, int, int) {
if len(rs) == 0 {
return recordText, 0, 1, 0
}
i := 0
for _, r := range rs {
if i == 0 && !atDate.IsAfterOrEqual(r.Date()) {
// The new record is dated prior to the first one, so we have to append a blank line.
recordText = append(recordText, blankLine)
return recordText, 0, 1, 0
}
if len(rs)-1 == i || (atDate.IsAfterOrEqual(r.Date()) && !atDate.IsAfterOrEqual(rs[i+1].Date())) {
// The record is in between.
break
}
i++
}
// The new record is dated after the last one, so we have to prepend a blank line.
recordText = append([]insertableText{blankLine}, recordText...)
return recordText, indexOfLastSignificantLine(bs[i]), 2, i + 1
}()
// Insert record and adjust pointers accordingly.
reconciler.insert(insertPointer, newRecordLines)
reconciler.lastLinePointer = insertPointer + lastLineOffset
reconciler.recordPointer = newRecordIndex
return reconciler
}
}
// NewReconcilerAtRecord is a reconciler creator for an existing record at a given date.
func NewReconcilerAtRecord(atDate klog.Date) Creator {
return func(rs []klog.Record, bs []txt.Block) *Reconciler {
index := -1
for i, r := range rs {
if r.Date().IsEqualTo(atDate) {
index = i
break
}
}
if index == -1 {
return nil
}
style := determine(rs[index], bs[index])
return &Reconciler{
Record: rs[index],
style: elect(*style, rs, bs),
lastLinePointer: indexOfLastSignificantLine(bs[index]),
recordPointer: index,
lines: flatten(bs),
}
}
}
func flatten(bs []txt.Block) []txt.Line {
var result []txt.Line
for _, b := range bs {
result = append(result, b.Lines()...)
}
return result
}
func indexOfLastSignificantLine(block txt.Block) int {
significantLines, precedingInsignificantLineCount, _ := block.SignificantLines()
return block.OverallLineIndex(precedingInsignificantLineCount + len(significantLines))
}
07070100000080000081A40000000000000000000000016863F92F000020D4000000000000000000000000000000000000003100000000klog-6.6/klog/parser/reconciling/creator_test.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestReconcilerSkipsIfNoRecordMatches(t *testing.T) {
original := "2018-01-01\n"
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(9999, 12, 31))(rs, bs)
require.Nil(t, reconciler)
}
func TestReconcilerRespectsIndentationStyle(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"1444-10-09\n\t1h", "1444-10-09\n\t1h\n\t30m\n"},
{"1444-10-09\n 1h", "1444-10-09\n 1h\n 30m\n"},
{"1444-10-09\n 1h", "1444-10-09\n 1h\n 30m\n"},
{"1444-10-09\n 1h", "1444-10-09\n 1h\n 30m\n"},
{"1444-10-08\n 3h\n\n1444-10-09\n\t1h", "1444-10-08\n 3h\n\n1444-10-09\n\t1h\n\t30m\n"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(1444, 10, 9))(rs, bs)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("30m"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
func TestReconcilerRespectsLineEndingStyle(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"1444-10-09\r\n\t1h", "1444-10-09\r\n\t1h\r\n\t30m\r\n"},
{"1444-10-09\r\n\t1h\n\t2h", "1444-10-09\r\n\t1h\n\t2h\r\n\t30m\r\n"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(1444, 10, 9))(rs, bs)
err := reconciler.AppendEntry(klog.Ɀ_EntrySummary_("30m"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
func TestReconcileAddRecordIfOriginalIsEmpty(t *testing.T) {
rs, bs, _ := parser.NewSerialParser().Parse("")
atDate := klog.Ɀ_Date_(2000, 5, 5)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, "2000-05-05\n", result.AllSerialised)
assert.Equal(t, "2000-05-05", result.Record.Date().ToString())
}
func TestReconcileAddRecordIfOriginalContainsOneRecord(t *testing.T) {
rs, bs, _ := parser.NewSerialParser().Parse("1999-12-31")
atDate := klog.Ɀ_Date_(2000, 2, 1)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, "1999-12-31\n\n2000-02-01\n", result.AllSerialised)
assert.Equal(t, "2000-02-01", result.Record.Date().ToString())
}
func TestReconcileNewRecordFromEmptyFile(t *testing.T) {
for _, x := range []struct {
original string
}{
{""},
{"\n"},
{"\n\n"},
{"\n\n\t\n"},
{"\n\n \t\n \t \n "},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
atDate := klog.Ɀ_Date_(1995, 3, 17)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, "1995-03-17\n", result.AllSerialised)
}
}
func TestReconcilePrependNewRecord(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"2018-01-02", "2018-01-01\n\n2018-01-02"},
{"2018-01-02", "2018-01-01\n\n2018-01-02"},
{"2018-01-02\n\t1h", "2018-01-01\n\n2018-01-02\n\t1h"},
{"2018-01-02\n\n", "2018-01-01\n\n2018-01-02\n\n"},
{"\n\n2018-01-02\n", "2018-01-01\n\n\n\n2018-01-02\n"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
func TestReconcileAppendNewRecord(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"2018-01-01", "2018-01-01\n\n2019-01-01\n"},
{"2018-01-01\n\n", "2018-01-01\n\n2019-01-01\n\n"},
{"\n\n2018-01-01\n", "\n\n2018-01-01\n\n2019-01-01\n"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
atDate := klog.Ɀ_Date_(2019, 1, 1)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
func TestReconcileAddBlockInBetween(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"2018-01-01\n\n2018-01-03", "2018-01-01\n\n2018-01-02\n\n2018-01-03"},
{"2018-01-01\n\n\n2018-01-03", "2018-01-01\n\n2018-01-02\n\n\n2018-01-03"},
{"2018-01-02\n\t1h\n\n2018-01-03", "2018-01-02\n\t1h\n\n2018-01-02\n\n2018-01-03"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
atDate := klog.Ɀ_Date_(2018, 1, 2)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
func TestReconcileAddRecordWithShouldTotal(t *testing.T) {
original := `
2018-01-01
1h`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 2)
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{ShouldTotal: klog.NewShouldTotal(5, 31)})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, `
2018-01-01
1h
2018-01-02 (5h31m!)
`, result.AllSerialised)
assert.Equal(t, klog.NewShouldTotal(5, 31), result.Record.ShouldTotal())
}
func TestReconcileAddRecordWithSummary(t *testing.T) {
original := `
2018-01-01
1h`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 2)
summary := klog.Ɀ_RecordSummary_("This is a new record.", "It has a summary.")
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{Summary: summary})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, `
2018-01-01
1h
2018-01-02
This is a new record.
It has a summary.
`, result.AllSerialised)
assert.Equal(t, result.Record.Summary(), summary)
}
func TestReconcileAddRecordWithUTF8Summary(t *testing.T) {
original := `
2018-01-01
Þetta er gömul færsla.
1h Ein klukkustund.
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 2)
summary := klog.Ɀ_RecordSummary_("Þetta er ný færsla.", "Það hefur margar línur.")
reconciler := NewReconcilerForNewRecord(atDate, NoReformat[klog.DateFormat](), AdditionalData{Summary: summary})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, `
2018-01-01
Þetta er gömul færsla.
1h Ein klukkustund.
2018-01-02
Þetta er ný færsla.
Það hefur margar línur.
`, result.AllSerialised)
assert.Equal(t, result.Record.Summary(), summary)
}
func TestReconcileDetectsExistingStylePref(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"3145/06/15\n", "3145/06/15\n\n3145/06/16\n"},
{"3145/06/14\n\n3145/06/15\n\n3145-06-15\n", "3145/06/14\n\n3145/06/15\n\n3145-06-15\n\n3145/06/16\n"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
atDate := klog.Ɀ_Date_(3145, 6, 16)
reconciler := NewReconcilerForNewRecord(atDate, ReformatAutoStyle[klog.DateFormat](), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
func TestReconcileRespectsExplicitStylePref(t *testing.T) {
for _, x := range []struct {
original string
expected string
}{
{"3145/06/15\n", "3145/06/15\n\n3145-06-16\n"},
{"3145/06/14\n\n3145/06/15\n\n3145-06-15\n", "3145/06/14\n\n3145/06/15\n\n3145-06-15\n\n3145-06-16\n"},
} {
rs, bs, _ := parser.NewSerialParser().Parse(x.original)
atDate := klog.Ɀ_Date_(3145, 6, 16)
reconciler := NewReconcilerForNewRecord(atDate, ReformatExplicitly[klog.DateFormat](klog.DateFormat{UseDashes: true}), AdditionalData{})(rs, bs)
result, err := reconciler.MakeResult()
require.Nil(t, err)
assert.Equal(t, x.expected, result.AllSerialised)
}
}
07070100000081000081A40000000000000000000000016863F92F000006DF000000000000000000000000000000000000003500000000klog-6.6/klog/parser/reconciling/pause_open_range.gopackage reconciling
import (
"errors"
"github.com/jotaen/klog/klog"
"regexp"
"strings"
)
// AppendPause adds a new pause entry to a record that contains an open range.
func (r *Reconciler) AppendPause(summary klog.EntrySummary, appendTags bool) error {
openEntryI := r.findOpenRangeIndex()
if openEntryI == -1 {
return errors.New("No open time range found")
}
entryValue := "-0m"
if len(summary) == 0 {
summary, _ = klog.NewEntrySummary("")
}
if len(summary[0]) > 0 {
entryValue += " "
}
summary[0] = entryValue + summary[0]
if appendTags {
openEntry := r.Record.Entries()[openEntryI]
appendableTags := strings.Join(openEntry.Summary().Tags().ToStrings(), " ")
summary = summary.Append(appendableTags)
}
return r.AppendEntry(summary)
}
// ExtendPause extends an existing pause entry.
func (r *Reconciler) ExtendPause(increment klog.Duration) error {
if r.findOpenRangeIndex() == -1 {
return errors.New("No open time range found")
}
pauseEntryI := r.findLastEntry(func(e klog.Entry) bool {
return klog.Unbox[bool](&e, func(_ klog.Range) bool {
return false
}, func(d klog.Duration) bool {
return d.InMinutes() <= 0
}, func(_ klog.OpenRange) bool {
return false
})
})
if pauseEntryI == -1 {
return errors.New("Could not find existing pause to extend")
}
extendedPause := r.Record.Entries()[pauseEntryI].Duration().Plus(increment)
pauseLineIndex := r.lastLinePointer - countLines(r.Record.Entries()[pauseEntryI:])
durationPattern := regexp.MustCompile(`(-\w+)`)
value := durationPattern.FindString(r.lines[pauseLineIndex].Text)
if extendedPause.InMinutes() != 0 {
r.lines[pauseLineIndex].Text = strings.Replace(r.lines[pauseLineIndex].Text, value, extendedPause.ToString(), 1)
}
return nil
}
07070100000082000081A40000000000000000000000016863F92F00001EF1000000000000000000000000000000000000003A00000000klog-6.6/klog/parser/reconciling/pause_open_range_test.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestReconcilerAppendingPauseAddsNewEntry(t *testing.T) {
original := `
2010-04-27
3:00pm - ?
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(nil, false)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - ?
-0m
`, result.AllSerialised)
}
func TestReconcilerAppendingPauseWithSummary(t *testing.T) {
original := `
2010-04-27
3:00pm - ?
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(klog.Ɀ_EntrySummary_("Lunch break"), false)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - ?
-0m Lunch break
`, result.AllSerialised)
}
func TestReconcilerAppendingPauseTakesOverTags(t *testing.T) {
original := `
2010-04-27
3:00pm - ? Did some #work
and also #misc other #THINGS=Thingy
and then more #work (redundant tag)
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(nil, true)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - ? Did some #work
and also #misc other #THINGS=Thingy
and then more #work (redundant tag)
-0m #work #misc #things=Thingy #work
`, result.AllSerialised)
}
func TestReconcilerAppendingPauseTakesOverTagsAndConcatsWithSummary(t *testing.T) {
original := `
2010-04-27
3:00pm - ? Did some #work
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(klog.Ɀ_EntrySummary_("Lunch break"), true)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - ? Did some #work
-0m Lunch break #work
`, result.AllSerialised)
}
func TestReconcilerAppendingPauseWithMultilineSummary(t *testing.T) {
original := `
2010-04-27
3:00pm - ?
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(klog.Ɀ_EntrySummary_("Lunch", "break"), false)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
3:00pm - ?
-0m Lunch
break
`, result.AllSerialised)
}
func TestReconcilerAppendPauseWithUTF8Summary(t *testing.T) {
original := `
2010-04-27
你好!你好吗?
8:00 - ? 去工作
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(klog.Ɀ_EntrySummary_("午休"), false)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
你好!你好吗?
8:00 - ? 去工作
-0m 午休
`, result.AllSerialised)
}
func TestReconcilerAppendingPauseFailsIfThereIsNoOpenRange(t *testing.T) {
original := `
2010-04-27
3:00 - 4:00
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.AppendPause(nil, false)
require.Error(t, err)
}
func TestReconcilerExtendingPauseExtendsPause(t *testing.T) {
original := `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-30m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(0, -3))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-33m
`, result.AllSerialised)
}
func TestReconcilerExtendingPauseWithSummary(t *testing.T) {
original := `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-1m Lunch break
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(0, -3))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-4m Lunch break
`, result.AllSerialised)
}
func TestReconcilerExtendingPauseWithSummaryOnNextLine(t *testing.T) {
original := `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-1h Lunch break
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(-1, 0))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-2h Lunch break
`, result.AllSerialised)
}
func TestReconcilerExtendingPauseWithMultilineSummary(t *testing.T) {
original := `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-1h Lunch
break
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(-1, 0))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-2h Lunch
break
`, result.AllSerialised)
}
func TestReconcilerExtendingPauseExtendsLastPause(t *testing.T) {
original := `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-30m
-30m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(-2, -51))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-30m
-3h21m
`, result.AllSerialised)
}
func TestReconcilerExtendingPauseOfZeroIsNoop(t *testing.T) {
original := `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-0m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(0, 0))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2010-04-27
Foo
3:00 - ? I desperately need
a break!
-0m
`, result.AllSerialised)
}
func TestReconcilerExtendingPauseFailsIfThereIsNoOpenRange(t *testing.T) {
original := `
2010-04-27
3:00 - 4:00
-30m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(2, 0))
require.Error(t, err)
}
func TestReconcilerDoesNotExtendNonNegativeDurations(t *testing.T) {
original := `
2010-04-27
3:00 - ?
30m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
reconciler := NewReconcilerAtRecord(klog.Ɀ_Date_(2010, 4, 27))(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.ExtendPause(klog.NewDuration(0, -10))
require.Error(t, err)
}
07070100000083000081A40000000000000000000000016863F92F00001350000000000000000000000000000000000000002F00000000klog-6.6/klog/parser/reconciling/reconciler.go/*
Package reconciling contains logic to manipulate klog source text.
The idea of the reconciler generally is to add or modify serialised records
in a minimally invasive manner. Instead or re-serialising the record itself,
it tries to find the location in the original text and modify that directly.
While this approach might feel a little hacky, it avoids lots of other
complications and sources of bugs, that could potentially mess up user data.
*/
package reconciling
import (
"errors"
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/txt"
"strings"
)
// Reconciler is a mechanism to manipulate record data in a file.
type Reconciler struct {
Record klog.Record
style *style
lastLinePointer int // Line index of the last entry
lines []txt.Line
recordPointer int
}
// Result is the result of an applied reconciler.
type Result struct {
Record klog.Record
AllRecords []klog.Record
AllSerialised string
}
// Reconcile is a function interface for applying a reconciler.
type Reconcile func(*Reconciler) error
func countLines(es []klog.Entry) int {
result := 0
for _, e := range es {
result += len(e.Summary())
}
return result
}
// MakeResult returns the reconciled data.
func (r *Reconciler) MakeResult() (*Result, error) {
text := ""
for _, l := range r.lines {
text += l.Original()
}
// As a safeguard, make sure the result is parseable.
newRecords, _, errs := parser.NewSerialParser().Parse(text)
if errs != nil {
return nil, errors.New("This operation wouldn’t result in a valid record")
}
return &Result{
Record: newRecords[r.recordPointer],
AllRecords: newRecords,
AllSerialised: text,
}, nil
}
// findOpenRangeIndex returns the index of the open range entry, or -1 if no open range.
func (r *Reconciler) findOpenRangeIndex() int {
return r.findLastEntry(func(e klog.Entry) bool {
return klog.Unbox[bool](&e,
func(klog.Range) bool { return false },
func(klog.Duration) bool { return false },
func(klog.OpenRange) bool { return true },
)
})
}
// findLastEntry finds the last entry that matches the predicate, or -1 if none match.
func (r *Reconciler) findLastEntry(match func(klog.Entry) bool) int {
candidate := -1
for i, e := range r.Record.Entries() {
if match(e) {
candidate = i
}
}
return candidate
}
// concatenateSummary adds summary text to an existing entry that potentially already has one ore
// more lines of summary text.
func (r *Reconciler) concatenateSummary(entryIndex int, entryLineIndex int, additionalSummary klog.EntrySummary) {
// Append additional summary text. Due to multiline entry summaries, that might
// not be the same line as the time value.
lineIndexOfLastSummaryLine := entryLineIndex + countLines([]klog.Entry{r.Record.Entries()[entryIndex]}) - 1
if len(additionalSummary) > 0 {
if len(additionalSummary[0]) > 0 {
// If there is additional summary text, always prepend a space to delimit
// the additional summary from either the time value or from an already
// existing summary text.
r.lines[lineIndexOfLastSummaryLine].Text += " "
}
r.lines[lineIndexOfLastSummaryLine].Text += additionalSummary[0]
}
if len(additionalSummary) > 1 {
var subsequentSummaryLines []insertableText
for _, nextLine := range additionalSummary[1:] {
subsequentSummaryLines = append(subsequentSummaryLines, insertableText{nextLine, 2})
}
r.insert(lineIndexOfLastSummaryLine+1, subsequentSummaryLines)
}
}
var blankLine = insertableText{"", 0}
type insertableText struct {
text string
indentation int
}
func (r *Reconciler) insert(lineIndex int, texts []insertableText) {
result := make([]txt.Line, len(r.lines)+len(texts))
offset := 0
for i := range result {
if i >= lineIndex && offset < len(texts) {
line := strings.Repeat(r.style.indentation.Get(), texts[offset].indentation)
line += texts[offset].text + r.style.lineEnding.Get()
result[i] = txt.NewLineFromString(line)
offset++
} else {
result[i] = r.lines[i-offset]
}
}
if lineIndex > 0 && result[lineIndex-1].LineEnding == "" {
result[lineIndex-1].LineEnding = r.style.lineEnding.Get()
}
r.lines = result
}
func toMultilineEntryTexts(entryValue string, entrySummary klog.EntrySummary) []insertableText {
var result []insertableText
firstLine := func() string {
text := entryValue
// Make sure that there is a space between entry value and the subsequent
// summary text. However, there shouldn’t be dangling spaces, in case either
// value would be absent.
if len(entrySummary) > 0 {
if len(text) > 0 && len(entrySummary[0]) > 0 {
text += " "
}
text += entrySummary[0]
entrySummary = entrySummary[1:]
}
return text
}()
result = append(result, insertableText{firstLine, 1})
for _, s := range entrySummary {
result = append(result, insertableText{s, 2})
}
return result
}
07070100000084000081A40000000000000000000000016863F92F00000346000000000000000000000000000000000000003500000000klog-6.6/klog/parser/reconciling/start_open_range.gopackage reconciling
import (
"errors"
"github.com/jotaen/klog/klog"
)
// StartOpenRange appends a new open range entry in a record.
func (r *Reconciler) StartOpenRange(startTime klog.Time, format ReformatDirective[klog.TimeFormat], entrySummary klog.EntrySummary) error {
if r.findOpenRangeIndex() != -1 {
return errors.New("There is already an open range in this record")
}
format.apply(r.style.timeFormat(), func(f klog.TimeFormat) {
// Re-parse time to apply format.
reformattedTime, err := klog.NewTimeFromString(startTime.ToStringWithFormat(f))
if err != nil {
panic("Invalid time")
}
startTime = reformattedTime
})
or := klog.NewOpenRangeWithFormat(startTime, r.style.openRangeFormat())
entryValue := or.ToString()
r.insert(r.lastLinePointer, toMultilineEntryTexts(entryValue, entrySummary))
return nil
}
07070100000085000081A40000000000000000000000016863F92F00001235000000000000000000000000000000000000003A00000000klog-6.6/klog/parser/reconciling/start_open_range_test.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestReconcilerStartsOpenRange(t *testing.T) {
original := `
2018-01-01
5h22m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(8, 3)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, NoReformat[klog.TimeFormat](), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
5h22m
8:03 - ?
`, result.AllSerialised)
}
func TestReconcilerStartsOpenRangeWithSummary(t *testing.T) {
original := `
2018-01-01
5h22m
Existing entry
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(8, 3)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("Test"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
5h22m
Existing entry
8:03 - ? Test
`, result.AllSerialised)
}
func TestReconcilerStartsOpenRangeWithUTF8Summary(t *testing.T) {
original := `
2018-01-01
ኣብ ቤት ጽሕፈት ሓደ መዓልቲ።
8:00 - 12:00 ንኽሰርሕ
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(12, 00)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("ናይ ምሳሕ ዕረፍቲ"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
ኣብ ቤት ጽሕፈት ሓደ መዓልቲ።
8:00 - 12:00 ንኽሰርሕ
12:00 - ? ናይ ምሳሕ ዕረፍቲ
`, result.AllSerialised)
}
func TestReconcilerStartsOpenRangeWithNewMultilineSummary(t *testing.T) {
original := `
2018-01-01
5h22m
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(8, 3)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, NoReformat[klog.TimeFormat](), klog.Ɀ_EntrySummary_("", "Started...", "something!"))
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
5h22m
8:03 - ?
Started...
something!
`, result.AllSerialised)
}
func TestReconcilerStartsOpenRangeWithAutoStyleFromSameRecord(t *testing.T) {
original := `
2018-01-01
2:00am-3:00am
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 1)
atTime := klog.Ɀ_Time_(8, 3)
reconciler := NewReconcilerAtRecord(atDate)(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, ReformatAutoStyle[klog.TimeFormat](), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
// Conforms to both am/pm and spaces around dash
assert.Equal(t, `
2018-01-01
2:00am-3:00am
8:03am-?
`, result.AllSerialised)
}
func TestReconcilerStartsOpenRangeWithAutoStyleFromOtherRecord(t *testing.T) {
original := `
2018/01/01
2:00am-?????????????????
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Date_(2018, 1, 2)
atTime := klog.Ɀ_Time_(8, 3)
reconciler := NewReconcilerForNewRecord(atDate, ReformatAutoStyle[klog.DateFormat](), AdditionalData{})(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, ReformatAutoStyle[klog.TimeFormat](), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
// Conforms to both am/pm and spaces around dash
assert.Equal(t, `
2018/01/01
2:00am-?????????????????
2018/01/02
8:03am-?????????????????
`, result.AllSerialised)
}
func TestReconcilerStartsOpenRangeExplicitFormatOverrulesAutoFormat(t *testing.T) {
original := `
2018-01-01
`
rs, bs, _ := parser.NewSerialParser().Parse(original)
atDate := klog.Ɀ_Slashes_(klog.Ɀ_Date_(2018, 1, 2))
atTime := klog.Ɀ_IsAmPm_(klog.Ɀ_Time_(8, 3))
reconciler := NewReconcilerForNewRecord(atDate, ReformatExplicitly[klog.DateFormat](atDate.Format()), AdditionalData{})(rs, bs)
require.NotNil(t, reconciler)
err := reconciler.StartOpenRange(atTime, ReformatExplicitly[klog.TimeFormat](atTime.Format()), nil)
require.Nil(t, err)
result := assertResult(t, reconciler)
assert.Equal(t, `
2018-01-01
2018/01/02
8:03am - ?
`, result.AllSerialised)
}
07070100000086000081A40000000000000000000000016863F92F00001511000000000000000000000000000000000000002A00000000klog-6.6/klog/parser/reconciling/style.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser/txt"
)
// style describes the general styling and formatting preferences of a record.
type style struct {
lineEnding styleProp[string]
indentation styleProp[string]
dateUseDashes styleProp[bool]
timeUse24HourClock styleProp[bool]
rangesUseSpacesAroundDash styleProp[bool]
openRangeAdditionalPlaceholderChars styleProp[int]
}
func (s *style) dateFormat() klog.DateFormat {
f := klog.DefaultDateFormat()
f.UseDashes = s.dateUseDashes.Get()
return f
}
func (s *style) timeFormat() klog.TimeFormat {
f := klog.DefaultTimeFormat()
f.Use24HourClock = s.timeUse24HourClock.Get()
return f
}
func (s *style) openRangeFormat() klog.OpenRangeFormat {
f := klog.DefaultOpenRangeFormat()
f.UseSpacesAroundDash = s.rangesUseSpacesAroundDash.Get()
f.AdditionalPlaceholderChars = s.openRangeAdditionalPlaceholderChars.Get()
return f
}
func determine(r klog.Record, b txt.Block) *style {
s := defaultStyle()
s.dateUseDashes.Set(r.Date().Format().UseDashes)
for _, e := range r.Entries() {
klog.Unbox[any](&e, func(r klog.Range) any {
s.timeUse24HourClock.Set(r.Start().Format().Use24HourClock)
s.rangesUseSpacesAroundDash.Set(r.Format().UseSpacesAroundDash)
return nil
}, func(d klog.Duration) any {
return nil
}, func(o klog.OpenRange) any {
s.timeUse24HourClock.Set(o.Start().Format().Use24HourClock)
s.rangesUseSpacesAroundDash.Set(o.Format().UseSpacesAroundDash)
s.openRangeAdditionalPlaceholderChars.Set(o.Format().AdditionalPlaceholderChars)
return nil
})
}
for _, l := range b.Lines() {
if l.Indentation() != "" {
s.indentation.Set(l.Indentation())
break
}
}
if len(b.Lines()) > 0 && b.Lines()[0].LineEnding != "" {
s.lineEnding.Set(b.Lines()[0].LineEnding)
}
return s
}
type styleProp[T any] struct {
value T
isExplicit bool
}
func (p *styleProp[T]) Set(value T) {
p.value = value
p.isExplicit = true
}
func (p *styleProp[T]) Get() T {
return p.value
}
// defaultStyle returns the canonical style preferences as recommended
// by the file format specification.
func defaultStyle() *style {
return &style{
lineEnding: styleProp[string]{"\n", false},
indentation: styleProp[string]{" ", false},
dateUseDashes: styleProp[bool]{klog.DefaultDateFormat().UseDashes, false},
timeUse24HourClock: styleProp[bool]{klog.DefaultTimeFormat().Use24HourClock, false},
rangesUseSpacesAroundDash: styleProp[bool]{klog.DefaultRangeFormat().UseSpacesAroundDash, false},
openRangeAdditionalPlaceholderChars: styleProp[int]{klog.DefaultOpenRangeFormat().AdditionalPlaceholderChars, false},
}
}
type election[T comparable] struct {
votes map[T]int
}
func newElection[T comparable]() election[T] {
return election[T]{make(map[T]int)}
}
// vote casts a vote for the style, but only if it’s explicit.
func (e *election[T]) vote(style styleProp[T]) {
if !style.isExplicit {
return
}
e.votes[style.value] += 1
}
// tallyUp returns the style that’s most voted for.
func (e *election[T]) tallyUp(defaultValue T) T {
max := 0
result := defaultValue
for value, count := range e.votes {
if count > max {
max = count
result = value
}
}
return result
}
// ascertain finds the prevailing style, which is either the explicit default style,
// or the tallied-up election.
func ascertain[T comparable](e *election[T], defaultStyle styleProp[T]) styleProp[T] {
if defaultStyle.isExplicit {
return defaultStyle
}
return styleProp[T]{e.tallyUp(defaultStyle.Get()), true}
}
// elect fills all unset fields of the `defaults` style with that value
// which was encountered most often in the parsed records. Fields of the
// `base` style that had been set explicitly take precedence.
func elect(base style, rs []klog.Record, bs []txt.Block) *style {
if len(rs) != len(bs) {
panic("Internal error")
}
lineEndingElection := newElection[string]()
indentationElection := newElection[string]()
dateUseDashes := newElection[bool]()
timeUse24HourClock := newElection[bool]()
rangesUseSpacesAroundDash := newElection[bool]()
openRangeAdditionalPlaceholderChars := newElection[int]()
for i, r := range rs {
s := determine(r, bs[i])
lineEndingElection.vote(s.lineEnding)
indentationElection.vote(s.indentation)
dateUseDashes.vote(s.dateUseDashes)
timeUse24HourClock.vote(s.timeUse24HourClock)
rangesUseSpacesAroundDash.vote(s.rangesUseSpacesAroundDash)
openRangeAdditionalPlaceholderChars.vote(s.openRangeAdditionalPlaceholderChars)
}
return &style{
lineEnding: ascertain[string](&lineEndingElection, base.lineEnding),
indentation: ascertain[string](&indentationElection, base.indentation),
dateUseDashes: ascertain[bool](&dateUseDashes, base.dateUseDashes),
timeUse24HourClock: ascertain[bool](&timeUse24HourClock, base.timeUse24HourClock),
rangesUseSpacesAroundDash: ascertain[bool](&rangesUseSpacesAroundDash, base.rangesUseSpacesAroundDash),
openRangeAdditionalPlaceholderChars: ascertain[int](&openRangeAdditionalPlaceholderChars, base.openRangeAdditionalPlaceholderChars),
}
}
07070100000087000081A40000000000000000000000016863F92F000005A9000000000000000000000000000000000000003300000000klog-6.6/klog/parser/reconciling/style_reformat.gopackage reconciling
import "github.com/jotaen/klog/klog"
// ReformatDirective tells the reconciler whether or in which way the
// date or time value is supposed to be reformatted when serialising it.
type ReformatDirective[T klog.TimeFormat | klog.DateFormat] struct {
Value T
mode int // 0 = Don’t do anything; 1 = Reformat from `Value`; 2 = Apply auto-styling
}
// NoReformat means the time/date value should be taken as is, without touching
// its own existing format.
func NoReformat[T klog.TimeFormat | klog.DateFormat]() ReformatDirective[T] {
return ReformatDirective[T]{mode: 0}
}
// ReformatExplicitly means that the time/date value should be reformatted
// according to the provided format.
func ReformatExplicitly[T klog.TimeFormat | klog.DateFormat](value T) ReformatDirective[T] {
return ReformatDirective[T]{Value: value, mode: 1}
}
// ReformatAutoStyle means that the time/date value should be reformatted
// in accordance to what prevalent style the reconciler detects in the file.
// If the style cannot be determined from the file, it falls back to the
// recommended style (as of the file format specification).
func ReformatAutoStyle[T klog.TimeFormat | klog.DateFormat]() ReformatDirective[T] {
return ReformatDirective[T]{mode: 2}
}
func (r ReformatDirective[T]) apply(autoStyle T, reformat func(T)) {
if r.mode == 0 {
return
}
format := autoStyle
if r.mode == 1 {
format = r.Value
}
reformat(format)
}
07070100000088000081A40000000000000000000000016863F92F00001108000000000000000000000000000000000000002F00000000klog-6.6/klog/parser/reconciling/style_test.gopackage reconciling
import (
"github.com/jotaen/klog/klog"
"github.com/jotaen/klog/klog/parser"
"github.com/jotaen/klog/klog/parser/txt"
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
func TestDefaultStyle(t *testing.T) {
assert.Equal(t, &style{
lineEnding: styleProp[string]{"\n", false},
indentation: styleProp[string]{" ", false},
dateUseDashes: styleProp[bool]{true, false},
timeUse24HourClock: styleProp[bool]{true, false},
rangesUseSpacesAroundDash: styleProp[bool]{true, false},
openRangeAdditionalPlaceholderChars: styleProp[int]{0, false},
}, defaultStyle())
}
func TestDetectsStyleFromMinimalFile(t *testing.T) {
rs, bs := parseOrPanic("2000-01-01")
s := determine(rs[0], bs[0])
assert.Equal(t, &style{
lineEnding: styleProp[string]{"\n", false},
indentation: styleProp[string]{" ", false},
dateUseDashes: styleProp[bool]{true, true},
timeUse24HourClock: styleProp[bool]{true, false},
rangesUseSpacesAroundDash: styleProp[bool]{true, false},
openRangeAdditionalPlaceholderChars: styleProp[int]{0, false},
}, s)
}
func TestDetectCanonicalStyle(t *testing.T) {
rs, bs := parseOrPanic("2000-01-01\nTest\n 8:00 - ?\n")
s := determine(rs[0], bs[0])
assert.Equal(t, &style{
lineEnding: styleProp[string]{"\n", true},
indentation: styleProp[string]{" ", true},
dateUseDashes: styleProp[bool]{true, true},
timeUse24HourClock: styleProp[bool]{true, true},
rangesUseSpacesAroundDash: styleProp[bool]{true, true},
openRangeAdditionalPlaceholderChars: styleProp[int]{0, true},
}, s)
}
func TestDetectsCustomStyle(t *testing.T) {
rs, bs := parseOrPanic("2000/01/01\r\nTest\r\n\t8:00am-?????\r\n")
s := determine(rs[0], bs[0])
assert.Equal(t, &style{
lineEnding: styleProp[string]{"\r\n", true},
indentation: styleProp[string]{"\t", true},
dateUseDashes: styleProp[bool]{false, true},
timeUse24HourClock: styleProp[bool]{false, true},
rangesUseSpacesAroundDash: styleProp[bool]{false, true},
openRangeAdditionalPlaceholderChars: styleProp[int]{4, true},
}, s)
}
func TestElectStyle(t *testing.T) {
rs, bs := parseOrPanic(
"2001-05-19\n\t1:00 - 2:00\n\n",
"2001/05/19\r\n 1:00am-2:00pm\r\n\r\n",
"2001-05-19\n 1:00am-2:00pm\n 2:00pm-3:00pm\n\n",
"2001/05/19\r\n 1:00 - 2:00\r\n\r\n",
"2001-05-19\r\n 1:00am-???\r\n\r\n",
)
result := elect(*defaultStyle(), rs, bs)
assert.Equal(t, &style{
lineEnding: styleProp[string]{"\r\n", true},
indentation: styleProp[string]{" ", true},
dateUseDashes: styleProp[bool]{true, true},
timeUse24HourClock: styleProp[bool]{false, true},
rangesUseSpacesAroundDash: styleProp[bool]{false, true},
openRangeAdditionalPlaceholderChars: styleProp[int]{2, true},
}, result)
}
func TestElectStyleDoesNotOverrideSetPreferences(t *testing.T) {
majorityRs, majorityBs := parseOrPanic(
"2001-05-19\n\t1:00 - 2:00\n\n",
"2001/05/19\r\n 1:00am-2:00pm\r\n\r\n",
"2001-05-19\n 1:00am-2:00pm\n 2:00pm-3:00pm\n\n",
"2001/05/19\r\n 1:00 - 2:00\r\n\r\n",
"2001-05-19\r\n 1:00am-2:00pm\r\n\r\n",
)
winnerRs, winnerBs := parseOrPanic("2018/01/01\n\t8:00 - 9:00")
result := elect(*determine(winnerRs[0], winnerBs[0]), majorityRs, majorityBs)
assert.Equal(t, &style{
lineEnding: styleProp[string]{"\n", true},
indentation: styleProp[string]{"\t", true},
dateUseDashes: styleProp[bool]{false, true},
timeUse24HourClock: styleProp[bool]{true, true},
rangesUseSpacesAroundDash: styleProp[bool]{true, true},
openRangeAdditionalPlaceholderChars: styleProp[int]{0, true},
}, result)
}
func parseOrPanic(recordsAsText ...string) ([]klog.Record, []txt.Block) {
rs, bs, err := parser.NewSerialParser().Parse(strings.Join(recordsAsText, ""))
if err != nil {
panic("Invalid data")
}
return rs, bs
}
07070100000089000081A40000000000000000000000016863F92F000008A0000000000000000000000000000000000000002300000000klog-6.6/klog/parser/serialiser.gopackage parser
import (
"github.com/jotaen/klog/klog"
"strings"
)
// Serialiser is used when the output should be modified, e.g. coloured.
type Serialiser interface {
Date(klog.Date) string
ShouldTotal(klog.Duration) string
Summary(SummaryText) string
Range(klog.Range) string
OpenRange(klog.OpenRange) string
Duration(klog.Duration) string
SignedDuration(klog.Duration) string
Time(klog.Time) string
}
type Line struct {
Text string
Record klog.Record
EntryI int
}
type Lines []Line
var canonicalLineEnding = "\n"
var canonicalIndentation = " "
func (ls Lines) ToString() string {
result := ""
for _, l := range ls {
result += l.Text + canonicalLineEnding
}
return result
}
// SerialiseRecords serialises records into the canonical string representation.
// (So it doesn’t and cannot restore the original formatting!)
func SerialiseRecords(s Serialiser, rs ...klog.Record) Lines {
var lines []Line
for i, r := range rs {
lines = append(lines, serialiseRecord(s, r)...)
if i < len(rs)-1 {
lines = append(lines, Line{"", nil, -1})
}
}
return lines
}
func serialiseRecord(s Serialiser, r klog.Record) []Line {
var lines []Line
headline := s.Date(r.Date())
if r.ShouldTotal().InMinutes() != 0 {
headline += " (" + s.ShouldTotal(r.ShouldTotal()) + ")"
}
lines = append(lines, Line{headline, r, -1})
for _, l := range r.Summary().Lines() {
lines = append(lines, Line{s.Summary([]string{l}), r, -1})
}
for entryI, e := range r.Entries() {
entryValue := klog.Unbox[string](&e,
func(r klog.Range) string { return s.Range(r) },
func(d klog.Duration) string { return s.Duration(d) },
func(o klog.OpenRange) string { return s.OpenRange(o) },
)
lines = append(lines, Line{canonicalIndentation + entryValue, r, entryI})
for i, l := range e.Summary().Lines() {
summaryText := s.Summary([]string{l})
if i == 0 && l != "" {
lines[len(lines)-1].Text += " " + summaryText
} else if i >= 1 {
lines = append(lines, Line{canonicalIndentation + canonicalIndentation + summaryText, r, entryI})
}
}
}
return lines
}
type SummaryText []string
func (s SummaryText) ToString() string {
return strings.Join(s, canonicalLineEnding)
}
0707010000008A000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001900000000klog-6.6/klog/parser/txt0707010000008B000081A40000000000000000000000016863F92F00000C12000000000000000000000000000000000000002200000000klog-6.6/klog/parser/txt/block.gopackage txt
import "unicode/utf8"
// Block is multiple consecutive lines with text, with no blank lines
// in between, but possibly one or more blank lines before or after.
// It’s basically like a paragraph of text, with surrounding whitespace.
// The Block is guaranteed to contain exactly a single sequence of
// significant lines, i.e. lines that contain text.
type Block interface {
// Lines returns all lines.
Lines() []Line
// SignificantLines returns the lines that are not blank. The two integers
// are the number of insignificant lines at the beginning and the end.
SignificantLines() (significant []Line, headCount int, tailCount int)
// OverallLineIndex returns the overall line index, taking into
// account the context of all preceding blocks.
OverallLineIndex(int) int
// SetPrecedingLineCount adjusts the overall line count.
SetPrecedingLineCount(int)
}
type block struct {
precedingLineCount int
lines []Line
}
// ParseBlock parses a block from the beginning of a text. It returns
// the parsed block, along with the number of bytes consumed from the
// string. If the text doesn’t contain significant lines, it returns nil.
func ParseBlock(text string, precedingLineCount int) (Block, int) {
const (
MODE_PRECEDING_BLANK_LINES = iota
MODE_SIGNIFICANT_LINES
MODE_TRAILING_BLANK_LINES
)
var lines []Line
bytesConsumed := 0
currentLineStart := 0
currentMode := MODE_PRECEDING_BLANK_LINES
_, lastRuneSize := utf8.DecodeLastRuneInString(text)
// Parse text line-wise.
parsingLoop:
for i, char := range text { // Note: char is a UTF-8 rune
if char != '\n' && i+lastRuneSize != len(text) {
continue
}
// Process line.
nextChar := i + len(string(char))
currentLine := text[currentLineStart:nextChar]
line := NewLineFromString(currentLine)
switch currentMode {
case MODE_PRECEDING_BLANK_LINES:
if !line.IsBlank() {
currentMode = MODE_SIGNIFICANT_LINES
}
case MODE_SIGNIFICANT_LINES:
if line.IsBlank() {
currentMode = MODE_TRAILING_BLANK_LINES
}
case MODE_TRAILING_BLANK_LINES:
if !line.IsBlank() {
break parsingLoop
}
}
lines = append(lines, line)
bytesConsumed += len(currentLine)
currentLineStart = nextChar
}
hasSignificantLines := currentMode != MODE_PRECEDING_BLANK_LINES
if !hasSignificantLines {
return nil, bytesConsumed
}
return &block{precedingLineCount, lines}, bytesConsumed
}
func (b *block) OverallLineIndex(lineIndex int) int {
return b.precedingLineCount + lineIndex
}
func (b *block) SetPrecedingLineCount(count int) {
b.precedingLineCount = count
}
func (b *block) Lines() []Line {
return b.lines
}
func (b *block) SignificantLines() (significant []Line, headCount int, tailCount int) {
first, last := 0, len(b.lines)
hasSeenSignificant := false
for i, l := range b.lines {
if !hasSeenSignificant && !l.IsBlank() {
first = i
hasSeenSignificant = true
continue
}
if hasSeenSignificant && l.IsBlank() {
last = i
break
}
}
significant = b.lines[first:last]
return significant, first, len(b.lines) - last
}
0707010000008C000081A40000000000000000000000016863F92F00000752000000000000000000000000000000000000002700000000klog-6.6/klog/parser/txt/block_test.gopackage txt
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestGroupEmptyInput(t *testing.T) {
for _, ls := range []string{
``,
"\n \n\t\t\n ",
} {
block, _ := ParseBlock(ls, 0)
assert.Nil(t, block)
}
}
func TestParseBlock(t *testing.T) {
for _, x := range []struct {
text string
expect []string // expected significant line contents
expectHead int
expectTail int
}{
// Single line
{"a", []string{"a"}, 0, 0},
{"\nfoo", []string{"foo"}, 1, 0},
{"\n12345\n", []string{"12345"}, 1, 0},
{" \ntest ", []string{"test "}, 1, 0},
{" \na\ta\n", []string{"a\ta"}, 1, 0},
{"\t\na1\n\t \t ", []string{"a1"}, 1, 1},
{"\n\na1\n\n", []string{"a1"}, 2, 1},
{"喜左衛門", []string{"喜左衛門"}, 0, 0},
{"喜左衛門\n", []string{"喜左衛門"}, 0, 0},
{"\n😀·½\n ", []string{"😀·½"}, 1, 1},
// Multiple lines
{"a1\na2", []string{"a1", "a2"}, 0, 0},
{"\nasdf\nasdf", []string{"asdf", "asdf"}, 1, 0},
{"\nHey 🥰!\n«How is it?»\n", []string{"Hey 🥰!", "«How is it?»"}, 1, 0},
{"\n \t\nA\nB", []string{"A", "B"}, 2, 0},
{"\n \t\na b c \n a b c\n \t \n", []string{"a b c ", " a b c"}, 2, 1},
{"\n \t\n _ \n - \n\n", []string{" _ ", " - "}, 2, 1},
{" \t \t\nAS:FLKJH\n!(@* #&\n\t", []string{"AS:FLKJH", "!(@* #&"}, 1, 1},
{" \n\t\n1—2\n·½⅓•ÄflÑ\n\n\n ", []string{"1—2", "·½⅓•ÄflÑ"}, 2, 3},
} {
b, _ := ParseBlock(x.text, 0)
sgLines, head, tail := b.SignificantLines()
require.NotNil(t, b)
require.Len(t, b.Lines(), len(x.expect)+x.expectHead+x.expectTail)
require.Len(t, sgLines, len(x.expect))
for i, l := range x.expect {
assert.Equal(t, l, sgLines[i].Text)
}
assert.Equal(t, x.expectHead, head)
assert.Equal(t, x.expectTail, tail)
}
}
0707010000008D000081A40000000000000000000000016863F92F000008C7000000000000000000000000000000000000002200000000klog-6.6/klog/parser/txt/error.gopackage txt
// Error contains infos about a parsing error in a Line.
type Error interface {
// Error is an alias for Message.
Error() string
// LineNumber returns the logical line number, as shown in an editor.
LineNumber() int
// LineText is the original text of the line.
LineText() string
// Position is the cursor position in the line, excluding the indentation.
Position() int
// Column is the cursor position in the line, including the indentation.
Column() int
// Length returns the number of erroneous characters.
Length() int
// Code returns a unique identifier of the error kind.
Code() string
// Title returns a short error description.
Title() string
// Details returns additional information, such as hints or further explanations.
Details() string
// Message is a combination of Title and Details.
Message() string
// Origin returns the origin of the error, such as the file name.
Origin() string
SetOrigin(string) Error
}
type err struct {
context Block
origin string
line int
position int
length int
code string
title string
details string
}
func (e *err) Error() string { return e.Message() }
func (e *err) LineNumber() int { return e.context.OverallLineIndex(e.line) + 1 }
func (e *err) LineText() string { return e.context.Lines()[e.line].Text }
func (e *err) Position() int { return e.position }
func (e *err) Column() int { return e.position + 1 }
func (e *err) Length() int { return e.length }
func (e *err) Code() string { return e.code }
func (e *err) Title() string { return e.title }
func (e *err) Details() string { return e.details }
func (e *err) Message() string { return e.title + ": " + e.details }
func (e *err) Origin() string { return e.origin }
func (e *err) SetOrigin(origin string) Error { e.origin = origin; return e }
func NewError(b Block, line int, start int, length int, code string, title string, details string) Error {
return &err{
context: b,
line: line,
position: start,
length: length,
code: code,
title: title,
details: details,
}
}
0707010000008E000081A40000000000000000000000016863F92F000001EB000000000000000000000000000000000000002700000000klog-6.6/klog/parser/txt/error_test.gopackage txt
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestCreateError(t *testing.T) {
block, _ := ParseBlock("Hello World\n", 2)
err := NewError(block, 0, 0, 5, "CODE", "Title", "Details")
assert.Equal(t, "CODE", err.Code())
assert.Equal(t, "Title", err.Title())
assert.Equal(t, "Details", err.Details())
assert.Equal(t, 0, err.Position())
assert.Equal(t, 1, err.Column())
assert.Equal(t, 5, err.Length())
assert.Equal(t, "Title: Details", err.Message())
}
0707010000008F000081A40000000000000000000000016863F92F000004DF000000000000000000000000000000000000002800000000klog-6.6/klog/parser/txt/indentation.gopackage txt
import "strings"
// NewIndentator creates an indentator object, if the given line is indented
// according to the allowed styles.
func NewIndentator(allowedIndentationStyles []string, l Line) *Indentator {
for _, s := range allowedIndentationStyles {
if strings.HasPrefix(l.Text, s) {
return &Indentator{s}
}
}
return nil
}
// Indentator is a utility to check to process indented text. It is initialised
// with the first indented line, and determines the indentation style of that line.
// For all subsequent lines, it can then create Parseable’s that are already pre-
// processed.
type Indentator struct {
indentationStyle string
}
// NewIndentedParseable returns a Parseable with already skipped indentation.
// It returns `nil` if the encountered indentation level is smaller than `atLevel`.
// It only consumes the desired indentation and disregards any additional indentation.
func (i *Indentator) NewIndentedParseable(l Line, atLevel int) *Parseable {
expectedIndentation := strings.Repeat(i.indentationStyle, atLevel)
if !strings.HasPrefix(l.Text, expectedIndentation) {
return nil
}
return NewParseable(l, len(expectedIndentation))
}
func (i *Indentator) Style() string {
return i.indentationStyle
}
07070100000090000081A40000000000000000000000016863F92F00000717000000000000000000000000000000000000002D00000000klog-6.6/klog/parser/txt/indentation_test.gopackage txt
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestCreateIndentatorFromLine(t *testing.T) {
for _, indentator := range []*Indentator{
NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")),
NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")),
NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")),
NewIndentator([]string{"\t"}, NewLineFromString("\tHello")),
} {
require.NotNil(t, indentator)
}
}
func TestCreatesNoIndentatorIfLineIsNotIndentatedAccordingly(t *testing.T) {
for _, indentator := range []*Indentator{
NewIndentator([]string{" ", " "}, NewLineFromString("Hello")),
NewIndentator([]string{" ", " "}, NewLineFromString(" Hello")),
NewIndentator([]string{"\t"}, NewLineFromString(" Hello")),
} {
require.Nil(t, indentator)
}
}
func TestCreatesIndentedParseable(t *testing.T) {
indentator := Indentator{"\t"}
p1 := indentator.NewIndentedParseable(NewLineFromString("Hello"), 0)
require.NotNil(t, p1)
assert.Equal(t, p1.PointerPosition, 0)
assert.Equal(t, []rune{'H', 'e', 'l', 'l', 'o'}, p1.Chars)
p2 := indentator.NewIndentedParseable(NewLineFromString("\tHello"), 1)
require.NotNil(t, p2)
assert.Equal(t, 1, p2.PointerPosition)
p3 := indentator.NewIndentedParseable(NewLineFromString("\t\tHello"), 1)
require.NotNil(t, p3)
assert.Equal(t, 1, p3.PointerPosition)
}
func TestCreatesNoParseableForIndentationMismatch(t *testing.T) {
indentator := Indentator{"\t"}
for _, p := range []*Parseable{
indentator.NewIndentedParseable(NewLineFromString("Hello"), 1),
indentator.NewIndentedParseable(NewLineFromString("\tHello"), 2),
indentator.NewIndentedParseable(NewLineFromString("\t\tHello"), 5),
} {
require.Nil(t, p)
}
}
07070100000091000081A40000000000000000000000016863F92F0000062D000000000000000000000000000000000000002100000000klog-6.6/klog/parser/txt/line.gopackage txt
import (
"strings"
)
// Line is a data structure that represent one line of the source file.
type Line struct {
// Text contains the copy of the line.
Text string
// LineEnding is the encountered line ending sequence `\n` or `\r\n`.
// Note that for the last line in a file, there might be no line ending.
LineEnding string
}
var LineEndings = []string{"\r\n", "\n"}
var Indentations = []string{" ", " ", " ", "\t"}
// NewLineFromString turns data into a Line object.
func NewLineFromString(rawLineText string) Line {
text, lineEnding := splitOffLineEnding(rawLineText)
return Line{
Text: text,
LineEnding: lineEnding,
}
}
// Original returns the (byte-wise) identical line of text as it appeared in the file.
func (l *Line) Original() string {
return l.Text + l.LineEnding
}
// IsBlank checks whether a line is all spaces or tabs.
func (l *Line) IsBlank() bool {
if len(l.Text) == 0 {
return true
}
for _, c := range l.Text {
if c != ' ' && c != '\t' {
return false
}
}
return true
}
// Indentation returns the indentation sequence of a line that is indented at least once.
// Note: it cannot determine the level of indentation. If the line is not indented,
// it returns empty string.
func (l *Line) Indentation() string {
for _, i := range Indentations {
if strings.HasPrefix(l.Original(), i) {
return i
}
}
return ""
}
func splitOffLineEnding(text string) (string, string) {
for _, e := range LineEndings {
if strings.HasSuffix(text, e) {
return text[:len(text)-len(e)], e
}
}
return text, ""
}
07070100000092000081A40000000000000000000000016863F92F000001CE000000000000000000000000000000000000002600000000klog-6.6/klog/parser/txt/line_test.gopackage txt
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestDeterminesLineEndings(t *testing.T) {
ls := []Line{NewLineFromString(
"foo\n"),
NewLineFromString("bar\r\n"),
NewLineFromString("baz"),
}
assert.Equal(t, "foo", ls[0].Text)
assert.Equal(t, "\n", ls[0].LineEnding)
assert.Equal(t, "bar", ls[1].Text)
assert.Equal(t, "\r\n", ls[1].LineEnding)
assert.Equal(t, "baz", ls[2].Text)
assert.Equal(t, "", ls[2].LineEnding)
}
07070100000093000081A40000000000000000000000016863F92F000008FB000000000000000000000000000000000000002600000000klog-6.6/klog/parser/txt/parseable.go/*
Package txt is a generic utility for parsing and processing a text
that is structured in individual lines.
*/
package txt
import "unicode/utf8"
// Parseable is utility data structure for parsing a piece of text.
type Parseable struct {
Chars []rune
PointerPosition int
}
// NewParseable creates a parseable from the given line.
func NewParseable(l Line, startPointerPosition int) *Parseable {
return &Parseable{
PointerPosition: startPointerPosition,
Chars: []rune(l.Text),
}
}
// Peek returns the next character, or `utf8.RuneError` if there is none anymore.
func (p *Parseable) Peek() rune {
char := SubRune(p.Chars, p.PointerPosition, 1)
if char == nil {
return utf8.RuneError
}
return char[0]
}
// PeekUntil moves the cursor forward until the condition is satisfied, or until the end
// of the line is reached. It returns a Parseable containing the consumed part of the line,
// as well as a bool to indicate whether the condition was met (`true`) or the end of the
// line was encountered (`false`).
func (p *Parseable) PeekUntil(isMatch func(rune) bool) (Parseable, bool) {
matchLength := 0
hasMatched := false
for i := p.PointerPosition; i < len(p.Chars); i++ {
matchLength++
if isMatch(SubRune(p.Chars, i, 1)[0]) {
matchLength -= 1
hasMatched = true
break
}
}
return Parseable{
PointerPosition: p.PointerPosition,
Chars: SubRune(p.Chars, p.PointerPosition, matchLength),
}, hasMatched
}
// Remainder returns the rest of the text.
func (p *Parseable) Remainder() Parseable {
rest, _ := p.PeekUntil(Is(utf8.RuneError))
return rest
}
// Advance moves forward the cursor position.
func (p *Parseable) Advance(increment int) {
p.PointerPosition += increment
}
// SkipWhile consumes all upcoming characters that match the predicate.
func (p *Parseable) SkipWhile(isMatch func(rune) bool) {
for isMatch(p.Peek()) {
p.Advance(1)
}
}
// Length returns the total length of the line.
func (p *Parseable) Length() int {
return len(p.Chars)
}
// RemainingLength returns the number of chars until the end of the line.
func (p *Parseable) RemainingLength() int {
return p.Length() - p.PointerPosition
}
// ToString returns the line text as string.
func (p *Parseable) ToString() string {
return string(p.Chars)
}
07070100000094000081A40000000000000000000000016863F92F000002E6000000000000000000000000000000000000002100000000klog-6.6/klog/parser/txt/util.gopackage txt
// SubRune returns a subset of a rune list. It might be shorter than
// the requested length, if the text doesn’t contain enough characters.
// It returns empty, if the start position is bigger than the length.
func SubRune(text []rune, start int, length int) []rune {
if start >= len(text) {
return nil
}
if start+length > len(text) {
length = len(text) - start
}
return text[start : start+length]
}
// IsSpaceOrTab checks whether a rune is a space or a tab character.
func IsSpaceOrTab(r rune) bool {
return r == ' ' || r == '\t'
}
func Is(matchingCharacter ...rune) func(rune) bool {
return func(r rune) bool {
for _, m := range matchingCharacter {
if m == r {
return true
}
}
return false
}
}
07070100000095000081A40000000000000000000000016863F92F00000B7D000000000000000000000000000000000000001700000000klog-6.6/klog/range.gopackage klog
import (
"errors"
"strings"
)
// Range represents the period of time between two points of time.
type Range interface {
Start() Time
End() Time
Duration() Duration
// ToString serialises the range, e.g. `13:15 - 17:23`.
ToString() string
// Format returns the current formatting.
Format() RangeFormat
}
// OpenRange represents a range that has not ended yet.
type OpenRange interface {
Start() Time
// ToString serialises the open range, e.g. `9:00 - ?`.
ToString() string
// Format returns the current formatting.
Format() OpenRangeFormat
}
// RangeFormat contains the formatting options for a Range.
type RangeFormat struct {
UseSpacesAroundDash bool
}
// DefaultRangeFormat returns the canonical time range format, as recommended by the spec.
func DefaultRangeFormat() RangeFormat {
return RangeFormat{
UseSpacesAroundDash: true,
}
}
// OpenRangeFormat contains the formatting options for an OpenRange.
type OpenRangeFormat struct {
UseSpacesAroundDash bool
AdditionalPlaceholderChars int
}
// DefaultOpenRangeFormat returns the canonical open range format, as recommended by the spec.
func DefaultOpenRangeFormat() OpenRangeFormat {
return OpenRangeFormat{
UseSpacesAroundDash: DefaultRangeFormat().UseSpacesAroundDash,
AdditionalPlaceholderChars: 0,
}
}
func NewRange(start Time, end Time) (Range, error) {
return NewRangeWithFormat(start, end, DefaultRangeFormat())
}
func NewRangeWithFormat(start Time, end Time, format RangeFormat) (Range, error) {
if !end.IsAfterOrEqual(start) {
return nil, errors.New("ILLEGAL_RANGE")
}
return &timeRange{
start: start,
end: end,
format: format,
}, nil
}
func NewOpenRange(start Time) OpenRange {
return NewOpenRangeWithFormat(start, DefaultOpenRangeFormat())
}
func NewOpenRangeWithFormat(start Time, format OpenRangeFormat) OpenRange {
return &openRange{start: start, format: format}
}
type timeRange struct {
start Time
end Time
format RangeFormat
}
type openRange struct {
start Time
format OpenRangeFormat
}
func (tr *timeRange) Start() Time {
return tr.start
}
func (tr *timeRange) End() Time {
return tr.end
}
func (tr *timeRange) Duration() Duration {
start := tr.Start().MidnightOffset().InMinutes()
end := tr.End().MidnightOffset().InMinutes()
return NewDuration(0, end-start)
}
func (tr *timeRange) ToString() string {
space := " "
if !tr.format.UseSpacesAroundDash {
space = ""
}
return tr.Start().ToString() + space + "-" + space + tr.End().ToString()
}
func (tr *timeRange) Format() RangeFormat {
return tr.format
}
func (or *openRange) Start() Time {
return or.start
}
func (or *openRange) ToString() string {
space := " "
if !or.format.UseSpacesAroundDash {
space = ""
}
return or.Start().ToString() + space + "-" + space + strings.Repeat("?", 1+or.format.AdditionalPlaceholderChars)
}
func (or *openRange) Format() OpenRangeFormat {
return or.format
}
07070100000096000081A40000000000000000000000016863F92F00000AFE000000000000000000000000000000000000001C00000000klog-6.6/klog/range_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
// "klog/testutil" REMINDER: can not use `testutil` because of circular import
"testing"
)
func TestCreateRange(t *testing.T) {
time1, _ := NewTime(11, 25)
time2, _ := NewTime(17, 10)
tr, err := NewRange(time1, time2)
require.Nil(t, err)
require.NotNil(t, tr)
assert.Equal(t, time1, tr.Start())
assert.Equal(t, time2, tr.End())
}
func TestCreateOpenRange(t *testing.T) {
time1, _ := NewTime(11, 25)
or := NewOpenRange(time1)
require.NotNil(t, or)
assert.Equal(t, time1, or.Start())
}
func TestCreateVoidRange(t *testing.T) {
time1, _ := NewTime(12, 00)
time2, _ := NewTime(12, 00)
tr, err := NewRange(time1, time2)
require.Nil(t, err)
require.NotNil(t, tr)
assert.Equal(t, time1, tr.Start())
assert.Equal(t, time2, tr.End())
assert.Equal(t, NewDuration(0, 00), tr.Duration())
}
func TestCreateOverlappingRangeStartingYesterday(t *testing.T) {
time1, _ := NewTimeYesterday(23, 30)
time2, _ := NewTime(8, 10)
tr, err := NewRange(time1, time2)
require.Nil(t, err)
require.NotNil(t, tr)
assert.Equal(t, time1, tr.Start())
assert.Equal(t, time2, tr.End())
assert.Equal(t, NewDuration(8, 40), tr.Duration())
}
func TestCreateOverlappingRangeEndingTomorrow(t *testing.T) {
time1, _ := NewTime(18, 15)
time2, _ := NewTimeTomorrow(1, 45)
tr, err := NewRange(time1, time2)
require.Nil(t, err)
require.NotNil(t, tr)
assert.Equal(t, time1, tr.Start())
assert.Equal(t, time2, tr.End())
assert.Equal(t, NewDuration(7, 30), tr.Duration())
}
func TestCreationFailsIfStartIsBeforeEnd(t *testing.T) {
for _, p := range []func() (Range, error){
func() (Range, error) {
start, _ := NewTime(15, 00)
end, _ := NewTime(14, 00)
return NewRange(start, end)
},
func() (Range, error) {
start, _ := NewTime(14, 00)
end, _ := NewTimeYesterday(15, 00)
return NewRange(start, end)
},
func() (Range, error) {
start, _ := NewTimeTomorrow(14, 00)
end, _ := NewTime(15, 00)
return NewRange(start, end)
},
} {
tr, err := p()
assert.Nil(t, tr)
assert.EqualError(t, err, "ILLEGAL_RANGE")
}
}
func TestDefaultFormatting(t *testing.T) {
time1, _ := NewTime(11, 25)
time2, _ := NewTime(17, 10)
tr, _ := NewRange(time1, time2)
assert.Equal(t, "11:25 - 17:10", tr.ToString())
or := NewOpenRange(time1)
assert.Equal(t, "11:25 - ?", or.ToString())
}
func TestCustomFormatting(t *testing.T) {
time1, _ := NewTime(11, 25)
time2, _ := NewTime(17, 10)
tr, _ := NewRangeWithFormat(time1, time2, RangeFormat{UseSpacesAroundDash: false})
assert.Equal(t, "11:25-17:10", tr.ToString())
or := NewOpenRangeWithFormat(time1, OpenRangeFormat{
UseSpacesAroundDash: false,
AdditionalPlaceholderChars: 4,
})
assert.Equal(t, "11:25-?????", or.ToString())
}
07070100000097000081A40000000000000000000000016863F92F00000BD4000000000000000000000000000000000000001800000000klog-6.6/klog/record.go/*
Package klog is the implementation of the domain logic of klog.
It is essentially the code representation of the concepts as they are defined
in the file format specification.
*/
package klog
import (
"errors"
)
// SPEC_VERSION contains the version number of the file format
// specification which this implementation is based on.
const SPEC_VERSION = "1.4"
// Record is a self-contained data container that holds the time tracking
// information associated with a certain date.
type Record interface {
Date() Date
ShouldTotal() ShouldTotal
SetShouldTotal(Duration)
Summary() RecordSummary
SetSummary(RecordSummary)
// Entries returns a list of all entries that are associated with this record.
Entries() []Entry
// SetEntries associates new entries with the record.
SetEntries([]Entry)
AddDuration(Duration, EntrySummary)
AddRange(Range, EntrySummary)
// OpenRange returns the open time range, or `nil` if there is none.
OpenRange() OpenRange
// Start starts a new open time range. It returns an error if there is
// already an open time range present. (There can only be one per record.)
Start(OpenRange, EntrySummary) error
// EndOpenRange ends the open time range. It returns an error if there is
// no open time range present, or if start and end time cannot be converted
// into a valid time range.
EndOpenRange(Time) error
}
func NewRecord(date Date) Record {
return &record{
date: date,
}
}
type record struct {
date Date
shouldTotal ShouldTotal
summary RecordSummary
entries []Entry
}
func (r *record) Date() Date {
return r.date
}
func (r *record) ShouldTotal() ShouldTotal {
if r.shouldTotal == nil {
return NewDuration(0, 0)
}
return r.shouldTotal
}
func (r *record) SetShouldTotal(t Duration) {
r.shouldTotal = NewShouldTotal(0, t.InMinutes())
}
func (r *record) Summary() RecordSummary {
return r.summary
}
func (r *record) SetSummary(summary RecordSummary) {
r.summary = summary
}
func (r *record) Entries() []Entry {
return r.entries
}
func (r *record) SetEntries(es []Entry) {
r.entries = es
}
func (r *record) AddDuration(d Duration, s EntrySummary) {
r.entries = append(r.entries, NewEntryFromDuration(d, s))
}
func (r *record) AddRange(tr Range, s EntrySummary) {
r.entries = append(r.entries, NewEntryFromRange(tr, s))
}
func (r *record) OpenRange() OpenRange {
for _, e := range r.entries {
t, isOpenRange := e.value.(*openRange)
if isOpenRange {
return t
}
}
return nil
}
func (r *record) Start(or OpenRange, s EntrySummary) error {
if r.OpenRange() != nil {
return errors.New("DUPLICATE_OPEN_RANGE")
}
r.entries = append(r.entries, NewEntryFromOpenRange(or, s))
return nil
}
func (r *record) EndOpenRange(end Time) error {
for i, e := range r.entries {
t, isOpenRange := e.value.(*openRange)
if isOpenRange {
tr, err := NewRange(t.Start(), end)
if err != nil {
return err
}
r.entries[i] = NewEntryFromRange(tr, e.summary)
return nil
}
}
return errors.New("NO_OPEN_RANGE")
}
07070100000098000081A40000000000000000000000016863F92F0000121D000000000000000000000000000000000000001D00000000klog-6.6/klog/record_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestInitialiseRecord(t *testing.T) {
date := Ɀ_Date_(2020, 1, 1)
r := NewRecord(date)
assert.Equal(t, r.Date(), date)
assert.Equal(t, NewDuration(0, 0).InMinutes(), r.ShouldTotal().InMinutes())
assert.Nil(t, r.Summary())
assert.Len(t, r.Entries(), 0)
}
func TestSavesSummary(t *testing.T) {
r := NewRecord(Ɀ_Date_(2020, 1, 1))
r.SetSummary(Ɀ_RecordSummary_("Hello World"))
assert.Equal(t, Ɀ_RecordSummary_("Hello World"), r.Summary())
r.SetSummary(Ɀ_RecordSummary_("Two", "Lines"))
assert.Equal(t, Ɀ_RecordSummary_("Two", "Lines"), r.Summary())
r.SetSummary(nil)
assert.Nil(t, r.Summary())
}
func TestSavesShouldTotal(t *testing.T) {
r := NewRecord(Ɀ_Date_(2020, 1, 1))
assert.Equal(t, NewDuration(0, 0).InMinutes(), r.ShouldTotal().InMinutes())
r.SetShouldTotal(NewDuration(5, 0))
assert.Equal(t, NewDuration(5, 0).InMinutes(), r.ShouldTotal().InMinutes())
}
func TestAddRanges(t *testing.T) {
range1 := Ɀ_Range_(Ɀ_Time_(9, 7), Ɀ_Time_(12, 59))
range2 := Ɀ_Range_(Ɀ_Time_(13, 49), Ɀ_Time_(17, 12))
range3 := Ɀ_Range_(Ɀ_Time_(23, 3), Ɀ_Time_(23, 3))
w := NewRecord(Ɀ_Date_(2020, 1, 1))
w.AddRange(range1, Ɀ_EntrySummary_("Range 1"))
w.AddRange(range2, Ɀ_EntrySummary_("Range 2", "With second line"))
w.AddRange(range3, nil)
require.Len(t, w.Entries(), 3)
assert.Equal(t, range1, w.Entries()[0].value)
assert.Equal(t, Ɀ_EntrySummary_("Range 1"), w.Entries()[0].Summary())
assert.Equal(t, range2, w.Entries()[1].value)
assert.Equal(t, Ɀ_EntrySummary_("Range 2", "With second line"), w.Entries()[1].Summary())
assert.Equal(t, range3, w.Entries()[2].value)
assert.Nil(t, w.Entries()[2].Summary())
}
func TestStartOpenRange(t *testing.T) {
time := Ɀ_Time_(11, 23)
w := NewRecord(Ɀ_Date_(2020, 1, 1))
assert.Equal(t, nil, w.OpenRange())
_ = w.Start(NewOpenRange(time), Ɀ_EntrySummary_("Open Range"))
require.Len(t, w.Entries(), 1)
assert.Equal(t, NewOpenRange(time), w.Entries()[0].value)
assert.Equal(t, Ɀ_EntrySummary_("Open Range"), w.Entries()[0].Summary())
}
func TestCannotStartSecondOpenRange(t *testing.T) {
time := Ɀ_Time_(11, 23)
w := NewRecord(Ɀ_Date_(2020, 1, 1))
assert.Equal(t, nil, w.OpenRange())
_ = w.Start(NewOpenRange(time), Ɀ_EntrySummary_("Open Range"))
err := w.Start(NewOpenRange(time), Ɀ_EntrySummary_("Open Range"))
require.Error(t, err)
}
func TestCloseOpenRange(t *testing.T) {
start := Ɀ_Time_(19, 22)
w := NewRecord(Ɀ_Date_(2012, 6, 17))
_ = w.Start(NewOpenRange(start), Ɀ_EntrySummary_("Started"))
end := Ɀ_Time_(20, 55)
err := w.EndOpenRange(end)
require.Nil(t, err)
assert.Nil(t, w.OpenRange())
require.Len(t, w.Entries(), 1)
assert.Equal(t, Ɀ_Range_(start, end), w.Entries()[0].value)
assert.Equal(t, Ɀ_EntrySummary_("Started"), w.Entries()[0].Summary())
}
func TestCloseOpenRangeFailsIfResultingRangeIsInvalid(t *testing.T) {
start := Ɀ_Time_(19, 22)
w := NewRecord(Ɀ_Date_(2012, 6, 17))
_ = w.Start(NewOpenRange(start), Ɀ_EntrySummary_("Started"))
oldEntry := w.OpenRange()
end := Ɀ_Time_(1, 30)
err := w.EndOpenRange(end)
require.Error(t, err)
assert.Equal(t, oldEntry, w.OpenRange())
}
func TestAddDurations(t *testing.T) {
d1 := NewDuration(0, 1)
d2 := NewDuration(2, 50)
d3 := NewDuration(1, 0)
w := NewRecord(Ɀ_Date_(2020, 1, 1))
w.AddDuration(d1, Ɀ_EntrySummary_("Duration 1"))
w.AddDuration(d2, Ɀ_EntrySummary_("Duration 2", "With second line"))
w.AddDuration(d3, nil)
require.Len(t, w.Entries(), 3)
assert.Equal(t, d1, w.Entries()[0].value)
assert.Equal(t, Ɀ_EntrySummary_("Duration 1"), w.Entries()[0].Summary())
assert.Equal(t, d2, w.Entries()[1].value)
assert.Equal(t, Ɀ_EntrySummary_("Duration 2", "With second line"), w.Entries()[1].Summary())
assert.Equal(t, d3, w.Entries()[2].value)
assert.Nil(t, w.Entries()[2].Summary())
}
func TestCalculatesDurationForEntries(t *testing.T) {
r := NewRecord(Ɀ_Date_(2012, 6, 17))
r.AddRange(Ɀ_Range_(Ɀ_Time_(8, 30), Ɀ_Time_(9, 19)), nil)
r.Start(NewOpenRange(Ɀ_Time_(10, 00)), nil)
r.AddDuration(NewDuration(15, 8), nil)
// Don’t take over forced sign from duration:
r.AddDuration(NewDurationWithFormat(0, 16, DurationFormat{ForcePlus: true}), nil)
require.Len(t, r.Entries(), 4)
assert.Equal(t, NewDuration(0, 49), r.Entries()[0].Duration())
assert.Equal(t, NewDuration(0, 0), r.Entries()[1].Duration())
assert.Equal(t, NewDuration(15, 8), r.Entries()[2].Duration())
assert.Equal(t, NewDuration(0, 16), r.Entries()[3].Duration())
}
07070100000099000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001600000000klog-6.6/klog/service0707010000009A000081A40000000000000000000000016863F92F000003F1000000000000000000000000000000000000002200000000klog-6.6/klog/service/datetime.gopackage service
import (
"github.com/jotaen/klog/klog"
gotime "time"
)
// DateTime represents a point in time with a normalized time value.
type DateTime struct {
Date klog.Date
Time klog.Time
}
func NewDateTime(d klog.Date, t klog.Time) DateTime {
normalizedTime, _ := klog.NewTime(t.Hour(), t.Minute())
dayOffset := func() int {
if t.IsTomorrow() {
return 1
} else if t.IsYesterday() {
return -1
}
return 0
}()
return DateTime{
Date: d.PlusDays(dayOffset),
Time: normalizedTime,
}
}
func NewDateTimeFromGo(reference gotime.Time) DateTime {
date := klog.NewDateFromGo(reference)
time := klog.NewTimeFromGo(reference)
return NewDateTime(date, time)
}
func (dt DateTime) IsEqual(compare DateTime) bool {
return dt.Date.IsEqualTo(compare.Date) && dt.Time.IsEqualTo(compare.Time)
}
func (dt DateTime) IsAfterOrEqual(compare DateTime) bool {
if dt.Date.IsEqualTo(compare.Date) {
return dt.Time.IsAfterOrEqual(compare.Time)
}
return dt.Date.IsAfterOrEqual(compare.Date)
}
0707010000009B000081A40000000000000000000000016863F92F00000704000000000000000000000000000000000000002700000000klog-6.6/klog/service/datetime_test.gopackage service
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"testing"
)
func TestCreatesNormalizedDateTime(t *testing.T) {
for _, x := range []struct {
date klog.Date
time klog.Time
}{
{klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(15, 00)},
{klog.Ɀ_Date_(1000, 7, 14), klog.Ɀ_TimeTomorrow_(15, 00)},
{klog.Ɀ_Date_(1000, 7, 16), klog.Ɀ_TimeYesterday_(15, 00)},
} {
dt := NewDateTime(x.date, x.time)
assert.Equal(t, "1000-07-15", dt.Date.ToString())
assert.Equal(t, "15:00", dt.Time.ToString())
}
}
func TestEqualsDateTime(t *testing.T) {
dt1 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 00))
dt2 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 01))
dt3 := NewDateTime(klog.Ɀ_Date_(1000, 7, 16), klog.Ɀ_Time_(12, 00))
assert.True(t, dt1.IsEqual(dt1))
assert.False(t, dt1.IsEqual(dt2))
assert.False(t, dt1.IsEqual(dt3))
assert.False(t, dt2.IsEqual(dt3))
}
func TestIsAfterOrEqualsDateTime(t *testing.T) {
dt1 := NewDateTime(klog.Ɀ_Date_(1000, 7, 14), klog.Ɀ_Time_(13, 00))
dt2 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(11, 59))
dt3 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 00))
dt4 := NewDateTime(klog.Ɀ_Date_(1000, 7, 15), klog.Ɀ_Time_(12, 01))
dt5 := NewDateTime(klog.Ɀ_Date_(1000, 7, 16), klog.Ɀ_Time_(11, 01))
assert.True(t, dt2.IsAfterOrEqual(dt1))
assert.True(t, dt3.IsAfterOrEqual(dt2))
assert.True(t, dt4.IsAfterOrEqual(dt3))
assert.True(t, dt5.IsAfterOrEqual(dt4))
assert.True(t, dt5.IsAfterOrEqual(dt1))
assert.True(t, dt5.IsAfterOrEqual(dt1))
assert.False(t, dt1.IsAfterOrEqual(dt2))
assert.False(t, dt1.IsAfterOrEqual(dt3))
assert.False(t, dt1.IsAfterOrEqual(dt5))
assert.False(t, dt2.IsAfterOrEqual(dt3))
}
0707010000009C000081A40000000000000000000000016863F92F0000031E000000000000000000000000000000000000002200000000klog-6.6/klog/service/evaluate.gopackage service
import (
"github.com/jotaen/klog/klog"
)
// Total calculates the overall time spent in records.
// It disregards open ranges.
func Total(rs ...klog.Record) klog.Duration {
total := klog.NewDuration(0, 0)
for _, r := range rs {
for _, e := range r.Entries() {
total = total.Plus(e.Duration())
}
}
return total
}
// ShouldTotalSum calculates the overall should-total time of records.
func ShouldTotalSum(rs ...klog.Record) klog.ShouldTotal {
total := klog.NewDuration(0, 0)
for _, r := range rs {
total = total.Plus(r.ShouldTotal())
}
return klog.NewShouldTotal(0, total.InMinutes())
}
// Diff calculates the difference between should-total and actual total
func Diff(should klog.ShouldTotal, actual klog.Duration) klog.Duration {
return actual.Minus(should)
}
0707010000009D000081A40000000000000000000000016863F92F00000383000000000000000000000000000000000000002700000000klog-6.6/klog/service/evaluate_test.gopackage service
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"testing"
)
func TestTotalSumUpZeroIfNoTimesSpecified(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
assert.Equal(t, klog.NewDuration(0, 0), Total(r))
}
func TestTotalSumsUpTimesAndRangesButNotOpenRanges(t *testing.T) {
r1 := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r1.AddDuration(klog.NewDuration(3, 0), nil)
r1.AddDuration(klog.NewDuration(1, 33), nil)
r1.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(8, 0), klog.Ɀ_TimeTomorrow_(12, 0)), nil)
r1.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(13, 49), klog.Ɀ_Time_(17, 12)), nil)
_ = r1.Start(klog.NewOpenRange(klog.Ɀ_Time_(1, 2)), nil)
r2 := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 2))
r2.AddDuration(klog.NewDuration(7, 55), nil)
assert.Equal(t, klog.NewDuration(3+1+(16+24+12)+3+7, 33+11+12+55), Total(r1, r2))
}
0707010000009E000041ED0000000000000000000000026863F92F00000000000000000000000000000000000000000000001D00000000klog-6.6/klog/service/period0707010000009F000081A40000000000000000000000016863F92F0000017C000000000000000000000000000000000000002400000000klog-6.6/klog/service/period/day.gopackage period
import "github.com/jotaen/klog/klog"
type DayHash Hash
type Day struct {
date klog.Date
}
func NewDayFromDate(d klog.Date) Day {
return Day{d}
}
func (d Day) Hash() DayHash {
hash := newBitMask()
hash.populate(uint32(d.date.Day()), 31)
hash.populate(uint32(d.date.Month()), 12)
hash.populate(uint32(d.date.Year()), 10000)
return DayHash(hash.Value())
}
070701000000A0000081A40000000000000000000000016863F92F000005CB000000000000000000000000000000000000002600000000klog-6.6/klog/service/period/month.gopackage period
import (
"errors"
"github.com/jotaen/klog/klog"
"regexp"
"strconv"
"strings"
)
type Month struct {
date klog.Date
}
type MonthHash Hash
var monthPattern = regexp.MustCompile(`^\d{4}-\d{2}$`)
func NewMonthFromDate(d klog.Date) Month {
return Month{d}
}
func NewMonthFromString(yyyymm string) (Month, error) {
if !monthPattern.MatchString(yyyymm) {
return Month{}, errors.New("INVALID_MONTH_PERIOD")
}
parts := strings.Split(yyyymm, "-")
year, _ := strconv.Atoi(parts[0])
month, _ := strconv.Atoi(parts[1])
d, err := klog.NewDate(year, month, 1)
if err != nil {
return Month{}, errors.New("INVALID_MONTH_PERIOD")
}
return Month{d}, nil
}
func (m Month) Period() Period {
since, _ := klog.NewDate(m.date.Year(), m.date.Month(), 1)
until, _ := klog.NewDate(m.date.Year(), m.date.Month(), 28)
for {
if until.Year() == 9999 && until.Month() == 12 && until.Day() == 31 {
// 9999-12-31 is the last representable date, so we can’t peak forward from it.
break
}
next := until.PlusDays(1)
if next.Month() != until.Month() {
break
}
until = next
}
return NewPeriod(since, until)
}
func (m Month) Previous() Month {
result := m.date
for {
result = result.PlusDays(-25)
if result.Month() != m.date.Month() {
return Month{result}
}
}
}
func (m Month) Hash() MonthHash {
hash := newBitMask()
hash.populate(uint32(m.date.Month()), 12)
hash.populate(uint32(m.date.Year()), 10000)
return MonthHash(hash.Value())
}
070701000000A1000081A40000000000000000000000016863F92F00000EA6000000000000000000000000000000000000002B00000000klog-6.6/klog/service/period/month_test.gopackage period
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestParseValidMonth(t *testing.T) {
for _, x := range []struct {
text string
expect Period
}{
{"0000-01", NewPeriod(klog.Ɀ_Date_(0, 1, 1), klog.Ɀ_Date_(0, 01, 31))},
{"0000-12", NewPeriod(klog.Ɀ_Date_(0, 12, 1), klog.Ɀ_Date_(0, 12, 31))},
{"0475-05", NewPeriod(klog.Ɀ_Date_(475, 5, 1), klog.Ɀ_Date_(475, 5, 31))},
{"2008-11", NewPeriod(klog.Ɀ_Date_(2008, 11, 1), klog.Ɀ_Date_(2008, 11, 30))},
{"8641-04", NewPeriod(klog.Ɀ_Date_(8641, 4, 1), klog.Ɀ_Date_(8641, 4, 30))},
{"9999-12", NewPeriod(klog.Ɀ_Date_(9999, 12, 1), klog.Ɀ_Date_(9999, 12, 31))},
} {
month, err := NewMonthFromString(x.text)
require.Nil(t, err)
assert.True(t, x.expect.Since().IsEqualTo(month.Period().Since()))
assert.True(t, x.expect.Until().IsEqualTo(month.Period().Until()))
}
}
func TestMonthEnds(t *testing.T) {
for _, x := range []struct {
text string
month int
lastDay int
}{
{"2018-01", 1, 31},
{"2018-02", 2, 28},
{"2018-03", 3, 31},
{"2018-04", 4, 30},
{"2018-05", 5, 31},
{"2018-06", 6, 30},
{"2018-07", 7, 31},
{"2018-08", 8, 31},
{"2018-09", 9, 30},
{"2018-10", 10, 31},
{"2018-11", 11, 30},
{"2018-12", 12, 31},
} {
m, err := NewMonthFromString(x.text)
require.Nil(t, err)
p := m.Period()
assert.Equal(t, p.Since(), klog.Ɀ_Date_(2018, x.month, 1))
assert.Equal(t, p.Until(), klog.Ɀ_Date_(2018, x.month, x.lastDay))
}
}
func TestParseMonthInLeapYear(t *testing.T) {
m, _ := NewMonthFromString("2016-02")
assert.Equal(t, m.Period().Until(), klog.Ɀ_Date_(2016, 2, 29))
}
func TestRejectsInvalidMonth(t *testing.T) {
for _, x := range []string{
"4000-00",
"4000-13",
"1833716-01",
"2008-1",
} {
_, err := NewMonthFromString(x)
require.Error(t, err)
}
}
func TestRejectsMalformedMonth(t *testing.T) {
for _, x := range []string{
"",
"asdf",
"2005",
"2005_12",
"2005--12",
} {
_, err := NewMonthFromString(x)
require.Error(t, err)
}
}
func TestMonthPeriod(t *testing.T) {
for _, x := range []struct {
actual Period
expected Period
}{
// Range in same year
{NewMonthFromDate(klog.Ɀ_Date_(1987, 5, 19)).Period(), NewPeriod(klog.Ɀ_Date_(1987, 5, 1), klog.Ɀ_Date_(1987, 5, 31))},
{NewMonthFromDate(klog.Ɀ_Date_(2004, 11, 16)).Period(), NewPeriod(klog.Ɀ_Date_(2004, 11, 1), klog.Ɀ_Date_(2004, 11, 30))},
// Since is same as original date
{NewMonthFromDate(klog.Ɀ_Date_(1998, 10, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 1), klog.Ɀ_Date_(1998, 10, 31))},
// Until is same as original date
{NewMonthFromDate(klog.Ɀ_Date_(1998, 2, 28)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 2, 1), klog.Ɀ_Date_(1998, 2, 28))},
// Leap year
{NewMonthFromDate(klog.Ɀ_Date_(2000, 2, 4)).Period(), NewPeriod(klog.Ɀ_Date_(2000, 2, 1), klog.Ɀ_Date_(2000, 2, 29))},
} {
assert.Equal(t, x.expected, x.actual)
}
}
func TestMonthPreviousMonth(t *testing.T) {
for _, x := range []struct {
initial Month
expected Period
}{
// In same year
{NewMonthFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 4, 1), klog.Ɀ_Date_(1987, 4, 30))},
{NewMonthFromDate(klog.Ɀ_Date_(1987, 3, 31)), NewPeriod(klog.Ɀ_Date_(1987, 2, 1), klog.Ɀ_Date_(1987, 2, 28))},
{NewMonthFromDate(klog.Ɀ_Date_(1987, 3, 1)), NewPeriod(klog.Ɀ_Date_(1987, 2, 1), klog.Ɀ_Date_(1987, 2, 28))},
// In last year
{NewMonthFromDate(klog.Ɀ_Date_(1987, 1, 19)), NewPeriod(klog.Ɀ_Date_(1986, 12, 1), klog.Ɀ_Date_(1986, 12, 31))},
} {
previous := x.initial.Previous().Period()
assert.Equal(t, x.expected, previous)
}
}
070701000000A2000081A40000000000000000000000016863F92F000006E7000000000000000000000000000000000000002700000000klog-6.6/klog/service/period/period.gopackage period
import (
"errors"
"github.com/jotaen/klog/klog"
"math"
)
// Period is an inclusive date range.
type Period interface {
Since() klog.Date
Until() klog.Date
}
type periodData struct {
since klog.Date
until klog.Date
}
func NewPeriod(since klog.Date, until klog.Date) Period {
return &periodData{since, until}
}
func NewPeriodFromPatternString(pattern string) (Period, error) {
type PeriodCreator interface{ Period() Period }
for _, create := range []func(string) (PeriodCreator, error){
func(s string) (PeriodCreator, error) { return NewYearFromString(s) },
func(s string) (PeriodCreator, error) { return NewMonthFromString(s) },
func(s string) (PeriodCreator, error) { return NewQuarterFromString(s) },
func(s string) (PeriodCreator, error) { return NewWeekFromString(s) },
} {
p, err := create(pattern)
if err == nil {
return p.Period(), nil
}
}
return nil, errors.New("INVALID_PERIOD_PATTERN")
}
func (p *periodData) Since() klog.Date {
return p.since
}
func (p *periodData) Until() klog.Date {
return p.until
}
// Hash is a super type for date-related hashes. Such a hash is
// the same when two dates fall into the same bucket, e.g. the same
// year and week for WeekHash or the same year, month and day for DayHash.
// The underlying int type doesn’t have any meaning.
type Hash uint32
type bitMask struct {
value uint32
bitsConsumed uint
}
func newBitMask() bitMask {
return bitMask{0, 0}
}
func (b *bitMask) Value() Hash {
return Hash(b.value)
}
func (b *bitMask) populate(value uint32, maxValue uint32) {
b.value = b.value | value<<b.bitsConsumed
maxBits := uint(math.Ceil(math.Log2(float64(maxValue)))) + 1
b.bitsConsumed += maxBits
if b.bitsConsumed > 32 {
panic("Overflow")
}
}
070701000000A3000081A40000000000000000000000016863F92F0000055F000000000000000000000000000000000000002C00000000klog-6.6/klog/service/period/period_test.gopackage period
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"testing"
)
func TestDeserialisePattern(t *testing.T) {
for _, x := range []string{
"2022",
"2022-05",
"2022-Q2",
"2022-W18",
} {
period, err := NewPeriodFromPatternString(x)
assert.Nil(t, err)
assert.IsType(t, NewPeriod(klog.Ɀ_Date_(1, 1, 1), klog.Ɀ_Date_(1, 1, 1)), period)
}
}
func TestDeserialisePatternFails(t *testing.T) {
period, err := NewPeriodFromPatternString("x")
assert.Error(t, err)
assert.Nil(t, period)
}
func TestHashYieldsDistinctValues(t *testing.T) {
dayHashes := make(map[DayHash]bool)
weekHashes := make(map[WeekHash]bool)
monthHashes := make(map[MonthHash]bool)
quarterHashes := make(map[QuarterHash]bool)
yearHashes := make(map[YearHash]bool)
// 1.1.1000 is a Wednesday. 1000 days later it’s Sunday, 27.9.1002
initialDate := klog.Ɀ_Date_(1000, 1, 1)
for i := 0; i < 1000; i++ {
d := initialDate.PlusDays(i)
dayHashes[NewDayFromDate(d).Hash()] = true
weekHashes[NewWeekFromDate(d).Hash()] = true
monthHashes[NewMonthFromDate(d).Hash()] = true
quarterHashes[NewQuarterFromDate(d).Hash()] = true
yearHashes[NewYearFromDate(d).Hash()] = true
}
assert.Len(t, dayHashes, 1000)
assert.Len(t, weekHashes, 144)
assert.Len(t, monthHashes, 33)
assert.Len(t, quarterHashes, 11)
assert.Len(t, yearHashes, 3)
}
070701000000A4000081A40000000000000000000000016863F92F00000763000000000000000000000000000000000000002800000000klog-6.6/klog/service/period/quarter.gopackage period
import (
"errors"
"github.com/jotaen/klog/klog"
"regexp"
"strconv"
"strings"
)
type Quarter struct {
date klog.Date
}
type QuarterHash Hash
var quarterPattern = regexp.MustCompile(`^\d{4}-Q\d$`)
func NewQuarterFromDate(d klog.Date) Quarter {
return Quarter{d}
}
func NewQuarterFromString(yyyyQq string) (Quarter, error) {
if !quarterPattern.MatchString(yyyyQq) {
return Quarter{}, errors.New("INVALID_QUARTER_PERIOD")
}
parts := strings.Split(yyyyQq, "-")
year, _ := strconv.Atoi(parts[0])
quarter, _ := strconv.Atoi(strings.TrimPrefix(parts[1], "Q"))
if quarter < 1 || quarter > 4 {
return Quarter{}, errors.New("INVALID_QUARTER_PERIOD")
}
month := quarter * 3
d, err := klog.NewDate(year, month, 1)
if err != nil {
return Quarter{}, errors.New("INVALID_QUARTER_PERIOD")
}
return Quarter{d}, nil
}
func (q Quarter) Period() Period {
switch q.date.Quarter() {
case 1:
since, _ := klog.NewDate(q.date.Year(), 1, 1)
until, _ := klog.NewDate(q.date.Year(), 3, 31)
return NewPeriod(since, until)
case 2:
since, _ := klog.NewDate(q.date.Year(), 4, 1)
until, _ := klog.NewDate(q.date.Year(), 6, 30)
return NewPeriod(since, until)
case 3:
since, _ := klog.NewDate(q.date.Year(), 7, 1)
until, _ := klog.NewDate(q.date.Year(), 9, 30)
return NewPeriod(since, until)
case 4:
since, _ := klog.NewDate(q.date.Year(), 10, 1)
until, _ := klog.NewDate(q.date.Year(), 12, 31)
return NewPeriod(since, until)
}
// This can/should never happen
panic("Invalid quarter")
}
func (q Quarter) Previous() Quarter {
result := q.date
for {
result = result.PlusDays(-80)
if result.Quarter() != q.date.Quarter() {
return Quarter{result}
}
}
}
func (q Quarter) Hash() QuarterHash {
hash := newBitMask()
hash.populate(uint32(q.date.Quarter()), 4)
hash.populate(uint32(q.date.Year()), 10000)
return QuarterHash(hash.Value())
}
070701000000A5000081A40000000000000000000000016863F92F00000CA3000000000000000000000000000000000000002D00000000klog-6.6/klog/service/period/quarter_test.gopackage period
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestQuarterPeriod(t *testing.T) {
for _, x := range []struct {
actual Period
expected Period
}{
// Q1
{NewQuarterFromDate(klog.Ɀ_Date_(1999, 1, 19)).Period(), NewPeriod(klog.Ɀ_Date_(1999, 1, 1), klog.Ɀ_Date_(1999, 3, 31))},
// Q2
{NewQuarterFromDate(klog.Ɀ_Date_(2005, 5, 19)).Period(), NewPeriod(klog.Ɀ_Date_(2005, 4, 1), klog.Ɀ_Date_(2005, 6, 30))},
// Q3
{NewQuarterFromDate(klog.Ɀ_Date_(1589, 8, 3)).Period(), NewPeriod(klog.Ɀ_Date_(1589, 7, 1), klog.Ɀ_Date_(1589, 9, 30))},
// Q4
{NewQuarterFromDate(klog.Ɀ_Date_(2134, 12, 30)).Period(), NewPeriod(klog.Ɀ_Date_(2134, 10, 1), klog.Ɀ_Date_(2134, 12, 31))},
// Since is same as original date
{NewQuarterFromDate(klog.Ɀ_Date_(1998, 4, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 4, 1), klog.Ɀ_Date_(1998, 6, 30))},
// Until is same as original date
{NewQuarterFromDate(klog.Ɀ_Date_(1998, 9, 30)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 7, 1), klog.Ɀ_Date_(1998, 9, 30))},
} {
assert.Equal(t, x.expected, x.actual)
}
}
func TestParseValidQuarter(t *testing.T) {
for _, x := range []struct {
text string
expect Period
}{
{"0000-Q1", NewPeriod(klog.Ɀ_Date_(0, 1, 1), klog.Ɀ_Date_(0, 3, 31))},
{"0475-Q2", NewPeriod(klog.Ɀ_Date_(475, 4, 1), klog.Ɀ_Date_(475, 6, 30))},
{"2008-Q3", NewPeriod(klog.Ɀ_Date_(2008, 7, 1), klog.Ɀ_Date_(2008, 9, 30))},
{"8641-Q4", NewPeriod(klog.Ɀ_Date_(8641, 10, 1), klog.Ɀ_Date_(8641, 12, 31))},
} {
quarter, err := NewQuarterFromString(x.text)
require.Nil(t, err)
assert.True(t, x.expect.Since().IsEqualTo(quarter.Period().Since()))
assert.True(t, x.expect.Until().IsEqualTo(quarter.Period().Until()))
}
}
func TestParseRejectsInvalidQuarter(t *testing.T) {
for _, x := range []string{
"2000-Q5",
"2000-Q0",
"2000-Q-1",
"2000-Q",
"2000-q2",
"2000-asdf",
"2000-Q01",
"2000-",
"273888-Q2",
"Q3",
} {
_, err := NewQuarterFromString(x)
require.Error(t, err)
}
}
func TestQuarterPreviousQuarter(t *testing.T) {
for _, x := range []struct {
initial Quarter
expected Period
}{
// In same year
{NewQuarterFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 1, 1), klog.Ɀ_Date_(1987, 3, 31))},
{NewQuarterFromDate(klog.Ɀ_Date_(1987, 4, 19)), NewPeriod(klog.Ɀ_Date_(1987, 1, 1), klog.Ɀ_Date_(1987, 3, 31))},
{NewQuarterFromDate(klog.Ɀ_Date_(1444, 8, 13)), NewPeriod(klog.Ɀ_Date_(1444, 4, 1), klog.Ɀ_Date_(1444, 6, 30))},
{NewQuarterFromDate(klog.Ɀ_Date_(2009, 12, 31)), NewPeriod(klog.Ɀ_Date_(2009, 7, 1), klog.Ɀ_Date_(2009, 9, 30))},
{NewQuarterFromDate(klog.Ɀ_Date_(2009, 10, 1)), NewPeriod(klog.Ɀ_Date_(2009, 7, 1), klog.Ɀ_Date_(2009, 9, 30))},
// In last year
{NewQuarterFromDate(klog.Ɀ_Date_(1987, 1, 1)), NewPeriod(klog.Ɀ_Date_(1986, 10, 1), klog.Ɀ_Date_(1986, 12, 31))},
{NewQuarterFromDate(klog.Ɀ_Date_(2400, 2, 27)), NewPeriod(klog.Ɀ_Date_(2399, 10, 1), klog.Ɀ_Date_(2399, 12, 31))},
} {
previous := x.initial.Previous().Period()
assert.Equal(t, x.expected, previous)
}
}
070701000000A6000081A40000000000000000000000016863F92F00000696000000000000000000000000000000000000002500000000klog-6.6/klog/service/period/week.gopackage period
import (
"errors"
"github.com/jotaen/klog/klog"
"regexp"
"strconv"
"strings"
)
type Week struct {
date klog.Date
}
type WeekHash Hash
var weekPattern = regexp.MustCompile(`^\d{4}-W\d{1,2}$`)
func NewWeekFromDate(d klog.Date) Week {
return Week{d}
}
func NewWeekFromString(yyyyWww string) (Week, error) {
if !weekPattern.MatchString(yyyyWww) {
return Week{}, errors.New("INVALID_WEEK_PERIOD")
}
parts := strings.Split(yyyyWww, "-")
year, _ := strconv.Atoi(parts[0])
week, _ := strconv.Atoi(strings.TrimPrefix(parts[1], "W"))
if week < 1 {
return Week{}, errors.New("INVALID_WEEK_PERIOD")
}
reference, err := func() (klog.Date, error) {
ref, yErr := klog.NewDate(year, 7, 1)
if yErr != nil {
return nil, errors.New("INVALID_WEEK_PERIOD")
}
for ref.Weekday() != 1 {
ref = ref.PlusDays(-1)
}
_, w := ref.WeekNumber()
ref = ref.PlusDays((week - w) * 7)
return ref, nil
}()
if err != nil {
return Week{}, err
}
if _, refWeekNr := reference.WeekNumber(); refWeekNr != week {
// Prevent implicit roll over.
return Week{}, errors.New("INVALID_WEEK_PERIOD")
}
return Week{reference}, nil
}
func (w Week) Period() Period {
since := w.date
until := w.date
for {
if since.Weekday() == 1 {
break
}
since = since.PlusDays(-1)
}
for {
if until.Weekday() == 7 {
break
}
until = until.PlusDays(1)
}
return NewPeriod(since, until)
}
func (w Week) Previous() Week {
return NewWeekFromDate(w.date.PlusDays(-7))
}
func (w Week) Hash() WeekHash {
hash := newBitMask()
year, week := w.date.WeekNumber()
hash.populate(uint32(week), 53)
hash.populate(uint32(year), 10000)
return WeekHash(hash.Value())
}
070701000000A7000081A40000000000000000000000016863F92F00000E77000000000000000000000000000000000000002A00000000klog-6.6/klog/service/period/week_test.gopackage period
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestWeekPeriod(t *testing.T) {
for _, x := range []struct {
actual Period
expected Period
}{
// Range in same month
{NewWeekFromDate(klog.Ɀ_Date_(1987, 5, 19)).Period(), NewPeriod(klog.Ɀ_Date_(1987, 5, 18), klog.Ɀ_Date_(1987, 5, 24))},
{NewWeekFromDate(klog.Ɀ_Date_(2004, 12, 16)).Period(), NewPeriod(klog.Ɀ_Date_(2004, 12, 13), klog.Ɀ_Date_(2004, 12, 19))},
// Range across months
{NewWeekFromDate(klog.Ɀ_Date_(1983, 6, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1983, 5, 30), klog.Ɀ_Date_(1983, 6, 5))},
{NewWeekFromDate(klog.Ɀ_Date_(1998, 10, 27)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 26), klog.Ɀ_Date_(1998, 11, 1))},
// Range across years
{NewWeekFromDate(klog.Ɀ_Date_(2009, 1, 2)).Period(), NewPeriod(klog.Ɀ_Date_(2008, 12, 29), klog.Ɀ_Date_(2009, 1, 4))},
{NewWeekFromDate(klog.Ɀ_Date_(2009, 12, 30)).Period(), NewPeriod(klog.Ɀ_Date_(2009, 12, 28), klog.Ɀ_Date_(2010, 1, 3))},
// Since is same as original date
{NewWeekFromDate(klog.Ɀ_Date_(1998, 10, 26)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 26), klog.Ɀ_Date_(1998, 11, 1))},
// Until is same as original date
{NewWeekFromDate(klog.Ɀ_Date_(1998, 11, 1)).Period(), NewPeriod(klog.Ɀ_Date_(1998, 10, 26), klog.Ɀ_Date_(1998, 11, 1))},
} {
assert.Equal(t, x.expected, x.actual)
}
}
func TestParseValidWeek(t *testing.T) {
for _, x := range []struct {
text string
expect Period
}{
{"2022-W01", NewPeriod(klog.Ɀ_Date_(2022, 1, 3), klog.Ɀ_Date_(2022, 1, 9))},
{"2022-W1", NewPeriod(klog.Ɀ_Date_(2022, 1, 3), klog.Ɀ_Date_(2022, 1, 9))},
{"2017-W26", NewPeriod(klog.Ɀ_Date_(2017, 6, 26), klog.Ɀ_Date_(2017, 7, 2))},
{"2017-W27", NewPeriod(klog.Ɀ_Date_(2017, 7, 3), klog.Ɀ_Date_(2017, 7, 9))},
{"2012-W09", NewPeriod(klog.Ɀ_Date_(2012, 2, 27), klog.Ɀ_Date_(2012, 3, 4))},
{"2022-W02", NewPeriod(klog.Ɀ_Date_(2022, 1, 10), klog.Ɀ_Date_(2022, 1, 16))},
{"2022-W52", NewPeriod(klog.Ɀ_Date_(2022, 12, 26), klog.Ɀ_Date_(2023, 1, 1))},
{"2025-W01", NewPeriod(klog.Ɀ_Date_(2024, 12, 30), klog.Ɀ_Date_(2025, 1, 5))},
} {
week, err := NewWeekFromString(x.text)
require.Nil(t, err)
assert.True(t, x.expect.Since().IsEqualTo(week.Period().Since()), x.text)
assert.True(t, x.expect.Until().IsEqualTo(week.Period().Until()))
}
}
func TestParseRejectsInvalidWeekString(t *testing.T) {
for _, x := range []string{
"2000-W00",
"2000-W-1",
"2000-W001",
"2000-W54",
"2000-W",
"2000-w14",
"2000-w14",
"2000-asdf",
"12873612-W02",
} {
_, err := NewWeekFromString(x)
require.Error(t, err)
}
}
func TestWeekPreviousWeek(t *testing.T) {
for _, x := range []struct {
initial Week
expected Period
}{
// Same month
{NewWeekFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 5, 11), klog.Ɀ_Date_(1987, 5, 17))},
// `Since` in other month
{NewWeekFromDate(klog.Ɀ_Date_(2014, 8, 6)), NewPeriod(klog.Ɀ_Date_(2014, 7, 28), klog.Ɀ_Date_(2014, 8, 3))},
// `Since`&`Until` in other month
{NewWeekFromDate(klog.Ɀ_Date_(2014, 8, 2)), NewPeriod(klog.Ɀ_Date_(2014, 7, 21), klog.Ɀ_Date_(2014, 7, 27))},
// `Since` in other year
{NewWeekFromDate(klog.Ɀ_Date_(2014, 1, 9)), NewPeriod(klog.Ɀ_Date_(2013, 12, 30), klog.Ɀ_Date_(2014, 1, 5))},
// `Since`&`Until` in other year
{NewWeekFromDate(klog.Ɀ_Date_(2029, 1, 2)), NewPeriod(klog.Ɀ_Date_(2028, 12, 25), klog.Ɀ_Date_(2028, 12, 31))},
} {
previous := x.initial.Previous().Period()
assert.Equal(t, x.expected, previous)
}
}
070701000000A8000081A40000000000000000000000016863F92F00000440000000000000000000000000000000000000002500000000klog-6.6/klog/service/period/year.gopackage period
import (
"errors"
"github.com/jotaen/klog/klog"
"regexp"
"strconv"
)
type Year struct {
date klog.Date
}
type YearHash Hash
var yearPattern = regexp.MustCompile(`^\d{4}$`)
func NewYearFromDate(d klog.Date) Year {
return Year{d}
}
func NewYearFromString(yyyy string) (Year, error) {
if !yearPattern.MatchString(yyyy) {
return Year{}, errors.New("INVALID_YEAR_PERIOD")
}
year, err := strconv.Atoi(yyyy)
if err != nil {
return Year{}, errors.New("INVALID_YEAR_PERIOD")
}
d, dErr := klog.NewDate(year, 1, 1)
if dErr != nil {
return Year{}, errors.New("INVALID_YEAR_PERIOD")
}
return Year{d}, nil
}
func (y Year) Period() Period {
since, _ := klog.NewDate(y.date.Year(), 1, 1)
until, _ := klog.NewDate(y.date.Year(), 12, 31)
return NewPeriod(since, until)
}
func (y Year) Previous() Year {
lastYear, err := klog.NewDate(y.date.Year()-1, 1, 1)
if err != nil {
panic("Invalid year")
}
return Year{lastYear}
}
func (y Year) Hash() YearHash {
hash := newBitMask()
hash.populate(uint32(y.date.Year()), 10000)
return YearHash(hash.Value())
}
070701000000A9000081A40000000000000000000000016863F92F000008F8000000000000000000000000000000000000002A00000000klog-6.6/klog/service/period/year_test.gopackage period
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestParseValidYear(t *testing.T) {
for _, x := range []struct {
text string
expect Period
}{
{"0000", NewPeriod(klog.Ɀ_Date_(0, 1, 1), klog.Ɀ_Date_(0, 12, 31))},
{"0475", NewPeriod(klog.Ɀ_Date_(475, 1, 1), klog.Ɀ_Date_(475, 12, 31))},
{"2008", NewPeriod(klog.Ɀ_Date_(2008, 1, 1), klog.Ɀ_Date_(2008, 12, 31))},
{"8641", NewPeriod(klog.Ɀ_Date_(8641, 1, 1), klog.Ɀ_Date_(8641, 12, 31))},
{"9999", NewPeriod(klog.Ɀ_Date_(9999, 1, 1), klog.Ɀ_Date_(9999, 12, 31))},
} {
year, err := NewYearFromString(x.text)
require.Nil(t, err)
assert.True(t, x.expect.Since().IsEqualTo(year.Period().Since()))
assert.True(t, x.expect.Until().IsEqualTo(year.Period().Until()))
}
}
func TestRejectsInvalidYear(t *testing.T) {
for _, x := range []string{
"-5",
"10000",
"9823746",
} {
_, err := NewYearFromString(x)
require.Error(t, err)
}
}
func TestRejectsMalformedYear(t *testing.T) {
for _, x := range []string{
"",
"asdf",
"2oo1",
} {
_, err := NewYearFromString(x)
require.Error(t, err)
}
}
func TestYearPeriod(t *testing.T) {
for _, x := range []struct {
initial Year
expected Period
}{
{NewYearFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1987, 1, 1), klog.Ɀ_Date_(1987, 12, 31))},
{NewYearFromDate(klog.Ɀ_Date_(2000, 3, 31)), NewPeriod(klog.Ɀ_Date_(2000, 1, 1), klog.Ɀ_Date_(2000, 12, 31))},
{NewYearFromDate(klog.Ɀ_Date_(2555, 12, 31)), NewPeriod(klog.Ɀ_Date_(2555, 1, 1), klog.Ɀ_Date_(2555, 12, 31))},
} {
p := x.initial.Period()
assert.Equal(t, x.expected, p)
}
}
func TestYearPreviousYear(t *testing.T) {
for _, x := range []struct {
initial Year
expected Period
}{
{NewYearFromDate(klog.Ɀ_Date_(1987, 5, 19)), NewPeriod(klog.Ɀ_Date_(1986, 1, 1), klog.Ɀ_Date_(1986, 12, 31))},
{NewYearFromDate(klog.Ɀ_Date_(2000, 3, 31)), NewPeriod(klog.Ɀ_Date_(1999, 1, 1), klog.Ɀ_Date_(1999, 12, 31))},
{NewYearFromDate(klog.Ɀ_Date_(2555, 12, 31)), NewPeriod(klog.Ɀ_Date_(2554, 1, 1), klog.Ɀ_Date_(2554, 12, 31))},
} {
previous := x.initial.Previous().Period()
assert.Equal(t, x.expected, previous)
}
}
070701000000AA000081A40000000000000000000000016863F92F00000CA7000000000000000000000000000000000000001F00000000klog-6.6/klog/service/query.gopackage service
import (
"github.com/jotaen/klog/klog"
gosort "sort"
)
type EntryType string
const (
ENTRY_TYPE_DURATION = EntryType("DURATION")
ENTRY_TYPE_POSITIVE_DURATION = EntryType("DURATION_POSITIVE")
ENTRY_TYPE_NEGATIVE_DURATION = EntryType("DURATION_NEGATIVE")
ENTRY_TYPE_RANGE = EntryType("RANGE")
ENTRY_TYPE_OPEN_RANGE = EntryType("OPEN_RANGE")
)
// FilterQry represents the filter clauses of a query.
type FilterQry struct {
Tags []klog.Tag
BeforeOrEqual klog.Date
AfterOrEqual klog.Date
AtDate klog.Date
EntryType EntryType
}
// Filter returns all records the matches the query.
// A matching record must satisfy *all* query clauses.
func Filter(rs []klog.Record, o FilterQry) []klog.Record {
var records []klog.Record
for _, r := range rs {
if o.AtDate != nil && !o.AtDate.IsEqualTo(r.Date()) {
continue
}
if o.BeforeOrEqual != nil && !o.BeforeOrEqual.IsAfterOrEqual(r.Date()) {
continue
}
if o.AfterOrEqual != nil && !r.Date().IsAfterOrEqual(o.AfterOrEqual) {
continue
}
if len(o.Tags) > 0 {
reducedR, hasMatched := reduceRecordToMatchingTags(o.Tags, r)
if !hasMatched {
continue
}
r = reducedR
}
if o.EntryType != "" {
reducedR, hasMatched := reduceRecordToMatchingEntryTypes(o.EntryType, r)
if !hasMatched {
continue
}
r = reducedR
}
records = append(records, r)
}
return records
}
// Sort orders the records by date.
func Sort(rs []klog.Record, startWithOldest bool) []klog.Record {
sorted := append([]klog.Record(nil), rs...)
gosort.Slice(sorted, func(i, j int) bool {
isLess := sorted[j].Date().IsAfterOrEqual(sorted[i].Date())
if !startWithOldest {
return !isLess
}
return isLess
})
return sorted
}
func reduceRecordToMatchingTags(queriedTags []klog.Tag, r klog.Record) (klog.Record, bool) {
if isSubsetOf(queriedTags, r.Summary().Tags()) {
return r, true
}
var matchingEntries []klog.Entry
for _, e := range r.Entries() {
allTags := klog.Merge(r.Summary().Tags(), e.Summary().Tags())
if isSubsetOf(queriedTags, &allTags) {
matchingEntries = append(matchingEntries, e)
}
}
if len(matchingEntries) == 0 {
return nil, false
}
r.SetEntries(matchingEntries)
return r, true
}
func reduceRecordToMatchingEntryTypes(t EntryType, r klog.Record) (klog.Record, bool) {
var matchingEntries []klog.Entry
for _, e := range r.Entries() {
isMatch := klog.Unbox(&e, func(r klog.Range) bool {
return t == ENTRY_TYPE_RANGE
}, func(duration klog.Duration) bool {
if t == ENTRY_TYPE_DURATION {
return true
} else if t == ENTRY_TYPE_POSITIVE_DURATION && e.Duration().InMinutes() >= 0 {
return true
} else if t == ENTRY_TYPE_NEGATIVE_DURATION && e.Duration().InMinutes() < 0 {
return true
}
return false
}, func(openRange klog.OpenRange) bool {
return t == ENTRY_TYPE_OPEN_RANGE
})
if isMatch {
matchingEntries = append(matchingEntries, e)
}
}
if len(matchingEntries) == 0 {
return nil, false
}
r.SetEntries(matchingEntries)
return r, true
}
func isSubsetOf(queriedTags []klog.Tag, allTags *klog.TagSet) bool {
for _, t := range queriedTags {
if !allTags.Contains(t) {
return false
}
}
return true
}
070701000000AB000081A40000000000000000000000016863F92F00001628000000000000000000000000000000000000002400000000klog-6.6/klog/service/query_test.gopackage service
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func sampleRecordsForQuerying() []klog.Record {
return []klog.Record{
func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(1999, 12, 30))
r.SetSummary(klog.Ɀ_RecordSummary_("Hello World", "#foo"))
return r
}(), func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(1999, 12, 31))
r.AddDuration(klog.NewDuration(5, 0), klog.Ɀ_EntrySummary_("#bar"))
return r
}(), func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2000, 1, 1))
r.SetSummary(klog.Ɀ_RecordSummary_("#foo"))
r.AddDuration(klog.NewDuration(0, 15), nil)
r.AddDuration(klog.NewDuration(6, 0), klog.Ɀ_EntrySummary_("#bar"))
r.AddDuration(klog.NewDuration(0, -30), nil)
return r
}(), func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2000, 1, 2))
r.SetSummary(klog.Ɀ_RecordSummary_("#foo"))
r.AddDuration(klog.NewDuration(7, 0), nil)
return r
}(), func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2000, 1, 3))
r.SetSummary(klog.Ɀ_RecordSummary_("#foo=a"))
r.AddDuration(klog.NewDuration(4, 0), klog.Ɀ_EntrySummary_("test", "foo #bar=1"))
r.AddDuration(klog.NewDuration(4, 0), klog.Ɀ_EntrySummary_("#bar=2"))
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(12, 00)), nil)
return r
}(),
}
}
func TestQueryWithNoClauses(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{})
require.Len(t, rs, 5)
assert.Equal(t, klog.NewDuration(5+6+7+8, -30+15), Total(rs...))
}
func TestQueryWithAtDate(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{AtDate: klog.Ɀ_Date_(2000, 1, 2)})
require.Len(t, rs, 1)
assert.Equal(t, klog.NewDuration(7, 0), Total(rs...))
}
func TestQueryWithAfter(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{AfterOrEqual: klog.Ɀ_Date_(2000, 1, 1)})
require.Len(t, rs, 3)
assert.Equal(t, 1, rs[0].Date().Day())
assert.Equal(t, 2, rs[1].Date().Day())
assert.Equal(t, 3, rs[2].Date().Day())
}
func TestQueryWithBefore(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{BeforeOrEqual: klog.Ɀ_Date_(2000, 1, 1)})
require.Len(t, rs, 3)
assert.Equal(t, 30, rs[0].Date().Day())
assert.Equal(t, 31, rs[1].Date().Day())
assert.Equal(t, 1, rs[2].Date().Day())
}
func TestQueryWithTagOnEntries(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{Tags: []klog.Tag{klog.NewTagOrPanic("bar", "")}})
require.Len(t, rs, 3)
assert.Equal(t, 31, rs[0].Date().Day())
assert.Equal(t, 1, rs[1].Date().Day())
assert.Equal(t, 3, rs[2].Date().Day())
assert.Equal(t, klog.NewDuration(5+8+6, 0), Total(rs...))
}
func TestQueryWithTagOnOverallSummary(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{Tags: []klog.Tag{klog.NewTagOrPanic("foo", "")}})
require.Len(t, rs, 4)
assert.Equal(t, 30, rs[0].Date().Day())
assert.Equal(t, 1, rs[1].Date().Day())
assert.Equal(t, 2, rs[2].Date().Day())
assert.Equal(t, 3, rs[3].Date().Day())
assert.Equal(t, klog.NewDuration(6+7+8, -30+15), Total(rs...))
}
func TestQueryWithTagOnEntriesAndInSummary(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{Tags: []klog.Tag{klog.NewTagOrPanic("foo", ""), klog.NewTagOrPanic("bar", "")}})
require.Len(t, rs, 2)
assert.Equal(t, 1, rs[0].Date().Day())
assert.Equal(t, 3, rs[1].Date().Day())
assert.Equal(t, klog.NewDuration(8+6, 0), Total(rs...))
}
func TestQueryWithTagValues(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{Tags: []klog.Tag{klog.NewTagOrPanic("foo", "a")}})
require.Len(t, rs, 1)
assert.Equal(t, 3, rs[0].Date().Day())
assert.Equal(t, klog.NewDuration(8, 0), Total(rs...))
}
func TestQueryWithTagValuesInEntries(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{Tags: []klog.Tag{klog.NewTagOrPanic("bar", "1")}})
require.Len(t, rs, 1)
assert.Equal(t, 3, rs[0].Date().Day())
assert.Equal(t, klog.NewDuration(4, 0), Total(rs...))
}
func TestQueryWithTagNonMatchingValues(t *testing.T) {
rs := Filter(sampleRecordsForQuerying(), FilterQry{Tags: []klog.Tag{klog.NewTagOrPanic("bar", "3")}})
require.Len(t, rs, 0)
}
func TestQueryWithEntryTypes(t *testing.T) {
{
rs := Filter(sampleRecordsForQuerying(), FilterQry{EntryType: ENTRY_TYPE_DURATION})
require.Len(t, rs, 4)
assert.Equal(t, klog.NewDuration(0, 1545), Total(rs...))
}
{
rs := Filter(sampleRecordsForQuerying(), FilterQry{EntryType: ENTRY_TYPE_NEGATIVE_DURATION})
require.Len(t, rs, 1)
assert.Equal(t, 1, rs[0].Date().Day())
assert.Equal(t, klog.NewDuration(0, -30), Total(rs...))
}
{
rs := Filter(sampleRecordsForQuerying(), FilterQry{EntryType: ENTRY_TYPE_POSITIVE_DURATION})
require.Len(t, rs, 4)
assert.Equal(t, klog.NewDuration(0, 1575), Total(rs...))
}
{
rs := Filter(sampleRecordsForQuerying(), FilterQry{EntryType: ENTRY_TYPE_RANGE})
require.Len(t, rs, 0)
assert.Equal(t, klog.NewDuration(0, 0), Total(rs...))
}
{
rs := Filter(sampleRecordsForQuerying(), FilterQry{EntryType: ENTRY_TYPE_OPEN_RANGE})
require.Len(t, rs, 1)
assert.Equal(t, klog.NewDuration(0, 0), Total(rs...))
}
}
func TestQueryWithSorting(t *testing.T) {
ss := sampleRecordsForQuerying()
for _, x := range []struct{ rs []klog.Record }{
{ss},
{[]klog.Record{ss[3], ss[1], ss[2], ss[0], ss[4]}},
{[]klog.Record{ss[1], ss[4], ss[0], ss[3], ss[2]}},
} {
ascending := Sort(x.rs, true)
assert.Equal(t, []klog.Record{ss[0], ss[1], ss[2], ss[3], ss[4]}, ascending)
descending := Sort(x.rs, false)
assert.Equal(t, []klog.Record{ss[4], ss[3], ss[2], ss[1], ss[0]}, descending)
}
}
070701000000AC000081A40000000000000000000000016863F92F00000462000000000000000000000000000000000000002000000000klog-6.6/klog/service/record.gopackage service
import (
"errors"
"github.com/jotaen/klog/klog"
gotime "time"
)
// CloseOpenRanges closes open ranges at the time of `endTime`. Returns an error
// if a range is not closeable at that point in time.
// This method alters the provided records!
// The bool return value indicates whether any open ranges have been closed.
func CloseOpenRanges(endTime gotime.Time, rs ...klog.Record) (bool, error) {
thisDay := klog.NewDateFromGo(endTime)
theDayBefore := thisDay.PlusDays(-1)
hasClosedAnyRange := false
for _, r := range rs {
if r.OpenRange() == nil {
continue
}
end, tErr := func() (klog.Time, error) {
end := klog.NewTimeFromGo(endTime)
if r.Date().IsEqualTo(thisDay) {
return end, nil
}
if r.Date().IsEqualTo(theDayBefore) {
return end.Plus(klog.NewDuration(24, 0))
}
return nil, errors.New("Encountered uncloseable open range")
}()
if tErr != nil {
return false, tErr
}
eErr := r.EndOpenRange(end)
hasClosedAnyRange = true
if eErr != nil {
return false, errors.New("Encountered uncloseable open range")
}
}
return hasClosedAnyRange, nil
}
070701000000AD000081A40000000000000000000000016863F92F000009CB000000000000000000000000000000000000002500000000klog-6.6/klog/service/record_test.gopackage service
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
gotime "time"
)
func TestDoesNotTouchRecordsIfNoOpenRange(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.AddDuration(klog.NewDuration(1, 0), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil)
hasClosedAnyRange, err := CloseOpenRanges(gotime.Now(), r)
require.Nil(t, err)
assert.False(t, hasClosedAnyRange)
assert.Equal(t, klog.NewDuration(2, 0), Total(r))
}
func TestClosesOpenRange(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.AddDuration(klog.NewDuration(1, 0), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil)
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil)
endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-01T05:30:00-0000")
hasClosedAnyRange, err := CloseOpenRanges(endTime, r)
require.Nil(t, err)
assert.True(t, hasClosedAnyRange)
assert.Equal(t, klog.NewDuration(2+2, 30), Total(r))
}
func TestClosesOpenRangeAndShiftsTime(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.AddDuration(klog.NewDuration(1, 0), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil)
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil)
endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-02T05:30:00-0000")
hasClosedAnyRange, err := CloseOpenRanges(endTime, r)
require.Nil(t, err)
assert.True(t, hasClosedAnyRange)
assert.Equal(t, klog.NewDuration(2+24+2, 30), Total(r))
}
func TestReturnsErrorIfOpenRangeCannotBeClosedAnymore(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.AddDuration(klog.NewDuration(1, 0), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil)
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil)
endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-03T05:30:00-0000")
_, err := CloseOpenRanges(endTime, r)
require.Error(t, err)
}
func TestReturnsErrorIfOpenRangeCannotBeClosedYet(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.AddDuration(klog.NewDuration(1, 0), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 0), klog.Ɀ_Time_(2, 0)), nil)
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil)
endTime, _ := gotime.Parse("2006-01-02T15:04:05-0700", "2020-01-01T01:30:00-0000")
_, err := CloseOpenRanges(endTime, r)
require.Error(t, err)
}
070701000000AE000081A40000000000000000000000016863F92F0000078A000000000000000000000000000000000000002200000000klog-6.6/klog/service/rounding.gopackage service
import (
"errors"
"github.com/jotaen/klog/klog"
"strconv"
"strings"
)
// Rounding is an integer divider of 60 that Time values can be rounded to.
type Rounding interface {
ToInt() int
ToString() string
}
type rounding int
func (r rounding) ToInt() int {
return int(r)
}
func (r rounding) ToString() string {
return strconv.Itoa(r.ToInt()) + "m"
}
// NewRounding creates a Rounding from an integer. For non-allowed
// values, it returns error.
func NewRounding(r int) (Rounding, error) {
for _, validRounding := range []int{5, 10, 12, 15, 20, 30, 60} {
if r == validRounding {
return rounding(r), nil
}
}
return nil, errors.New("INVALID_ROUNDING")
}
// NewRoundingFromString parses a string containing a rounding value.
// The string might be suffixed with `m`. Additionally, it might be `1h`,
// which is equivalent to `60m`.
func NewRoundingFromString(v string) (Rounding, error) {
r := func() int {
if v == "1h" {
return 60
}
v = strings.TrimSuffix(v, "m")
number, err := strconv.Atoi(v)
if err != nil {
return -1
}
return number
}()
return NewRounding(r)
}
// RoundToNearest rounds a time (up or down) to the nearest given rounding multiple.
// E.g., for rounding=5m: 8:03 => 8:05, or for rounding=30m: 15:12 => 15:00
func RoundToNearest(t klog.Time, r Rounding) klog.Time {
midnightOffset := t.MidnightOffset().InMinutes()
v := r.ToInt()
remainder := midnightOffset % v
uprounder := func() int { // Decide whether to round up the value.
if remainder >= (v/2 + v%2) {
return v
}
return 0
}()
roundedMidnightOffset := midnightOffset - remainder + uprounder
midnight, _ := klog.NewTime(0, 0)
roundedTime, err := midnight.Plus(klog.NewDuration(0, roundedMidnightOffset))
if err != nil {
// This is the special case where we can’t round up after `23:59>`.
maxTime, _ := klog.NewTimeTomorrow(23, 59)
return maxTime
}
return roundedTime
}
070701000000AF000081A40000000000000000000000016863F92F0000171C000000000000000000000000000000000000002700000000klog-6.6/klog/service/rounding_test.gopackage service
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"strings"
"testing"
)
func r(f int) Rounding {
result, err := NewRounding(f)
if err != nil {
panic(err)
}
return result
}
func TestValidRoundingValues(t *testing.T) {
for _, m := range []int{
5, 10, 15, 30, 60,
} {
fr, err := NewRounding(m)
require.Nil(t, err)
assert.Equal(t, m, fr.ToInt())
}
}
func TestInvalidRoundingValues(t *testing.T) {
for _, m := range []int{
-60, -30, -15, -10, -5, 0, 1, 2, 3, 4, 6, 7, 8, 9, 11, 13, 14, 16, 17, 18, 19, 25, 35, 45, 55, 120, 600,
} {
fr, err := NewRounding(m)
require.Nil(t, fr)
assert.Error(t, err)
}
}
func TestParseRoundingValuesFromString(t *testing.T) {
for _, x := range []struct {
value string
expected int
}{
{"5", 5},
{"5m", 5},
{"10", 10},
{"10m", 10},
{"12m", 12},
{"15m", 15},
{"20m", 20},
{"30m", 30},
{"60m", 60},
{"1h", 60},
} {
fr, err := NewRoundingFromString(x.value)
require.Nil(t, err)
assert.Equal(t, x.expected, fr.ToInt())
if strings.HasSuffix(x.value, "m") {
// It always stringifies with a `m` suffix.
assert.Equal(t, x.value, fr.ToString())
}
}
}
func TestInvalidRoundingValuesFromStringFail(t *testing.T) {
for _, v := range []string{
"0", "1", "11", "a", "", "5h",
} {
fr, err := NewRoundingFromString(v)
require.Nil(t, fr)
require.Error(t, err)
}
}
func TestRound(t *testing.T) {
for _, tm := range []struct {
original klog.Time
exp klog.Time
}{
// Round to 5s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(5)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 01), r(5)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 02), r(5)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 03), r(5)), klog.Ɀ_Time_(8, 05)},
{RoundToNearest(klog.Ɀ_Time_(8, 04), r(5)), klog.Ɀ_Time_(8, 05)},
{RoundToNearest(klog.Ɀ_Time_(8, 05), r(5)), klog.Ɀ_Time_(8, 05)},
{RoundToNearest(klog.Ɀ_Time_(8, 06), r(5)), klog.Ɀ_Time_(8, 05)},
{RoundToNearest(klog.Ɀ_Time_(8, 07), r(5)), klog.Ɀ_Time_(8, 05)},
{RoundToNearest(klog.Ɀ_Time_(8, 8), r(5)), klog.Ɀ_Time_(8, 10)},
{RoundToNearest(klog.Ɀ_Time_(8, 57), r(5)), klog.Ɀ_Time_(8, 55)},
{RoundToNearest(klog.Ɀ_Time_(8, 58), r(5)), klog.Ɀ_Time_(9, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 59), r(5)), klog.Ɀ_Time_(9, 00)},
// Round to 10s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(10)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 04), r(10)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 05), r(10)), klog.Ɀ_Time_(8, 10)},
{RoundToNearest(klog.Ɀ_Time_(8, 10), r(10)), klog.Ɀ_Time_(8, 10)},
{RoundToNearest(klog.Ɀ_Time_(8, 14), r(10)), klog.Ɀ_Time_(8, 10)},
{RoundToNearest(klog.Ɀ_Time_(8, 15), r(10)), klog.Ɀ_Time_(8, 20)},
{RoundToNearest(klog.Ɀ_Time_(8, 55), r(10)), klog.Ɀ_Time_(9, 00)},
// Round to 12s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(12)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 04), r(12)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 05), r(12)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 06), r(12)), klog.Ɀ_Time_(8, 12)},
{RoundToNearest(klog.Ɀ_Time_(8, 11), r(12)), klog.Ɀ_Time_(8, 12)},
{RoundToNearest(klog.Ɀ_Time_(8, 12), r(12)), klog.Ɀ_Time_(8, 12)},
{RoundToNearest(klog.Ɀ_Time_(8, 13), r(12)), klog.Ɀ_Time_(8, 12)},
{RoundToNearest(klog.Ɀ_Time_(8, 17), r(12)), klog.Ɀ_Time_(8, 12)},
{RoundToNearest(klog.Ɀ_Time_(8, 18), r(12)), klog.Ɀ_Time_(8, 24)},
// Round to 15s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(15)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 07), r(15)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 8), r(15)), klog.Ɀ_Time_(8, 15)},
{RoundToNearest(klog.Ɀ_Time_(8, 15), r(15)), klog.Ɀ_Time_(8, 15)},
{RoundToNearest(klog.Ɀ_Time_(8, 22), r(15)), klog.Ɀ_Time_(8, 15)},
{RoundToNearest(klog.Ɀ_Time_(8, 23), r(15)), klog.Ɀ_Time_(8, 30)},
{RoundToNearest(klog.Ɀ_Time_(8, 55), r(15)), klog.Ɀ_Time_(9, 00)},
// Round to 20s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(20)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 9), r(20)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 10), r(20)), klog.Ɀ_Time_(8, 20)},
{RoundToNearest(klog.Ɀ_Time_(8, 20), r(20)), klog.Ɀ_Time_(8, 20)},
{RoundToNearest(klog.Ɀ_Time_(8, 29), r(20)), klog.Ɀ_Time_(8, 20)},
{RoundToNearest(klog.Ɀ_Time_(8, 30), r(20)), klog.Ɀ_Time_(8, 40)},
{RoundToNearest(klog.Ɀ_Time_(8, 49), r(20)), klog.Ɀ_Time_(8, 40)},
{RoundToNearest(klog.Ɀ_Time_(8, 50), r(20)), klog.Ɀ_Time_(9, 00)},
// Round to 30s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(30)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 14), r(30)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 15), r(30)), klog.Ɀ_Time_(8, 30)},
{RoundToNearest(klog.Ɀ_Time_(8, 44), r(30)), klog.Ɀ_Time_(8, 30)},
{RoundToNearest(klog.Ɀ_Time_(8, 45), r(30)), klog.Ɀ_Time_(9, 00)},
// Round to 60s
{RoundToNearest(klog.Ɀ_Time_(8, 00), r(60)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 29), r(60)), klog.Ɀ_Time_(8, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 30), r(60)), klog.Ɀ_Time_(9, 00)},
{RoundToNearest(klog.Ɀ_Time_(8, 59), r(60)), klog.Ɀ_Time_(9, 00)},
// Round near day border
{RoundToNearest(klog.Ɀ_Time_(0, 01), r(5)), klog.Ɀ_Time_(0, 00)},
{RoundToNearest(klog.Ɀ_Time_(23, 59), r(15)), klog.Ɀ_TimeTomorrow_(0, 00)},
{RoundToNearest(klog.Ɀ_TimeYesterday_(23, 59), r(10)), klog.Ɀ_Time_(0, 00)},
// It can’t get higher than `23:59>`:
{RoundToNearest(klog.Ɀ_TimeTomorrow_(23, 59), r(60)), klog.Ɀ_TimeTomorrow_(23, 59)},
} {
assert.Equal(t, tm.exp, tm.original)
}
}
070701000000B0000081A40000000000000000000000016863F92F00000076000000000000000000000000000000000000002100000000klog-6.6/klog/service/service.go/*
Package service contains utilities that address various common use-cases
of processing records.
*/
package service
070701000000B1000081A40000000000000000000000016863F92F000007DD000000000000000000000000000000000000001E00000000klog-6.6/klog/service/tags.gopackage service
import (
"github.com/jotaen/klog/klog"
"sort"
)
type TagStats struct {
Tag klog.Tag
// Total is the total duration allotted to the tag.
Total klog.Duration
// Count is the total number of matching entries for that tag.
// I.e., this is *not* how often a tag appears in the record text.
Count int
keyForSort string
}
// AggregateTotalsByTags returns a list of tags (sorted by tag, alphanumerically)
// that contains statistics about the tags appearing in the data.
func AggregateTotalsByTags(rs ...klog.Record) ([]TagStats, TagStats) {
tagStats := make(totalByTag)
untagged := TagStats{
Tag: klog.NewTagOrPanic("_", ""),
Total: klog.NewDuration(0, 0),
Count: 0,
keyForSort: "",
}
for _, r := range rs {
for _, e := range r.Entries() {
allTags := klog.Merge(r.Summary().Tags(), e.Summary().Tags())
if allTags.IsEmpty() {
untagged.Count += 1
untagged.Total = untagged.Total.Plus(e.Duration())
continue
}
alreadyCounted := make(map[klog.Tag]bool)
for tag := range allTags.ForLookup() {
if alreadyCounted[tag] {
continue
}
tagStats.put(tag, e.Duration())
}
}
}
return tagStats.toSortedList(), untagged
}
// Structure: "tagName":"tagValue":TagStats
type totalByTag map[string]map[string]*TagStats
func (tbt totalByTag) put(t klog.Tag, d klog.Duration) {
if tbt[t.Name()] == nil {
tbt[t.Name()] = make(map[string]*TagStats)
}
if tbt[t.Name()][t.Value()] == nil {
tbt[t.Name()][t.Value()] = &TagStats{
Tag: t,
Total: klog.NewDuration(0, 0),
Count: 0,
keyForSort: t.Name() + "=" + t.Value(),
}
}
stats := tbt[t.Name()][t.Value()]
stats.Total = stats.Total.Plus(d)
stats.Count++
}
func (tbt totalByTag) toSortedList() []TagStats {
var result []TagStats
for _, ts := range tbt {
for _, t := range ts {
result = append(result, *t)
}
}
sort.Slice(result, func(i int, j int) bool {
return result[i].keyForSort < result[j].keyForSort
})
return result
}
070701000000B2000081A40000000000000000000000016863F92F00000F4E000000000000000000000000000000000000002300000000klog-6.6/klog/service/tags_test.gopackage service
import (
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestAggregateTotalTimesByTag(t *testing.T) {
rs := []klog.Record{
func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.SetSummary(klog.Ɀ_RecordSummary_("#foo"))
r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=1"))
r.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#foo"))
r.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#test"))
return r
}(),
func() klog.Record {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 2))
r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=2"))
r.AddDuration(klog.NewDuration(8, 0), klog.Ɀ_EntrySummary_("#bar"))
r.AddDuration(klog.NewDuration(0, 45), klog.Ɀ_EntrySummary_("no tag"))
return r
}(),
}
tagStats, untagged := AggregateTotalsByTags(rs...)
require.Len(t, tagStats, 5)
assert.Equal(t, klog.NewDuration(0, 45), untagged.Total)
assert.Equal(t, 1, untagged.Count)
i := 0
assert.Equal(t, klog.NewTagOrPanic("bar", ""), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(8, 0), tagStats[i].Total)
assert.Equal(t, 1, tagStats[i].Count)
i++
assert.Equal(t, klog.NewTagOrPanic("foo", ""), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(5, 30), tagStats[i].Total)
assert.Equal(t, 4, tagStats[i].Count)
i++
assert.Equal(t, klog.NewTagOrPanic("foo", "1"), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(1, 0), tagStats[i].Total)
assert.Equal(t, 1, tagStats[i].Count)
i++
assert.Equal(t, klog.NewTagOrPanic("foo", "2"), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(1, 0), tagStats[i].Total)
assert.Equal(t, 1, tagStats[i].Count)
i++
assert.Equal(t, klog.NewTagOrPanic("test", ""), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(0, 30), tagStats[i].Total)
assert.Equal(t, 1, tagStats[i].Count)
}
func TestAggregateTotalIgnoresRedundantTags(t *testing.T) {
r := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r.SetSummary(klog.Ɀ_RecordSummary_("#foo #foo #foo=1"))
r.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#foo=1 #foo"))
r.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#foo=2 #foo=1 #foo"))
tagStats, _ := AggregateTotalsByTags(r)
require.Len(t, tagStats, 3)
i := 0
assert.Equal(t, klog.NewTagOrPanic("foo", ""), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(4, 0), tagStats[i].Total)
assert.Equal(t, 2, tagStats[i].Count)
i++
assert.Equal(t, klog.NewTagOrPanic("foo", "1"), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(4, 0), tagStats[i].Total)
assert.Equal(t, 2, tagStats[i].Count)
i++
assert.Equal(t, klog.NewTagOrPanic("foo", "2"), tagStats[i].Tag)
assert.Equal(t, klog.NewDuration(3, 0), tagStats[i].Total)
assert.Equal(t, 1, tagStats[i].Count)
}
func TestAggregateTotalTimesByTagSortsAlphabetically(t *testing.T) {
r1 := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 1))
r1.AddDuration(klog.NewDuration(1, 0), klog.Ɀ_EntrySummary_("#bbb"))
r1.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#aaa"))
r1.AddDuration(klog.NewDuration(3, 0), klog.Ɀ_EntrySummary_("#ddd"))
r2 := klog.NewRecord(klog.Ɀ_Date_(2020, 1, 2))
r2.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#ccc=1"))
r2.AddDuration(klog.NewDuration(0, 30), klog.Ɀ_EntrySummary_("#ccc=2"))
tagStats, _ := AggregateTotalsByTags(r1, r2)
require.Len(t, tagStats, 6)
i := 0
assert.Equal(t, klog.NewTagOrPanic("aaa", ""), tagStats[i].Tag)
i += 1
assert.Equal(t, klog.NewTagOrPanic("bbb", ""), tagStats[i].Tag)
i += 1
assert.Equal(t, klog.NewTagOrPanic("ccc", ""), tagStats[i].Tag)
i += 1
assert.Equal(t, klog.NewTagOrPanic("ccc", "1"), tagStats[i].Tag)
i += 1
assert.Equal(t, klog.NewTagOrPanic("ccc", "2"), tagStats[i].Tag)
i += 1
assert.Equal(t, klog.NewTagOrPanic("ddd", ""), tagStats[i].Tag)
}
070701000000B3000081A40000000000000000000000016863F92F00001994000000000000000000000000000000000000002100000000klog-6.6/klog/service/warning.gopackage service
import (
"sort"
gotime "time"
"github.com/jotaen/klog/klog"
)
// Warning contains information for helping locate an issue.
type Warning struct {
date klog.Date
origin checker
}
// Date is the date of the record that the warning refers to.
func (w Warning) Date() klog.Date {
return w.date
}
// Warning is a short description of the problem.
func (w Warning) Warning() string {
return w.origin.Message()
}
type checker interface {
Warn(klog.Record) klog.Date
Message() string
Name() string
}
// DisabledCheckers is a lookup table for checkers that the user wants to opt out of.
type DisabledCheckers map[string]bool
// NewDisabledCheckers creates a new lookup table with all checkers opted-in (enabled).
func NewDisabledCheckers() DisabledCheckers {
return map[string]bool{
(&unclosedOpenRangeChecker{}).Name(): false,
(&futureEntriesChecker{}).Name(): false,
(&overlappingTimeRangesChecker{}).Name(): false,
(&moreThan24HoursChecker{}).Name(): false,
}
}
// CheckForWarnings checks records for potential logical issues in the data. For every
// issue encountered, it invokes the `onWarn` callback. Note: Warnings are not meant as
// strict validation, but the main purpose is to help users spot accidental mistakes users
// might have made. The checks are limited to record-level, because otherwise it would
// need to make assumptions on how records are organised within or across files.
func CheckForWarnings(onWarn func(Warning), reference gotime.Time, rs []klog.Record, disabledCheckers DisabledCheckers) {
now := NewDateTimeFromGo(reference)
sortedRs := Sort(rs, false)
checkers := []checker{
&unclosedOpenRangeChecker{today: now.Date},
&futureEntriesChecker{now: now, gracePeriod: klog.NewDuration(0, 31)},
&overlappingTimeRangesChecker{},
&moreThan24HoursChecker{},
}
for _, r := range sortedRs {
for _, c := range checkers {
if disabledCheckers[c.Name()] {
continue
}
d := c.Warn(r)
if d != nil {
onWarn(Warning{
date: d,
origin: c,
})
}
}
}
}
type unclosedOpenRangeChecker struct {
today klog.Date
encounteredRecordAtToday bool
}
// Warn returns warnings for all open ranges before yesterday, as these
// cannot be closed anymore via a shifted time. It also returns a warning
// if there is an open range yesterday, when there is a record today already.
func (c *unclosedOpenRangeChecker) Warn(record klog.Record) klog.Date {
if record.Date().IsEqualTo(c.today) {
// Open ranges at today’s date are always okay
c.encounteredRecordAtToday = true
return nil
}
if !c.encounteredRecordAtToday && c.today.PlusDays(-1).IsEqualTo(record.Date()) {
// Open ranges at yesterday’s date are only okay if there is no entry today
return nil
}
if record.OpenRange() != nil {
// Any other case is most likely a mistake
return record.Date()
}
return nil
}
func (c *unclosedOpenRangeChecker) Message() string {
return "Unclosed open range"
}
func (c *unclosedOpenRangeChecker) Name() string {
return "UNCLOSED_OPEN_RANGE"
}
type futureEntriesChecker struct {
now DateTime
gracePeriod klog.Duration
}
// Warn returns warnings if there are entries at future dates. It doesn’t
// return warnings if there are future records that don’t contain entries.
func (c *futureEntriesChecker) Warn(record klog.Record) klog.Date {
if len(record.Entries()) == 0 {
return nil
}
if c.now.Date.PlusDays(-2).IsAfterOrEqual(record.Date()) {
return nil
}
if c.now.Date.PlusDays(-1).IsEqualTo(record.Date()) || c.now.Date.IsEqualTo(record.Date()) || c.now.Date.PlusDays(1).IsEqualTo(record.Date()) {
countEntriesWithFutureTimes := 0
fuzzyNow := func() DateTime {
incTime, err := c.now.Time.Plus(c.gracePeriod)
if err != nil {
return c.now
}
return NewDateTime(c.now.Date, incTime)
}()
for _, e := range record.Entries() {
countEntriesWithFutureTimes += klog.Unbox[int](&e,
func(r klog.Range) int {
if NewDateTime(record.Date(), r.Start()).IsAfterOrEqual(fuzzyNow) || NewDateTime(record.Date(), r.End()).IsAfterOrEqual(fuzzyNow) {
return 1
}
return 0
}, func(klog.Duration) int {
if record.Date().IsAfterOrEqual(c.now.Date.PlusDays(1)) {
return 1
}
return 0
}, func(or klog.OpenRange) int {
if NewDateTime(record.Date(), or.Start()).IsAfterOrEqual(fuzzyNow) {
return 1
}
return 0
})
}
if countEntriesWithFutureTimes == 0 {
return nil
}
}
return record.Date()
}
func (c *futureEntriesChecker) Message() string {
return "Entry in the future"
}
func (c *futureEntriesChecker) Name() string {
return "FUTURE_ENTRIES"
}
type overlappingTimeRangesChecker struct{}
// Warn returns warnings if there are entries with overlapping time ranges.
// E.g. `8:00-9:00` and `8:30-9:30`.
func (c *overlappingTimeRangesChecker) Warn(record klog.Record) klog.Date {
var orderedRanges []klog.Range
for _, e := range record.Entries() {
klog.Unbox(&e,
func(r klog.Range) any {
orderedRanges = append(orderedRanges, r)
return nil
},
func(klog.Duration) any { return nil },
func(or klog.OpenRange) any {
// As best guess, assume open ranges to be closed at the end of the day.
end, tErr := klog.NewTime(23, 59)
if tErr != nil {
return nil
}
tr, rErr := klog.NewRange(or.Start(), end)
if rErr != nil {
return nil
}
orderedRanges = append(orderedRanges, tr)
return nil
},
)
}
sort.Slice(orderedRanges, func(i, j int) bool {
return orderedRanges[j].Start().IsAfterOrEqual(orderedRanges[i].Start())
})
for i, curr := range orderedRanges {
if i == 0 {
continue
}
if curr.Start().IsEqualTo(curr.End()) {
// Ignore point-in-time ranges
continue
}
prev := orderedRanges[i-1]
if !curr.Start().IsAfterOrEqual(prev.End()) {
return record.Date()
}
}
return nil
}
func (c *overlappingTimeRangesChecker) Message() string {
return "Overlapping time ranges"
}
func (c *overlappingTimeRangesChecker) Name() string {
return "OVERLAPPING_RANGES"
}
type moreThan24HoursChecker struct{}
// Warn returns warnings if there are records with a total time of more than 24h.
func (c *moreThan24HoursChecker) Warn(record klog.Record) klog.Date {
if Total(record).InMinutes() > 24*60 {
return record.Date()
}
return nil
}
func (c *moreThan24HoursChecker) Message() string {
return "Total time exceeds 24 hours"
}
func (c *moreThan24HoursChecker) Name() string {
return "MORE_THAN_24H"
}
070701000000B4000081A40000000000000000000000016863F92F000027F0000000000000000000000000000000000000002600000000klog-6.6/klog/service/warning_test.gopackage service
import (
"testing"
gotime "time"
"github.com/jotaen/klog/klog"
"github.com/stretchr/testify/assert"
)
func countWarningsOfKind(c checker, ws []Warning) int {
count := 0
for _, w := range ws {
if w.Warning() == c.Message() {
count++
}
}
return count
}
func collectWarnings(reference gotime.Time, rs []klog.Record) []Warning {
var ws []Warning
CheckForWarnings(func(w Warning) {
ws = append(ws, w)
}, reference, rs, NewDisabledCheckers())
return ws
}
func TestNoWarnForOpenRanges(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
now := klog.NewTimeFromGo(timestamp)
rs1 := []klog.Record{
func() klog.Record {
// This open range is okay, because there is no record at today’s date
r := klog.NewRecord(today.PlusDays(-1))
r.Start(klog.NewOpenRange(now), nil)
return r
}(), func() klog.Record {
r := klog.NewRecord(today.PlusDays(2))
return r
}(),
}
ws1 := collectWarnings(timestamp, rs1)
assert.Equal(t, 0, countWarningsOfKind(&unclosedOpenRangeChecker{}, ws1))
rs2 := []klog.Record{
func() klog.Record {
r := klog.NewRecord(today)
r.Start(klog.NewOpenRange(now), nil)
return r
}(),
}
ws2 := collectWarnings(timestamp, rs2)
assert.Equal(t, 0, countWarningsOfKind(&unclosedOpenRangeChecker{}, ws2))
}
func TestOpenRangeWarningWhenUnclosedOpenRangeBeforeTodayRegardlessOfOrder(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
now := klog.NewTimeFromGo(timestamp)
// The warnings must work reliably even when the records are not ordered by date initially
rs := []klog.Record{
func() klog.Record {
// NOT OK: There is a record at today’s date
r := klog.NewRecord(today.PlusDays(-1))
r.Start(klog.NewOpenRange(now), nil)
return r
}(), func() klog.Record {
r := klog.NewRecord(today)
return r
}(), func() klog.Record {
// NOT OK: There is a record at today’s date
r := klog.NewRecord(today.PlusDays(-2))
r.Start(klog.NewOpenRange(now), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, 2, countWarningsOfKind(&unclosedOpenRangeChecker{}, ws))
assert.Equal(t, today.PlusDays(-1), ws[0].Date())
assert.Equal(t, today.PlusDays(-2), ws[1].Date())
}
func TestNoWarningForFutureEntries(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
rs := []klog.Record{
func() klog.Record {
// Future entry okay if it doesn’t contain entries
r := klog.NewRecord(today.PlusDays(1))
return r
}(),
func() klog.Record {
r := klog.NewRecord(today.PlusDays(-1))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 0), klog.Ɀ_Time_(12, 30)), nil)
r.AddDuration(klog.NewDuration(2, 0), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today.PlusDays(1))
// Times shifted to yesterday
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(11, 0), klog.Ɀ_TimeYesterday_(12, 30)), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today)
// Has grace period of 30 minutes.
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 0), klog.Ɀ_Time_(12, 30)), nil)
// If the total time exceeds “now”, that’s okay. (0:00-12:30 + 2h would be 14:30)
r.AddDuration(klog.NewDuration(2, 0), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, 0, countWarningsOfKind(&futureEntriesChecker{}, ws))
}
func TestFutureEntriesWarning(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
rs := []klog.Record{
func() klog.Record {
r := klog.NewRecord(today.PlusDays(1))
r.AddDuration(klog.NewDuration(2, 0), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today.PlusDays(4))
r.AddDuration(klog.NewDuration(2, 0), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today.PlusDays(1))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(2, 0), klog.Ɀ_Time_(10, 0)), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(11, 00), klog.Ɀ_Time_(12, 31)), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today)
// Times shifted to next day
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeTomorrow_(1, 00), klog.Ɀ_TimeTomorrow_(3, 0)), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today.PlusDays(1))
// Times shifted to yesterday, but there is also a duration
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(11, 0), klog.Ɀ_TimeYesterday_(12, 30)), nil)
r.AddDuration(klog.NewDuration(2, 0), nil)
return r
}(),
func() klog.Record {
r := klog.NewRecord(today)
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(12, 31)), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, len(rs), countWarningsOfKind(&futureEntriesChecker{}, ws))
}
func TestNoWarnForMoreThan24HoursPerRecord(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
rs := []klog.Record{
func() klog.Record {
r := klog.NewRecord(today.PlusDays(-3))
r.AddDuration(klog.NewDuration(24, 0), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, 0, countWarningsOfKind(&moreThan24HoursChecker{}, ws))
}
func TestMoreThan24HoursPerRecord(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
rs := []klog.Record{
func() klog.Record {
r := klog.NewRecord(today.PlusDays(-1))
r.AddDuration(klog.NewDuration(24, 1), nil)
return r
}(), func() klog.Record {
r := klog.NewRecord(today)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 0), klog.Ɀ_Time_(12, 0)), nil)
r.AddDuration(klog.NewDuration(13, 0), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, len(rs), countWarningsOfKind(&moreThan24HoursChecker{}, ws))
}
func TestNoWarnForOverlappingTimeRanges(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
rs := []klog.Record{
func() klog.Record {
// No overlap
r := klog.NewRecord(today.PlusDays(-9999))
r.AddDuration(klog.NewDuration(5, 0), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(4, 0), klog.Ɀ_Time_(4, 59)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 0), klog.Ɀ_Time_(2, 0)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(2, 0), klog.Ɀ_Time_(4, 0)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(4, 0), klog.Ɀ_Time_(4, 0)), nil) // point in time range
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(5, 0), klog.Ɀ_Time_(6, 0)), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, 0, countWarningsOfKind(&overlappingTimeRangesChecker{}, ws))
}
func TestOverlappingTimeRanges(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
rs := []klog.Record{
func() klog.Record {
// Overlap with started time
r := klog.NewRecord(today)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(1, 30), klog.Ɀ_Time_(5, 45)), nil)
r.Start(klog.NewOpenRange(klog.Ɀ_Time_(3, 0)), nil)
return r
}(), func() klog.Record {
// Overlap with sorted entries
r := klog.NewRecord(today.PlusDays(-1))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 30), klog.Ɀ_Time_(1, 0)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(2, 0), klog.Ɀ_Time_(5, 0)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(4, 59), klog.Ɀ_Time_(6, 0)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(18, 30), klog.Ɀ_Time_(19, 0)), nil)
return r
}(), func() klog.Record {
// Overlap with unsorted entries
r := klog.NewRecord(today.PlusDays(-2))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 30), klog.Ɀ_Time_(0, 45)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(2, 45), klog.Ɀ_Time_(3, 45)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_TimeYesterday_(23, 0), klog.Ɀ_Time_(1, 0)), nil)
return r
}(),
}
ws := collectWarnings(timestamp, rs)
assert.Equal(t, len(rs), countWarningsOfKind(&overlappingTimeRangesChecker{}, ws))
}
func TestNoWarningsWithDisabledCheckers(t *testing.T) {
timestamp := gotime.Date(2000, 3, 5, 12, 00, 0, 0, gotime.Local)
today := klog.NewDateFromGo(timestamp)
now := klog.NewTimeFromGo(timestamp)
for _, x := range []struct {
dc DisabledCheckers
exp int
}{
// No disabled checkers (default)
{func() DisabledCheckers {
dc := NewDisabledCheckers()
return dc
}(), 4},
// One checker disabled
{func() DisabledCheckers {
dc := NewDisabledCheckers()
dc["MORE_THAN_24H"] = true
return dc
}(), 3},
// Multiple checkers disabled
{func() DisabledCheckers {
dc := NewDisabledCheckers()
dc["FUTURE_ENTRIES"] = true
dc["UNCLOSED_OPEN_RANGE"] = true
return dc
}(), 2},
// All checkers disabled
{func() DisabledCheckers {
dc := NewDisabledCheckers()
dc["MORE_THAN_24H"] = true
dc["OVERLAPPING_RANGES"] = true
dc["FUTURE_ENTRIES"] = true
dc["UNCLOSED_OPEN_RANGE"] = true
return dc
}(), 0},
} {
rs := []klog.Record{
// Unclosed open range
func() klog.Record {
r := klog.NewRecord(today.PlusDays(-2))
r.Start(klog.NewOpenRange(now), nil)
return r
}(),
// Future entries
func() klog.Record {
r := klog.NewRecord(today.PlusDays(4))
r.AddDuration(klog.NewDuration(2, 0), nil)
return r
}(),
// More than 24h
func() klog.Record {
r := klog.NewRecord(today.PlusDays(-3))
r.AddDuration(klog.NewDuration(25, 0), nil)
return r
}(),
// Overlapping entries
func() klog.Record {
r := klog.NewRecord(today.PlusDays(-2))
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 15), klog.Ɀ_Time_(1, 30)), nil)
r.AddRange(klog.Ɀ_Range_(klog.Ɀ_Time_(0, 45), klog.Ɀ_Time_(3, 45)), nil)
return r
}(),
}
var ws []Warning
CheckForWarnings(func(w Warning) {
ws = append(ws, w)
}, timestamp, rs, x.dc)
assert.Len(t, ws, x.exp)
}
}
070701000000B5000081A40000000000000000000000016863F92F0000014B000000000000000000000000000000000000001D00000000klog-6.6/klog/shouldtotal.gopackage klog
// ShouldTotal represents the targeted total time of a Record.
type ShouldTotal Duration
type shouldTotal struct {
Duration
}
func NewShouldTotal(hours int, minutes int) ShouldTotal {
return shouldTotal{NewDuration(hours, minutes)}
}
func (s shouldTotal) ToString() string {
return s.Duration.ToString() + "!"
}
070701000000B6000081A40000000000000000000000016863F92F000009C7000000000000000000000000000000000000001900000000klog-6.6/klog/summary.gopackage klog
import (
"errors"
"regexp"
)
// RecordSummary contains the summary lines of the overall summary that
// appears underneath the date of a record.
type RecordSummary []string
// EntrySummary contains the summary line that appears behind the time value
// of an entry.
type EntrySummary []string
var recordSummaryLinePattern = regexp.MustCompile(`^[\p{Zs}\t]`)
// NewRecordSummary creates a new RecordSummary from individual lines of text.
// None of the lines can start with blank characters, and none of the lines
// can be empty or blank.
func NewRecordSummary(line ...string) (RecordSummary, error) {
for _, l := range line {
if len(l) == 0 || recordSummaryLinePattern.MatchString(l) {
return nil, errors.New("MALFORMED_SUMMARY")
}
}
return line, nil
}
var entrySummaryLinePattern = regexp.MustCompile("^[\\p{Zs}\t]*$")
// NewEntrySummary creates an EntrySummary from individual lines of text.
// Except for the first line, none of the lines can be empty or blank.
func NewEntrySummary(line ...string) (EntrySummary, error) {
for i, l := range line {
if i == 0 {
continue
}
if len(l) == 0 || entrySummaryLinePattern.MatchString(l) {
return nil, errors.New("MALFORMED_SUMMARY")
}
}
return line, nil
}
func (s RecordSummary) Lines() []string {
return s
}
func (s EntrySummary) Lines() []string {
return RecordSummary(s).Lines()
}
func (s RecordSummary) Tags() *TagSet {
tags := NewEmptyTagSet()
for _, l := range s {
for _, m := range HashTagPattern.FindAllStringSubmatch(l, -1) {
tag, _ := NewTagFromString(m[0])
tags.Put(tag)
}
}
return &tags
}
// Tags returns the tags that the entry summary contains.
func (s EntrySummary) Tags() *TagSet {
return RecordSummary(s).Tags()
}
func (s RecordSummary) Equals(summary RecordSummary) bool {
if len(s) != len(summary) {
return false
}
for i, l := range s {
if l != summary[i] {
return false
}
}
return true
}
func (s EntrySummary) Equals(summary EntrySummary) bool {
if len(s) == 1 && s[0] == "" && summary == nil {
// In the case of entry summary, an empty one matches nil.
return true
}
return RecordSummary(s).Equals(RecordSummary(summary))
}
// Append appends a text to an entry summary
func (s EntrySummary) Append(appendableText string) EntrySummary {
if len(s) == 0 {
return []string{appendableText}
}
delimiter := ""
lastLine := s[len(s)-1]
if len(lastLine) > 0 {
delimiter = " "
}
s[len(s)-1] = lastLine + delimiter + appendableText
return s
}
070701000000B7000081A40000000000000000000000016863F92F00001C93000000000000000000000000000000000000001E00000000klog-6.6/klog/summary_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestCreatesEmptySummary(t *testing.T) {
recordSummary, rErr := NewRecordSummary()
require.Nil(t, rErr)
assert.Nil(t, recordSummary.Lines())
assert.True(t, recordSummary.Tags().IsEmpty())
entrySummary, eErr := NewEntrySummary()
require.Nil(t, eErr)
assert.Nil(t, entrySummary.Lines())
assert.True(t, entrySummary.Tags().IsEmpty())
}
func TestCreatesValidSingleLineSummary(t *testing.T) {
recordSummary, rErr := NewRecordSummary("First line")
require.Nil(t, rErr)
assert.Equal(t, []string{"First line"}, recordSummary.Lines())
assert.True(t, recordSummary.Tags().IsEmpty())
entrySummary, eErr := NewEntrySummary("First line")
require.Nil(t, eErr)
assert.Equal(t, []string{"First line"}, entrySummary.Lines())
assert.True(t, entrySummary.Tags().IsEmpty())
}
func TestCreatesValidMultilineSummary(t *testing.T) {
recordSummary, rErr := NewRecordSummary("First line", "Second line")
require.Nil(t, rErr)
assert.Equal(t, []string{"First line", "Second line"}, recordSummary.Lines())
assert.True(t, recordSummary.Tags().IsEmpty())
entrySummary, eErr := NewEntrySummary("First line", "Second line")
require.Nil(t, eErr)
assert.Equal(t, []string{"First line", "Second line"}, entrySummary.Lines())
assert.True(t, entrySummary.Tags().IsEmpty())
}
func TestRecordSummaryCannotContainBlankLines(t *testing.T) {
for _, l := range [][]string{
{""},
{" "},
{"\u00a0\u00a0\u00a0\u00a0"},
{"Foo", "\u00a0\u00a0\u00a0\u00a0"},
{"\t\t"},
{"Hello", " ", "Foo"},
{"Hello", "\t", "Foo"},
{"Hello", "", "Foo"},
{"Hello", "Foo", ""},
} {
recordSummary, err := NewRecordSummary(l...)
require.Error(t, err)
require.Nil(t, recordSummary)
}
}
func TestRecordSummaryCannotContainWhitespaceAtBeginningOfLine(t *testing.T) {
for _, l := range [][]string{
{" Hello"},
{"\u00a0Hello"},
{"\u2000Hello"},
{"\u2007Hello"},
{"\tHello"},
{"Hello", " World"},
{"Hello", "\tWorld"},
{"Hello", "\u00a0World"},
} {
summary, err := NewRecordSummary(l...)
require.Error(t, err)
require.Nil(t, summary)
}
}
func TestEntrySummaryCanStartWithBlankOrEmptyLine(t *testing.T) {
for _, l := range [][]string{
{"", "Foo"},
{" ", "Foo", "Bar"},
{"\t", " Foo"},
{"\u00a0", "\tFoo "},
{"\u00a0\t \t ", " Foo", "\u00a0Baz \t"},
} {
entrySummary, err := NewEntrySummary(l...)
require.Nil(t, err)
require.NotNil(t, entrySummary)
}
}
func TestEntrySummaryCannotContainSubsequentBlankLines(t *testing.T) {
for _, l := range [][]string{
{"Foo", ""},
{"Foo", " "},
{"Foo", "\u00a0\u00a0\u00a0\u00a0"},
{"Foo", "\t\t"},
{"Hello", " ", "Foo"},
{"Hello", "\t", "Foo"},
{"Hello", "", "Foo"},
{"Hello", "Foo", ""},
} {
entrySummary, err := NewEntrySummary(l...)
require.Error(t, err)
require.Nil(t, entrySummary)
}
}
func TestDetectsSummaryEquality(t *testing.T) {
for _, x := range [][]string{
nil,
{""},
{"a"},
{"a", "b"},
} {
entrySummary1, _ := NewEntrySummary(x...)
entrySummary2, _ := NewEntrySummary(x...)
assert.True(t, entrySummary1.Equals(entrySummary2))
assert.True(t, entrySummary2.Equals(entrySummary1))
recordSummary1, _ := NewRecordSummary(x...)
recordSummary2, _ := NewRecordSummary(x...)
assert.True(t, recordSummary1.Equals(recordSummary2))
assert.True(t, recordSummary2.Equals(recordSummary1))
}
}
func TestEqualityOfEmptyEntrySummary(t *testing.T) {
emptyEntrySummary, _ := NewEntrySummary()
assert.True(t, emptyEntrySummary.Equals(nil))
blankEntrySummary, _ := NewEntrySummary("")
assert.True(t, blankEntrySummary.Equals(nil))
}
func TestDetectsSummaryInequality(t *testing.T) {
for _, x := range []struct {
ls1 []string
ls2 []string
}{
{[]string{"a"}, nil},
{[]string{"a"}, []string{"b"}},
{[]string{"a"}, []string{"a", "b"}},
{[]string{"a"}, []string{"a", ""}},
} {
{
entrySummary1, _ := NewEntrySummary(x.ls1...)
entrySummary2, _ := NewEntrySummary(x.ls2...)
assert.False(t, entrySummary1.Equals(entrySummary2))
assert.False(t, entrySummary2.Equals(entrySummary1))
}
{
recordSummary1, _ := NewRecordSummary(x.ls1...)
recordSummary2, _ := NewRecordSummary(x.ls2...)
assert.False(t, recordSummary1.Equals(recordSummary2))
assert.False(t, recordSummary2.Equals(recordSummary1))
}
}
}
func TestRecognisesAllTags(t *testing.T) {
recordSummary, _ := NewRecordSummary(
"Hello #world, I feel",
"(super #GREAT) today #123_test: #234-foo!",
"#太陽 #λουλούδι #पहाड #мир #Léift #ΓΕΙΑ-ΣΑΣ",
)
assert.Equal(t, recordSummary.Tags().ToStrings(), []string{
"#world", "#great", "#123_test", "#234-foo", "#太陽", "#λουλούδι", "#पह", "#мир", "#léift", "#γεια-σασ",
})
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("123_test", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("234-foo", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("太陽", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("λουλούδι", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("γεια-σασ", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("GREAT", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("Great", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("great", "")))
assert.True(t, recordSummary.Tags().Contains(NewTagOrPanic("world", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("foo", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("test", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("test", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("ടെലിഫോണ്", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("123", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("wor", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("super", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("маркуч", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("grea", "")))
assert.False(t, recordSummary.Tags().Contains(NewTagOrPanic("blabla", "")))
entrySummary, _ := NewEntrySummary("Hello #world, I feel #great #TODAY")
assert.Equal(t, entrySummary.Tags().ToStrings(), []string{"#world", "#great", "#today"})
}
func TestAppendsToEntrySummary(t *testing.T) {
t.Run("append empty to empty", func(t *testing.T) {
e := Ɀ_EntrySummary_().Append("")
assert.Equal(t, []string{""}, e.Lines())
})
t.Run("append non-empty to zero", func(t *testing.T) {
e := Ɀ_EntrySummary_().Append("foo")
assert.Equal(t, []string{"foo"}, e.Lines())
})
t.Run("append non-empty to empty", func(t *testing.T) {
e := Ɀ_EntrySummary_("").Append("foo")
assert.Equal(t, []string{"foo"}, e.Lines())
})
t.Run("append non-empty to existing", func(t *testing.T) {
e := Ɀ_EntrySummary_("hello").Append("foo")
assert.Equal(t, []string{"hello foo"}, e.Lines())
})
t.Run("append non-empty to multiline", func(t *testing.T) {
e := Ɀ_EntrySummary_("hello", "world").Append("foo")
assert.Equal(t, []string{"hello", "world foo"}, e.Lines())
})
}
070701000000B8000081A40000000000000000000000016863F92F00000C11000000000000000000000000000000000000001500000000klog-6.6/klog/tag.gopackage klog
import (
"errors"
"regexp"
"strings"
)
var HashTagPattern = regexp.MustCompile(`#([\p{L}\d_-]+)(=(("[^"]*")|('[^']*')|([\p{L}\d_-]*)))?`)
var unquotedValuePattern = regexp.MustCompile(`^[\p{L}\d_-]+$`)
type Tag struct {
name string
value string
}
func NewTagFromString(tag string) (Tag, error) {
if !strings.HasPrefix(tag, "#") {
tag = "#" + tag
}
match := HashTagPattern.FindStringSubmatch(tag)
if match == nil {
// The tag pattern didn’t match at all.
return Tag{}, errors.New("INVALID_TAG")
}
name := match[1]
value := func() string {
v := match[3]
if strings.HasPrefix(v, `"`) {
return strings.Trim(v, `"`)
}
if strings.HasPrefix(v, `'`) {
return strings.Trim(v, `'`)
}
return v
}()
if len(match[0]) != len(tag) {
// The original tag contains more/other characters.
return Tag{}, errors.New("INVALID_TAG")
}
return NewTagOrPanic(name, value), nil
}
// NewTagOrPanic constructs a new tag but will panic if the
// parameters don’t yield a valid tag.
func NewTagOrPanic(name string, value string) Tag {
if strings.Contains(value, "\"") && strings.Contains(value, "'") {
// A tag value can never contain both ' and " at the same time.
panic("Invalid tag")
}
return Tag{strings.ToLower(name), value}
}
func (t Tag) Name() string {
return t.name
}
func (t Tag) Value() string {
return t.value
}
func (t Tag) ToString() string {
result := "#" + t.name
if t.value != "" {
result += "="
quotation := ""
if !unquotedValuePattern.MatchString(t.value) {
if strings.Contains(t.value, `"`) {
quotation = `'`
} else {
quotation = "\""
}
}
result += quotation + t.value + quotation
}
return result
}
type TagSet struct {
lookup map[Tag]bool
original []Tag
}
func NewEmptyTagSet() TagSet {
return TagSet{
lookup: make(map[Tag]bool),
original: []Tag{},
}
}
// Put inserts the tag into the TagSet.
func (ts *TagSet) Put(tag Tag) {
ts.lookup[tag] = true
ts.lookup[NewTagOrPanic(tag.Name(), "")] = true
ts.original = append(ts.original, tag)
}
// Contains checks whether the TagSet contains the given tag.
// Note that if the TagSet contains a tag with value, then this
// will always yield a match against the base tag (without value).
func (ts *TagSet) Contains(tag Tag) bool {
return ts.lookup[tag]
}
// IsEmpty checks whether the TagSet contains something or not.
func (ts *TagSet) IsEmpty() bool {
return len(ts.lookup) == 0
}
// ForLookup returns a denormalised and unordered representation
// of the TagSet.
func (ts *TagSet) ForLookup() map[Tag]bool {
return ts.lookup
}
// ToStrings returns the tags as string, in their original order
// and without deduplication or normalisation.
func (ts *TagSet) ToStrings() []string {
tags := make([]string, len(ts.original))
for i, t := range ts.original {
tags[i] = t.ToString()
}
return tags
}
// Merge combines multiple tag sets into a new one.
func Merge(tagSets ...*TagSet) TagSet {
result := NewEmptyTagSet()
for _, ts := range tagSets {
for t := range ts.lookup {
result.Put(t)
}
}
return result
}
070701000000B9000081A40000000000000000000000016863F92F0000122C000000000000000000000000000000000000001A00000000klog-6.6/klog/tag_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestCreatesNewTag(t *testing.T) {
for _, x := range []struct {
tag string
expectName string
}{
{"#tag", "tag"},
{"#TAG", "tag"},
{"#t-a-g", "t-a-g"},
{"#t_a_g", "t_a_g"},
{"#t1a2g3", "t1a2g3"},
{"#---", "---"},
{"#___", "___"},
} {
tag, err := NewTagFromString(x.tag)
require.Nil(t, err)
assert.Equal(t, x.expectName, tag.Name())
}
}
func TestTagMatching(t *testing.T) {
for _, x := range []struct {
tag1 string
tag2 string
}{
// Identity
{`#tag`, `#tag`},
{`#tag=value`, `#tag=value`},
// Value empty is the same as value absent
{`#tag`, `#tag=`},
{`#tag`, `#tag=""`},
{`#tag`, `#tag=''`},
{`#tag=`, `#tag`},
{`#tag=""`, `#tag`},
{`#tag=''`, `#tag`},
{`#tag=''`, `#tag=""`},
{`#tag=''`, `#tag=`},
// Case-insensitivity of name
{`#TAG`, `#tag`},
{`#TAG=value`, `#tag=value`},
// Quotation style is irrelevant
{`#tag=value`, `#tag="value"`},
{`#tag=value`, `#tag='value'`},
{`#tag="value"`, `#tag='value'`},
} {
first, err1 := NewTagFromString(x.tag1)
require.Nil(t, err1)
second, err2 := NewTagFromString(x.tag2)
require.Nil(t, err2)
assert.Equal(t, first, second)
}
}
func TestTagIsNotMatching(t *testing.T) {
for _, x := range []struct {
tag1 string
tag2 string
}{
// Name is different
{`#tag`, `#t-a-g`},
{`#tag`, `#tags`},
// Query has value, but base hasn’t
{`#tag`, `#tag=value`},
// Query value is different than base’s
{`#tag=value`, `#tag=VALUE`},
{`#tag=value`, `#tag=foo`},
{`#tag='V A L U E'`, `#tag='v a l u e'`},
{`#tag=''`, `#tag=' '`},
} {
first, err1 := NewTagFromString(x.tag1)
require.Nil(t, err1)
second, err2 := NewTagFromString(x.tag2)
require.Nil(t, err2)
assert.NotEqual(t, first, second)
}
}
func TestPrecedingHashCharIsOptional(t *testing.T) {
tag, err := NewTagFromString("tag")
require.Nil(t, err)
assert.Equal(t, "tag", tag.Name())
}
func TestRejectsInvalidTags(t *testing.T) {
for _, name := range []string{
"",
"##tag",
"##tag",
"a#tag",
"a #tag",
"#tag#tag",
"#tag #tag",
"#t^a*g",
"#tag?",
"#tag:tag",
"#tag!!!",
"#t-a?g",
`#tag=foo=bar`,
`#tag='foo`,
`#tag='It's great'`,
`#tag="foo`,
`#tag="foo`,
`#tag="`,
} {
_, err := NewTagFromString(name)
require.Error(t, err)
}
}
func TestCreatesNewTagWithValue(t *testing.T) {
for _, x := range []struct {
tag string
expectValue string
}{
{`#tag=value`, `value`},
{`#tag=VALUE`, `VALUE`},
{`#tag=V_A_L_U_E`, `V_A_L_U_E`},
{`#tag=v-a-l-u-e`, `v-a-l-u-e`},
{`#tag=v-a-l-u-e`, `v-a-l-u-e`},
{`#tag="v a l u e"`, `v a l u e`},
{`#tag='v!a?l,u=e'`, `v!a?l,u=e`},
{`#tag='foo=bar'`, `foo=bar`},
} {
tag, err := NewTagFromString(x.tag)
require.Nil(t, err)
assert.Equal(t, x.expectValue, tag.Value())
}
}
func TestSerialiseTag(t *testing.T) {
tagWithoutValue := NewTagOrPanic("test", "")
assert.Equal(t, "#test", tagWithoutValue.ToString())
tagWithValue := NewTagOrPanic("test", "value")
assert.Equal(t, "#test=value", tagWithValue.ToString())
tagWithValueNeedingQuoting := NewTagOrPanic("test", "v a l u e")
assert.Equal(t, `#test="v a l u e"`, tagWithValueNeedingQuoting.ToString())
tagWithValueContainingDoubleQuote := NewTagOrPanic("test", `It's great`)
assert.Equal(t, `#test="It's great"`, tagWithValueContainingDoubleQuote.ToString())
tagWithValueContainingSingleQuote := NewTagOrPanic("test", `5"`)
assert.Equal(t, `#test='5"'`, tagWithValueContainingSingleQuote.ToString())
}
func TestTagSet(t *testing.T) {
ts := NewEmptyTagSet()
assert.True(t, ts.IsEmpty())
assert.False(t, ts.Contains(NewTagOrPanic("test", "")))
ts.Put(NewTagOrPanic("test", ""))
assert.False(t, ts.IsEmpty())
assert.True(t, ts.Contains(NewTagOrPanic("test", "")))
assert.Equal(t, []string{"#test"}, ts.ToStrings())
ts.Put(NewTagOrPanic("project", "value"))
assert.True(t, ts.Contains(NewTagOrPanic("project", "")))
assert.True(t, ts.Contains(NewTagOrPanic("project", "value")))
assert.False(t, ts.Contains(NewTagOrPanic("project", "other-value")))
assert.False(t, ts.Contains(NewTagOrPanic("project", "VALUE")))
assert.Equal(t, []string{"#test", "#project=value"}, ts.ToStrings())
ts.Put(NewTagOrPanic("FOO", ""))
assert.True(t, ts.Contains(NewTagOrPanic("fOo", "")))
assert.Equal(t, []string{"#test", "#project=value", "#foo"}, ts.ToStrings())
ts.Put(NewTagOrPanic("foo", ""))
assert.True(t, ts.Contains(NewTagOrPanic("foo", "")))
assert.Equal(t, []string{"#test", "#project=value", "#foo", "#foo"}, ts.ToStrings())
}
070701000000BA000081A40000000000000000000000016863F92F00000A42000000000000000000000000000000000000001A00000000klog-6.6/klog/testutil.gopackage klog
/**
Only use these functions in test code.
(They cannot live in a `_test.go` file
because they need to be imported elsewhere.
They cannot live in a separate package
neither due to circular imports.)
The `Deprecated` markers and the funny naming
are supposed to act as a reminder for this.
*/
// Deprecated
func Ɀ_Date_(year int, month int, day int) Date {
date, err := NewDate(year, month, day)
if err != nil {
panic("Operation failed!")
}
return date
}
// Deprecated
func Ɀ_Slashes_(d Date) Date {
df, canCast := d.(*date)
if !canCast {
panic("Operation failed!")
}
df.format.UseDashes = false
return df
}
// Deprecated
func Ɀ_RecordSummary_(line ...string) RecordSummary {
summary, err := NewRecordSummary(line...)
if err != nil {
panic("Operation failed!")
}
return summary
}
// Deprecated
func Ɀ_EntrySummary_(line ...string) EntrySummary {
summary, err := NewEntrySummary(line...)
if err != nil {
panic("Operation failed!")
}
return summary
}
// Deprecated
func Ɀ_ForceSign_(d Duration) Duration {
do, canCast := d.(*duration)
if !canCast {
panic("Operation failed!")
}
do.format.ForcePlus = true
return do
}
// Deprecated
func Ɀ_Time_(hour int, minute int) Time {
time, err := NewTime(hour, minute)
if err != nil {
panic("Operation failed!")
}
return time
}
// Deprecated
func Ɀ_IsAmPm_(t Time) Time {
tm, canCast := t.(*time)
if !canCast {
panic("Operation failed!")
}
tm.format.Use24HourClock = false
return tm
}
// Deprecated
func Ɀ_TimeYesterday_(hour int, minute int) Time {
time, err := NewTimeYesterday(hour, minute)
if err != nil {
panic("Operation failed!")
}
return time
}
// Deprecated
func Ɀ_TimeTomorrow_(hour int, minute int) Time {
time, err := NewTimeTomorrow(hour, minute)
if err != nil {
panic("Operation failed!")
}
return time
}
// Deprecated
func Ɀ_Range_(start Time, end Time) Range {
r, err := NewRange(start, end)
if err != nil {
panic("Operation failed!")
}
return r
}
// Deprecated
func Ɀ_NoSpaces_(r Range) Range {
tr, canCast := r.(*timeRange)
if !canCast {
panic("Operation failed!")
}
tr.format.UseSpacesAroundDash = false
return tr
}
// Deprecated
func Ɀ_NoSpacesO_(r OpenRange) OpenRange {
or, canCast := r.(*openRange)
if !canCast {
panic("Operation failed!")
}
or.format.UseSpacesAroundDash = false
return or
}
// Deprecated
func Ɀ_QuestionMarks_(r OpenRange, additionalQuestionMarks int) OpenRange {
or, canCast := r.(*openRange)
if !canCast {
panic("Operation failed!")
}
or.format.AdditionalPlaceholderChars = additionalQuestionMarks
return or
}
070701000000BB000081A40000000000000000000000016863F92F00001506000000000000000000000000000000000000001600000000klog-6.6/klog/time.gopackage klog
import (
"cloud.google.com/go/civil"
"errors"
"fmt"
"regexp"
"strconv"
gotime "time"
)
// Time represents a wall clock time. It can be shifted to the adjacent dates.
type Time interface {
Hour() int
Minute() int
// MidnightOffset returns the duration since (positive) or until (negative) midnight.
MidnightOffset() Duration
// IsYesterday checks whether the time is shifted to the previous day.
IsYesterday() bool
// IsTomorrow checks whether the time is shifted to the next day.
IsTomorrow() bool
// IsToday checks whether the time is not shifted.
IsToday() bool
IsEqualTo(Time) bool
IsAfterOrEqual(Time) bool
// Plus returns a time, where the specified duration was added. It doesn’t modify
// the original object. If the resulting time would be shifted by more than one
// day, it returns an error.
Plus(Duration) (Time, error)
// ToString serialises the time, e.g. `8:00` or `23:00>`
ToString() string
// ToStringWithFormat serialises the date according to the given format.
ToStringWithFormat(TimeFormat) string
// Format returns the current formatting.
Format() TimeFormat
}
// TimeFormat contains the formatting options for the Time.
type TimeFormat struct {
Use24HourClock bool
}
// DefaultTimeFormat returns the canonical time format, as recommended by the spec.
func DefaultTimeFormat() TimeFormat {
return TimeFormat{
Use24HourClock: true,
}
}
type time struct {
hour int
minute int
dayShift int
format TimeFormat
}
func newTime(hour int, minute int, dayShift int, format TimeFormat) (Time, error) {
if hour == 24 && minute == 00 && dayShift <= 0 {
// Accept a time of 24:00 (today), and interpret it as 0:00 (tomorrow).
// Accept a time of 24:00 (yesterday), and interpret it as 0:00 (today).
// This case is not supported for 24:00 (tomorrow), since that couldn’t be represented.
hour = 0
dayShift += 1
}
ct := civil.Time{Hour: hour, Minute: minute}
if !ct.IsValid() {
return nil, errors.New("INVALID_TIME")
}
return &time{
hour: ct.Hour,
minute: ct.Minute,
dayShift: dayShift,
format: format,
}, nil
}
func NewTime(hour int, minute int) (Time, error) {
return newTime(hour, minute, 0, DefaultTimeFormat())
}
func NewTimeYesterday(hour int, minute int) (Time, error) {
return newTime(hour, minute, -1, DefaultTimeFormat())
}
func NewTimeTomorrow(hour int, minute int) (Time, error) {
return newTime(hour, minute, +1, DefaultTimeFormat())
}
var timePattern = regexp.MustCompile(`^(<)?(\d{1,2}):(\d{2})(am|pm)?(>)?$`)
func NewTimeFromString(hhmm string) (Time, error) {
match := timePattern.FindStringSubmatch(hhmm)
if len(match) != 6 || (match[1] == "<" && match[5] == ">") {
return nil, errors.New("MALFORMED_TIME")
}
hour, _ := strconv.Atoi(match[2])
minute, _ := strconv.Atoi(match[3])
format := DefaultTimeFormat()
if match[4] == "am" || match[4] == "pm" {
if hour < 1 || hour > 12 {
return nil, errors.New("INVALID_TIME")
}
format.Use24HourClock = false
if match[4] == "am" && hour == 12 {
hour = 0
} else if match[4] == "pm" && hour < 12 {
hour += 12
}
}
dayShift := 0
if match[1] == "<" {
dayShift = -1
} else if match[5] == ">" {
dayShift = +1
}
return newTime(hour, minute, dayShift, format)
}
func NewTimeFromGo(t gotime.Time) Time {
time, err := NewTime(t.Hour(), t.Minute())
if err != nil {
// This can/should never occur
panic("Illegal time")
}
return time
}
func (t *time) Hour() int {
return t.hour
}
func (t *time) Minute() int {
return t.minute
}
func (t *time) MidnightOffset() Duration {
if t.IsYesterday() {
return NewDuration(-23+t.Hour(), -60+t.Minute())
} else if t.IsTomorrow() {
return NewDuration(24+t.Hour(), t.Minute())
}
return NewDuration(t.Hour(), t.Minute())
}
func (t *time) IsToday() bool {
return t.dayShift == 0
}
func (t *time) IsYesterday() bool {
return t.dayShift < 0
}
func (t *time) IsTomorrow() bool {
return t.dayShift > 0
}
func (t *time) IsEqualTo(otherTime Time) bool {
return t.MidnightOffset().InMinutes() == otherTime.MidnightOffset().InMinutes()
}
func (t *time) IsAfterOrEqual(otherTime Time) bool {
first := t.MidnightOffset()
second := otherTime.MidnightOffset()
return first.InMinutes() >= second.InMinutes()
}
func (t *time) Plus(d Duration) (Time, error) {
ONE_DAY := 24 * 60
mins := t.MidnightOffset().Plus(d).InMinutes()
if mins >= 2*ONE_DAY || mins < ONE_DAY*-1 {
return nil, errors.New("IMPOSSIBLE_OPERATION")
}
dayShift := 0
if mins < 0 {
dayShift = -1
mins = ONE_DAY + mins
} else if mins > ONE_DAY {
dayShift = 1
mins = mins - ONE_DAY
}
return newTime(mins/60, mins%60, dayShift, t.format)
}
func (t *time) ToString() string {
yesterdayPrefix := ""
if t.IsYesterday() {
yesterdayPrefix = "<"
}
tomorrowSuffix := ""
if t.IsTomorrow() {
tomorrowSuffix = ">"
}
hour, amPmSuffix := func() (int, string) {
if t.format.Use24HourClock {
return t.hour, ""
}
if t.hour == 12 {
return 12, "pm"
}
if t.hour > 12 {
return t.hour - 12, "pm"
}
if t.hour == 0 {
return 12, "am"
}
return t.hour, "am"
}()
return fmt.Sprintf("%s%d:%02d%s%s", yesterdayPrefix, hour, t.minute, amPmSuffix, tomorrowSuffix)
}
func (t *time) ToStringWithFormat(f TimeFormat) string {
c := *t
c.format = f
return c.ToString()
}
func (t *time) Format() TimeFormat {
return t.format
}
070701000000BC000081A40000000000000000000000016863F92F00002292000000000000000000000000000000000000001B00000000klog-6.6/klog/time_test.gopackage klog
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
func TestOnlyConstructsValidTimes(t *testing.T) {
tm, err := NewTime(22, 13)
require.Nil(t, err)
assert.Equal(t, tm.Hour(), 22)
assert.Equal(t, tm.Minute(), 13)
assert.Equal(t, tm.IsToday(), true)
assert.Equal(t, tm.IsYesterday(), false)
assert.Equal(t, tm.IsTomorrow(), false)
}
func TestHandle2400SpecialCase(t *testing.T) {
{
tm, err := NewTime(24, 00)
require.Nil(t, err)
assert.Equal(t, tm.Hour(), 0)
assert.Equal(t, tm.Minute(), 0)
assert.Equal(t, tm.IsToday(), false)
assert.Equal(t, tm.IsYesterday(), false)
assert.Equal(t, tm.IsTomorrow(), true)
}
{
tm, err := NewTimeYesterday(24, 00)
require.Nil(t, err)
assert.Equal(t, tm.Hour(), 0)
assert.Equal(t, tm.Minute(), 0)
assert.Equal(t, tm.IsToday(), true)
assert.Equal(t, tm.IsYesterday(), false)
assert.Equal(t, tm.IsTomorrow(), false)
}
{
// 24:00 tomorrow cannot be represented.
tm, err := NewTimeTomorrow(24, 00)
require.Nil(t, tm)
require.Error(t, err)
}
}
func TestDetectsInvalidTimes(t *testing.T) {
for _, invalidTime := range []struct {
hours int
minutes int
}{
// Invalid hours
{24, 01},
{25, 30},
{124, 34},
{-12, 34},
// Invalid minutes
{05, 60},
{05, 61},
{05, 245},
{05, -12},
// Both invalid
{1575, 28293},
} {
for _, constructor := range []func(int, int) (Time, error){
NewTime, NewTimeYesterday, NewTimeTomorrow,
} {
invalidTime, err := constructor(invalidTime.hours, invalidTime.minutes)
assert.EqualError(t, err, "INVALID_TIME")
assert.Nil(t, invalidTime)
}
}
}
func TestSerialiseTime(t *testing.T) {
tm, err := NewTime(13, 45)
require.Nil(t, err)
assert.Equal(t, "13:45", tm.ToString())
assert.Equal(t, "13:45", tm.ToStringWithFormat(TimeFormat{Use24HourClock: true}))
assert.Equal(t, "1:45pm", tm.ToStringWithFormat(TimeFormat{Use24HourClock: false}))
}
func TestSerialiseTimeWithoutLeadingZeros(t *testing.T) {
tm, err := NewTime(8, 5)
require.Nil(t, err)
assert.Equal(t, "8:05", tm.ToString())
assert.Equal(t, "8:05am", Ɀ_IsAmPm_(tm).ToString())
}
func TestSerialiseTimeYesterday(t *testing.T) {
tm, err := NewTimeYesterday(23, 0)
require.Nil(t, err)
assert.Equal(t, "<23:00", tm.ToString())
assert.Equal(t, "<11:00pm", Ɀ_IsAmPm_(tm).ToString())
}
func TestSerialiseTimeTomorrow(t *testing.T) {
tm, err := NewTimeTomorrow(0, 2)
require.Nil(t, err)
assert.Equal(t, "0:02>", tm.ToString())
assert.Equal(t, "12:02am>", Ɀ_IsAmPm_(tm).ToString())
}
func TestParseTime24Hours(t *testing.T) {
for _, s := range []struct {
val string
exp Time
}{
{"9:42", Ɀ_Time_(9, 42)},
{"09:42", Ɀ_Time_(9, 42)},
{"16:01", Ɀ_Time_(16, 01)},
} {
tm, err := NewTimeFromString(s.val)
require.Nil(t, err)
require.NotNil(t, tm)
assert.Equal(t, s.exp, tm)
assert.True(t, s.exp.IsEqualTo(tm), s.val)
assert.Equal(t, TimeFormat{Use24HourClock: true}, tm.Format())
}
}
func TestParseTime12Hours(t *testing.T) {
for _, s := range []struct {
val string
exp Time
}{
{"12:00am", Ɀ_Time_(0, 00)},
{"12:37am", Ɀ_Time_(0, 37)},
{"1:00am", Ɀ_Time_(1, 0)},
{"1:00am", Ɀ_Time_(1, 0)},
{"12:00pm", Ɀ_Time_(12, 00)},
{"12:22pm", Ɀ_Time_(12, 22)},
{"1:59pm", Ɀ_Time_(13, 59)},
{"7:33pm", Ɀ_Time_(19, 33)},
} {
tm, err := NewTimeFromString(s.val)
require.Nil(t, err)
require.NotNil(t, tm)
assert.Equal(t, Ɀ_IsAmPm_(s.exp), tm)
assert.True(t, s.exp.IsEqualTo(tm), s.val)
assert.Equal(t, TimeFormat{Use24HourClock: false}, tm.Format())
}
}
func TestParseTimeYesterday(t *testing.T) {
for _, s := range []struct {
val string
exp Time
}{
{"<3:43", Ɀ_TimeYesterday_(3, 43)},
{"<03:43", Ɀ_TimeYesterday_(3, 43)},
{"<03:43am", Ɀ_IsAmPm_(Ɀ_TimeYesterday_(3, 43))},
{"<3:43pm", Ɀ_IsAmPm_(Ɀ_TimeYesterday_(15, 43))},
} {
tm, err := NewTimeFromString(s.val)
require.Nil(t, err)
assert.Equal(t, s.exp, tm)
assert.Equal(t, false, tm.IsToday())
assert.Equal(t, true, tm.IsYesterday())
assert.Equal(t, false, tm.IsTomorrow())
}
}
func TestParseTimeTomorrow(t *testing.T) {
for _, s := range []struct {
val string
exp Time
}{
{"2:12>", Ɀ_TimeTomorrow_(2, 12)},
{"02:12>", Ɀ_TimeTomorrow_(2, 12)},
{"2:12am>", Ɀ_IsAmPm_(Ɀ_TimeTomorrow_(2, 12))},
{"02:12pm>", Ɀ_IsAmPm_(Ɀ_TimeTomorrow_(14, 12))},
} {
tm, err := NewTimeFromString(s.val)
require.Nil(t, err)
assert.Equal(t, s.exp, tm)
assert.Equal(t, false, tm.IsToday())
assert.Equal(t, false, tm.IsYesterday())
assert.Equal(t, true, tm.IsTomorrow())
}
}
func TestParseTime2400SpecialCase(t *testing.T) {
for _, s := range []struct {
val string
exp Time
}{
{"<24:00", Ɀ_Time_(0, 0)},
{"24:00", Ɀ_TimeTomorrow_(0, 0)},
} {
tm, err := NewTimeFromString(s.val)
require.Nil(t, err)
require.NotNil(t, tm)
assert.True(t, s.exp.IsEqualTo(tm), s.val)
}
}
func TestParseMalformedTimesFail(t *testing.T) {
for _, s := range []string{
"009:42", // Hours cannot have infinite leading 0s
"09:042", // Minutes cannot have infinite leading 0s
"<2:15>", // Shift-markers cannot appear on both sides
"asdf",
"12",
"12am", // Minutes missing
"13:3", // Minutes must have 2 digits
"-14:12", // Hours cannot be negative
"14:-12", // Minutes cannot be negative
"⠃⠚:⠙⠛", // Braille digits
"四:二八", // Japanese digits
"᠒᠐:᠑᠒", // Mongolean digits
} {
tm, err := NewTimeFromString(s)
require.Nil(t, tm, s)
assert.EqualError(t, err, "MALFORMED_TIME", s)
}
}
func TestParseUnrepresentableTimesFail(t *testing.T) {
for _, s := range []string{
"49:12", // Invalid hours
"25:12", // Invalid hours
"3:60", // Invalid minutes
"3:87", // Invalid minutes
"24:00>", // This would require shifting twice
"24:01", // The 24-hour special case can’t have minutes
"13:00am",
"13:00pm",
"0:00am", // There is no `0` hour when using am/pm
"0:00pm", // There is no `0` hour when using am/pm
} {
tm, err := NewTimeFromString(s)
require.Nil(t, tm, s)
assert.EqualError(t, err, "INVALID_TIME", s)
}
}
func TestCalculateMinutesSinceMidnight(t *testing.T) {
for _, s := range []struct {
in string
exp Duration
}{
{in: "0:00", exp: NewDuration(0, 0)},
{in: "0:01", exp: NewDuration(0, 1)},
{in: "14:59", exp: NewDuration(14, 59)},
{in: "23:59", exp: NewDuration(23, 59)},
{in: "<18:35", exp: NewDuration(-5, -25)},
{in: "5:35>", exp: NewDuration(24+5, 35)},
} {
tm, err := NewTimeFromString(s.in)
require.Nil(t, err)
assert.Equal(t, s.exp, tm.MidnightOffset())
}
}
func TestTimeComparison(t *testing.T) {
midnight := Ɀ_Time_(0, 0)
midnight2 := Ɀ_Time_(0, 0)
noon := Ɀ_Time_(12, 30)
noon2 := Ɀ_Time_(12, 31)
yesterday := Ɀ_TimeYesterday_(22, 43)
tomorrow := Ɀ_TimeTomorrow_(9, 50)
assert.True(t, midnight2.IsAfterOrEqual(midnight))
assert.True(t, noon.IsAfterOrEqual(noon))
assert.True(t, noon2.IsAfterOrEqual(noon))
assert.True(t, noon.IsAfterOrEqual(yesterday))
assert.True(t, tomorrow.IsAfterOrEqual(noon))
}
func TestAddDuration(t *testing.T) {
for _, x := range []struct {
initial Time
increment Duration
expect Time
}{
{Ɀ_Time_(11, 30), NewDuration(0, 00), Ɀ_Time_(11, 30)},
{Ɀ_Time_(11, 30), NewDuration(0, 30), Ɀ_Time_(12, 00)},
{Ɀ_Time_(18, 00), NewDuration(-6, 0), Ɀ_Time_(12, 00)},
{Ɀ_Time_(3, 59), NewDuration(8, 1), Ɀ_Time_(12, 00)},
{Ɀ_Time_(23, 59), NewDuration(0, 1), Ɀ_TimeTomorrow_(0, 00)},
{Ɀ_TimeYesterday_(23, 45), NewDuration(12, 15), Ɀ_Time_(12, 00)},
{Ɀ_TimeYesterday_(12, 12), NewDuration(1, 19), Ɀ_TimeYesterday_(13, 31)},
{Ɀ_TimeYesterday_(0, 1), NewDuration(0, -1), Ɀ_TimeYesterday_(0, 0)},
{Ɀ_TimeTomorrow_(4, 12), NewDuration(-16, -12), Ɀ_Time_(12, 00)},
{Ɀ_TimeTomorrow_(18, 38), NewDuration(-1, -1), Ɀ_TimeTomorrow_(17, 37)},
{Ɀ_TimeTomorrow_(23, 58), NewDuration(0, 1), Ɀ_TimeTomorrow_(23, 59)},
} {
result, err := x.initial.Plus(x.increment)
require.Nil(t, err)
assert.Equal(t, x.expect, result, x.initial)
}
}
func TestAddDurationPreservesFormat(t *testing.T) {
hour24, _ := Ɀ_Time_(11, 30).Plus(NewDuration(0, 1))
assert.Equal(t, hour24.Format().Use24HourClock, true)
hour12, _ := Ɀ_IsAmPm_(Ɀ_Time_(11, 30)).Plus(NewDuration(0, 1))
assert.Equal(t, hour12.Format().Use24HourClock, false)
}
func TestAddDurationImpossible(t *testing.T) {
for _, x := range []struct {
initial Time
increment Duration
}{
{Ɀ_Time_(11, 30), NewDuration(353, 0)},
{Ɀ_Time_(11, 30), NewDuration(-353, 0)},
{Ɀ_TimeYesterday_(0, 0), NewDuration(0, -1)},
{Ɀ_TimeTomorrow_(23, 59), NewDuration(0, 1)},
} {
result, err := x.initial.Plus(x.increment)
require.Nil(t, result)
assert.Error(t, err)
}
}
070701000000BD000081ED0000000000000000000000016863F92F000002B8000000000000000000000000000000000000001000000000klog-6.6/run.sh#!/bin/bash
# Install all dependencies
run::install() {
go get -t ./...
go mod tidy
}
# Compile to ./out/klog
# Takes two positional arguments:
# - The version (e.g.: v1.2)
# - The build hash (7 chars hex)
run::build() {
go build \
-ldflags "\
-X 'main.BinaryVersion=${1:-v?.?}' \
-X 'main.BinaryBuildHash=${2:-???????}' \
" \
-o ./out/klog \
klog.go
}
# Execute all tests
run::test() {
go test ./...
}
# Reformat all code
run::format() {
go fmt ./...
}
# Static code (style) analysis
run::lint() {
set -o errexit
go vet ./...
staticcheck ./...
}
# Run CLI from sources “on the fly”
# Passes through all input args
run::cli() {
go run klog.go "$@"
}
070701000000BE000081A40000000000000000000000016863F92F00000070000000000000000000000000000000000000001A00000000klog-6.6/staticcheck.confchecks = [
"inherit",
"-ST1001", # Allow dot imports
"-ST1005", # Allow capitalized error strings
]
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!1296 blocks