9 Commits

Author SHA1 Message Date
513735bb4c bump version 2026-08-15 00:10:43 +02:00
b72f8ea9d8 add expr-lang.org support (use with -e) 2026-08-15 00:10:01 +02:00
1bb24327e5 tidy 2025-11-02 10:24:36 +01:00
cc5317b64f fix link 2025-11-01 20:51:05 +01:00
300fc30bae separate uint test 2025-11-01 20:44:40 +01:00
9e8d7ddd0c fix ci test call 2025-11-01 20:38:26 +01:00
f853a9fd87 migrate to codeberg 2025-11-01 20:35:01 +01:00
T.v.Dein
d138af85f3 Doc improvements (#6)
* add default datetime format, fix default format usage
* fixed format tests
* make reference time configurable
* improve doc
2025-09-25 22:13:40 +02:00
T.v.Dein
ef4cc8b84b Added more unittests, fixed hour format output (#4)
* little refactoring
* added more tests, fixed hour format output
* bump version
2025-09-24 19:43:50 +02:00
22 changed files with 712 additions and 262 deletions

View File

@@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

View File

@@ -1,36 +0,0 @@
name: build-and-test
on: [push]
jobs:
build:
strategy:
matrix:
version: [1.23.5]
os: [ubuntu-latest, windows-latest, macos-latest]
name: Build
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go ${{ matrix.os }}
uses: actions/setup-go@v6
with:
go-version: '${{ matrix.version }}'
id: go
- name: checkout
uses: actions/checkout@v5
- name: build
run: go build
- name: test
run: make test
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.23
- uses: actions/checkout@v5
- name: golangci-lint
uses: golangci/golangci-lint-action@v8

View File

@@ -1,87 +0,0 @@
name: build-release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
name: Build Release Assets
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: 1.23.5
- name: Build the executables
run: ./mkrel.sh ts ${{ github.ref_name}}
- name: List the executables
run: ls -l ./releases
- name: Upload the binaries
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
file: ./releases/*
file_glob: true
- name: Build Changelog
id: github_release
uses: mikepenz/release-changelog-builder-action@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
mode: "PR"
configurationJson: |
{
"template": "#{{CHANGELOG}}\n\n**Full Changelog**: #{{RELEASE_DIFF}}",
"pr_template": "- #{{TITLE}} (##{{NUMBER}}) by #{{AUTHOR}}\n#{{BODY}}",
"empty_template": "- no changes",
"categories": [
{
"title": "## New Features",
"labels": ["add", "feature"]
},
{
"title": "## Bug Fixes",
"labels": ["fix", "bug", "revert"]
},
{
"title": "## Documentation Enhancements",
"labels": ["doc"]
},
{
"title": "## Refactoring Efforts",
"labels": ["refactor"]
},
{
"title": "## Miscellaneus Changes",
"labels": []
}
],
"ignore_labels": [
"duplicate", "good first issue", "help wanted", "invalid", "question", "wontfix"
],
"label_extractor": [
{
"pattern": "(.) (.+)",
"target": "$1"
},
{
"pattern": "(.) (.+)",
"target": "$1",
"on_property": "title"
}
]
}
- name: Create Release
uses: softprops/action-gh-release@v2
with:
body: ${{steps.github_release.outputs.changelog}}

69
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,69 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
gitea_urls:
api: https://codeberg.org/api/v1
download: https://codeberg.org
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- freebsd
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}_{{ .Tag }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
- goos: linux
formats: [tar.gz,binary]
files:
- src: "*.md"
strip_parent: true
- src: "docs/*"
strip_parent: true
- src: Makefile.dist
dst: Makefile
wrap_in_directory: true
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
groups:
- title: Improved
regexp: '^.*?(feat|add|new)(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Fixed
regexp: '^.*?(bug|fix)(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Changed
order: 999
release:
header: "# Release Notes"
footer: >-
---
Full Changelog: [{{ .PreviousTag }}...{{ .Tag }}](https://codeberg.org/scip/ts/compare/{{ .PreviousTag }}...{{ .Tag }})

35
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,35 @@
matrix:
platform:
- linux/amd64
goversion:
- 1.24
labels:
platform: ${platform}
steps:
build:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go build
- go test -cover ./...
test:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go test -cover ./...
linter:
when:
event: [push]
image: golang:${goversion}
commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0
- golangci-lint --version
- golangci-lint run ./...

15
.woodpecker/release.yaml Normal file
View File

@@ -0,0 +1,15 @@
# build release
labels:
platform: linux/amd64
steps:
goreleaser:
image: goreleaser/goreleaser
when:
event: [tag]
environment:
GITEA_TOKEN:
from_secret: DEPLOY_TOKEN
commands:
- goreleaser release --clean --verbose

18
Makefile.dist Normal file
View File

@@ -0,0 +1,18 @@
# -*-make-*-
.PHONY: install all
tool = ts
PREFIX = /usr/local
UID = root
GID = 0
all:
@echo "Type 'sudo make install' to install the tool."
@echo "To change prefix, type 'sudo make install PREFIX=/opt'"
install:
install -d -o $(UID) -g $(GID) $(PREFIX)/bin
install -d -o $(UID) -g $(GID) $(PREFIX)/share/doc
install -o $(UID) -g $(GID) -m 555 $(tool) $(PREFIX)/sbin/
install -o $(UID) -g $(GID) -m 444 *.md $(PREFIX)/share/doc/

159
README.md
View File

@@ -2,11 +2,137 @@
generic cli timestamp parser and calculator tool
## Usage
## Introduction
This little utility is a commandline frontent to the amazing datetime
parser module [anytime](https://github.com/ijt/go-anytime). It uses
two other modules as fallback if anytime might fail:
[now](https://github.com/jinzhu/now) and
[dateparse](github.com/araddon/dateparse).
You can use it to print timestamps from plain english phrases like
`next December 23rd AT 5:25 PM` or `two minutes from now`. In addition
you can calculate the difference between two timestamps and you can
add a duration to a timestamp.
## Example Usage
In these examples the current time is always **2025-09-17T07:30:00+01:00**.
Show current date and time (same as `date`):
```default
% ts now
Wed Sep 17 07:30:00 +0100 2025
```
show timestamp for minus 1 hour
```default
% ts "1 hour ago"
Wed Sep 17 06:30:00 +0100 2025
```
... or from a couple days ago:
```default
% ts "4 days ago"
Sat Sep 13 07:30:00 +0100 2025
```
There are much more ways to get timestamps, see `ts -e`.
We can also add times to timestamps, here we want to know the
timestamp from now plus 10 days and 4 hours in the future:
```default
% ts -a now 10d4h
Sat Sep 27 11:30:00 +0100 2025
```
It doesn't make a difference where you position the `-a` parameter:
```default
% ts now -a 10d4h
Sat Sep 27 11:30:00 +0100 2025
```
Of course you can also calculate the difference between two
dates. Here we have two timestamps (maybe we took them from a log
file) and want to know the dime elapsed between them:
```default
This is ts, a timestamp tool.
% ts 2025-09-17T07:30:00+01:00 2025-09-15T12:45:00+01:00
42h45m0s
```
As you can see, if you do not provide a parameter, the default is to
calculate the difference between the two args. To explicitly calculate
the difference, use the `-d` parameter.
You can of course use english phrases for time differences as well:
```default
% ts "today 9 am" 2025-09-15T12:45:00+01:00
44h15m0s
```
Lets talk a little bit about formatting. You may have already
recognized, that `ts` prints either whole timestamps or
durations. Both output types can be modified with the `-f`
parameter. There are predefined formats for timestamps:
```default
% ts now
Wed Sep 17 07:30:00 +0100 2025
% ts now -f rfc3339
2025-09-17T07:30:00+01:00
% ts now -f date
2025-09-17
% ts now -f unix
1758090600
```
But you can also specify your own, you have to follow the [golang
rules for timestamp formats](https://pkg.go.dev/time#Layout),
basically:
* Year: "2006" "06"
* Month: "Jan" "January" "01" "1"
* Day of the week: "Mon" "Monday"
* Day of the month: "2" "_2" "02"
* Day of the year: "__2" "002"
* Hour: "15" "3" "03" (PM or AM)
* Minute: "4" "04"
* Second: "5" "05"
* AM/PM mark: "PM"
for example:
```default
% ts now -f "Mon, 02.January 2006"
Wed, 17.September 2025
```
Ok I admit look is kinda weird, complaints go the the golang dev team
:).
Duration formatting is also customizable. By default a duration looks
like we have seen above: `44h15m0s`. But sometimes we want to know the
number of hours or minutes. Easy:
```default
% ts now 2025-09-15T12:45:00+01:00 -f hours
42.75
% ts now 2025-09-15T12:45:00+01:00 -f minutes
2565.00
```
You may also add the `-u` parameter to have the unit shown as well:
```default
% ts now 2025-09-15T12:45:00+01:00 -f hours -u
42.75 hours
% ts now 2025-09-15T12:45:00+01:00 -f minutes -u
2565.00 minutes
```
## Commandline parameters
Here is the list of all supported parameters:
```default
Usage: ts <time string> [<time string>]
-d --diff Calculate difference between two timestamps (default).
-a --add Add two timestamps (second parameter must be a time).
@@ -21,31 +147,6 @@ Usage: ts <time string> [<time string>]
-e --examples Show examples or supported inputs.
```
## Examples
```default
# diff between to day and yesterday 10 am
% date && ts today "10am yesterday"
Wed Sep 24 02:05:03 PM CEST 2025
14h0m0s
# show timestamp from a couple days ago
% date && ts "3 days ago"
Wed Sep 24 02:04:42 PM CEST 2025
2025-09-21 14:04:42.428910108 +0200 CEST
# show timestamp of one hour and 45 minutes before (-d is the defaul)
% date && ts -d now 1h45m
Wed Sep 24 02:04:14 PM CEST 2025
2025-09-24 12:19:14.932440045 +0200 CEST
# 10 hours from now
% date && ts --add now 10h
Wed Sep 24 02:03:31 PM CEST 2025
2025-09-25 00:03:31.304518854 +0200 CEST
```
To see a comprehensive list of supported inputs, call `ts -e`.
## Installation
@@ -54,7 +155,7 @@ your platform from the releases page and you're good to go.
### Installation using a pre-compiled binary
Go to the [latest release page](https://github.com/TLINDEN/ts/releases/latest)
Go to the [latest release page](https://codeberg.org/scip/ts/releases/)
and look for your OS and platform. There are two options to install the binary:
Directly download the binary for your platform,
@@ -95,7 +196,7 @@ install`.
# Report bugs
[Please open an issue](https://github.com/TLINDEN/ts/issues). Thanks!
[Please open an issue](https://codeberg.org/scip/ts/issues). Thanks!
# License

View File

@@ -23,6 +23,7 @@ import (
"io"
"log"
"os"
"time"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
@@ -31,7 +32,7 @@ import (
)
const (
VERSIONstring = "0.0.3"
VERSIONstring = "0.0.5"
Usage string = `This is ts, a timestamp tool.
Usage: ts <time string> [<time string>]
@@ -76,8 +77,11 @@ noon Yesterday at 10:15am Mon, 02 Jan 2006 15:
Example durations for second parameter:
2d1h30m 2 days, one and a half hour
30m 30 minutes`
ModeDiff int = iota
ModeAdd
DefaultFormat string = "Mon Jan 02 15:04:05 MST 2006"
)
type Config struct {
@@ -89,10 +93,14 @@ type Config struct {
Examples bool `koanf:"examples"`
Unit bool `koanf:"unit"`
Format string `koanf:"format"`
Expr string `koanf:"expr"`
Args []string
Output io.Writer
Mode int
TZ string // for unit tests
// internal flags for [unit] tests
tz string // has to be set directly in code
refTime time.Time // must be set via env var $TSREFTIME
}
func InitConfig(output io.Writer) (*Config, error) {
@@ -114,8 +122,9 @@ func InitConfig(output io.Writer) (*Config, error) {
flagset.BoolP("diff", "d", false, "diff two timestamps")
flagset.BoolP("add", "a", false, "add two timestamps")
flagset.BoolP("unit", "u", false, "add unit to diff outputs")
flagset.BoolP("examples", "e", false, "show examples of supported inputs")
flagset.BoolP("examples", "E", false, "show examples of supported inputs")
flagset.StringP("format", "f", "", "format to print timestamps or diffs")
flagset.StringP("expr", "e", "", "run expr program")
if err := flagset.Parse(os.Args[1:]); err != nil {
return nil, fmt.Errorf("failed to parse program arguments: %w", err)
@@ -127,11 +136,23 @@ func InitConfig(output io.Writer) (*Config, error) {
}
// fetch values
conf := &Config{Output: output}
conf := &Config{Output: output, refTime: time.Now()}
if err := kloader.Unmarshal("", &conf); err != nil {
return nil, fmt.Errorf("error unmarshalling: %w", err)
}
// check internal env var[s], if any
reftime, present := os.LookupEnv("TSREFTIME")
if present {
// e.g: 2014-01-03T00:00:00+01:00
ts, err := time.Parse(time.RFC3339Nano, reftime)
if err != nil {
os.Exit(Die("failed to set reference time from $TSREFTIME", err))
}
conf.refTime = ts
}
// want examples?
if conf.Examples {
_, err := fmt.Fprintln(output, Examples)
@@ -142,6 +163,11 @@ func InitConfig(output io.Writer) (*Config, error) {
os.Exit(0)
}
if conf.Expr != "" {
// ignore args etc
return conf, nil
}
// args are timestamps
if len(flagset.Args()) == 0 {
return nil, errors.New("no timestamp argument[s] specified.\n" + Usage)

View File

@@ -21,6 +21,8 @@ import (
"fmt"
"io"
"os"
"codeberg.org/scip/ts/pkg/runexpr"
)
func Die(format string, err error) int {
@@ -36,6 +38,12 @@ func Main(output io.Writer) int {
return Die("failed to initialize", err)
}
if conf.Expr != "" {
runexpr.RunExpr(conf.Expr)
return 0
}
tp := NewTP(conf)
if err := tp.ProcessTimestamps(); err != nil {

View File

@@ -45,9 +45,10 @@ func NewTP(conf *Config, ref ...time.Time) *TimestampProccessor {
modnow.TimeFormats = append(modnow.TimeFormats, formats...)
tp := &TimestampProccessor{Config: *conf, Reference: time.Now()}
tp := &TimestampProccessor{Config: *conf, Reference: conf.refTime}
if len(ref) == 1 {
// overwritten externally by unit test
tp.Reference = ref[0]
}
@@ -73,9 +74,9 @@ func (tp *TimestampProccessor) Parse(timestamp string) (time.Time, error) {
return ts, err
}
if tp.TZ != "" {
if tp.tz != "" {
// apply custom timezone
zone, _ := time.LoadLocation(tp.TZ)
zone, _ := time.LoadLocation(tp.tz)
ts = ts.In(zone)
}
@@ -105,7 +106,8 @@ func (tp *TimestampProccessor) SingleTimestamp(timestamp string) error {
return err
}
tp.Print(ts)
tp.Print(TPdatetime{TimestampProccessor: *tp, Data: ts})
//tp.Print(ts)
return nil
}

View File

@@ -73,7 +73,7 @@ func TestParseTimestamps(t *testing.T) {
testname := fmt.Sprintf("parsetimestamp-%s", strings.ReplaceAll(tt.input, " ", "-"))
t.Run(testname, func(t *testing.T) {
var writer bytes.Buffer
tp := NewTP(&Config{Args: []string{tt.input}, Output: &writer, TZ: "UTC"}, now)
tp := NewTP(&Config{Args: []string{tt.input}, Output: &writer, tz: "UTC"}, now)
// writer.String()
ts, err := tp.Parse(tt.input)
@@ -82,7 +82,7 @@ func TestParseTimestamps(t *testing.T) {
err = tp.ProcessTimestamps()
assert.NoError(t, err)
assert.EqualValues(t, tt.want.String()+"\n", writer.String())
assert.EqualValues(t, tt.want.Format(DefaultFormat)+"\n", writer.String())
})
}
}
@@ -148,7 +148,7 @@ func TestAddTimestamps(t *testing.T) {
err := tp.ProcessTimestamps()
assert.NoError(t, err)
assert.EqualValues(t, tt.want.String()+"\n", writer.String())
assert.EqualValues(t, tt.want.Format(DefaultFormat)+"\n", writer.String())
})
}
}

View File

@@ -87,7 +87,7 @@ func (datetime TPdatetime) String() string {
case "datetime":
fallthrough
case "":
return datetime.Data.String()
return datetime.Data.Format(DefaultFormat)
default:
return datetime.Data.Format(datetime.Format)
}

View File

@@ -65,7 +65,7 @@ func TestDatetime(t *testing.T) {
{"date", now, "2025-09-25"},
{"time", now, "12:30:00"},
{"unix", now, "1758803400"},
{"datetime", now, "2025-09-25 12:30:00 +0000 UTC"},
{"datetime", now, "Thu Sep 25 12:30:00 UTC 2025"},
}
for _, tt := range tests {

30
go.mod
View File

@@ -1,26 +1,30 @@
module github.com/tlinden/ts
module codeberg.org/scip/ts
go 1.23.0
go 1.26.0
toolchain go1.23.5
toolchain go1.26.4
require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/ijt/go-anytime v1.9.2
github.com/jinzhu/now v1.1.5
github.com/knadh/koanf/providers/posflag v1.0.2
github.com/knadh/koanf/v2 v2.3.6
github.com/rogpeppe/go-internal v1.14.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
)
require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/expr-lang/expr v1.17.8 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/ijt/go-anytime v1.9.2 // indirect
github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect
github.com/itlightning/dateparse v0.2.1 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/providers/posflag v1.0.1 // indirect
github.com/knadh/koanf/v2 v2.3.0 // indirect
github.com/knadh/koanf/maps v0.1.3 // indirect
github.com/lestrrat-go/strftime v1.2.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/tools v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

15
go.sum
View File

@@ -3,22 +3,30 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoU
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/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/ijt/go-anytime v1.9.2 h1:DmYgVwUiFPNR+n6c1T5P070tlGATRZG4aYNJs6XDUfU=
github.com/ijt/go-anytime v1.9.2/go.mod h1:egBT6FhVjNlXNHUN2wTPi6ILCNKXeeXFy04pWJjw/LI=
github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d h1:LFOmpWrSbtolg0YqYC9hQjj5WSLtRGb6aZ3JAugLfgg=
github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d/go.mod h1:112TOyA+aruNSUBlyBWlKBdLVYTdhjiO2CKD0j/URSU=
github.com/itlightning/dateparse v0.2.1 h1:AB0NJTyI0HYcerEUMovKZOiQVBg1mBPxgAnWQwzLP6g=
github.com/itlightning/dateparse v0.2.1/go.mod h1:xHlmL8lT0L9JIBlaKotRwsoDYpKJskXpiU9ZwbbSkNA=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4=
github.com/knadh/koanf/maps v0.1.3/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
github.com/knadh/koanf/providers/posflag v1.0.2 h1:ky9Yqmoz0EHGfby6/gB6SUXmLs5kjxW/1ekbHRuPwIk=
github.com/knadh/koanf/providers/posflag v1.0.2/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/lestrrat-go/strftime v1.2.0 h1:8fAUYOeaJKCuLzNvUWBAo8t6I6hkFfodDTndEzJIun0=
github.com/lestrrat-go/strftime v1.2.0/go.mod h1:GtsIA/7ddIGJjEdfadUafEb1sbutvlvpMdPCMglykYo=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
@@ -36,10 +44,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI=
github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -20,7 +20,7 @@ package main
import (
"os"
"github.com/tlinden/ts/cmd"
"codeberg.org/scip/ts/cmd"
)
func main() {

View File

@@ -1,70 +0,0 @@
#!/bin/bash
# Copyright © 2024 Thomas von Dein
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# get list with: go tool dist list
DIST="darwin/amd64
freebsd/amd64
linux/amd64
windows/amd64
freebsd/arm64
linux/arm64"
tool="$1"
version="$2"
if test -z "$version"; then
echo "Usage: $0 <tool name> <release version>"
exit 1
fi
rm -rf releases
mkdir -p releases
for D in $DIST; do
os=${D/\/*/}
arch=${D/*\//}
binfile="releases/${tool}-${os}-${arch}-${version}"
if test "$os" = "windows"; then
binfile="${binfile}.exe"
fi
tardir="${tool}-${os}-${arch}-${version}"
tarfile="releases/${tool}-${os}-${arch}-${version}.tar.gz"
set -x
GOOS=${os} GOARCH=${arch} go build -tags osusergo,netgo -ldflags "-extldflags=-static" -o ${binfile}
mkdir -p ${tardir}
cp ${binfile} README.md LICENSE ${tardir}/
echo 'tool = ts
PREFIX = /usr/local
UID = root
GID = 0
install:
install -d -o $(UID) -g $(GID) $(PREFIX)/bin
install -d -o $(UID) -g $(GID) $(PREFIX)/man/man1
install -o $(UID) -g $(GID) -m 555 $(tool) $(PREFIX)/sbin/
install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/' > ${tardir}/Makefile
tar cpzf ${tarfile} ${tardir}
sha256sum ${binfile} | cut -d' ' -f1 > ${binfile}.sha256
sha256sum ${tarfile} | cut -d' ' -f1 > ${tarfile}.sha256
rm -rf ${tardir}
set +x
done

142
pkg/runexpr/duration.go Normal file
View File

@@ -0,0 +1,142 @@
package runexpr
import (
"fmt"
"log"
"math"
"strconv"
"time"
)
const maxDuration time.Duration = 1<<63 - 1
// humanDuration represents a ES duration with day support
type humanDuration struct {
days, hours, minutes, seconds int
}
func parseDuration(duration string) time.Duration {
var seconds int64
for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) {
if len(match) == 3 {
durationvalue, err := strconv.ParseInt(match[1], 10, 64)
if err != nil {
log.Fatal(err)
}
switch match[2][0] {
case 'y':
seconds += durationvalue * 31536000
case 'd':
seconds += durationvalue * 86400
case 'h':
seconds += durationvalue * 3600
case 'm':
seconds += durationvalue * 60
case 's':
seconds += durationvalue
}
}
}
if seconds >= int64(maxDuration.Seconds()) {
log.Fatal("duration overflow, choose a smaller duration")
}
dur := time.Duration(seconds) * time.Second
return dur
}
// extractDuration parses a ES duration and returns an esDuration
func extractDuration(duration string) humanDuration {
esd := humanDuration{}
for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) {
if len(match) == 3 {
durationvalue, _ := strconv.Atoi(match[1])
switch match[2][0] {
case 'd':
esd.days = durationvalue
case 'h':
esd.hours = durationvalue
case 'm':
esd.minutes = durationvalue
case 's':
esd.seconds = durationvalue
}
}
}
return esd
}
// formatDays returns days string, unless days is larger than 365,
// then it returns years and days, e.g. 3y/34d
func formatDays(days float64) string {
if days > 365 {
years := days / 365
days := math.Remainder(days, 365)
return fmt.Sprintf("%dy/%dd", int64(years), int64(days))
}
return fmt.Sprintf("%dd", int64(days))
}
// formatDuration converts a time.Duration to a prettier human
// readable form, like 156h6m15.095s-6d/12h/6m => 6d/12h/6m
func formatDuration(val time.Duration) string {
esd := extractDuration(val.String())
daysF := float64(esd.hours) / 24.0
days := math.Floor(daysF)
hoursLeft := (daysF - days) * 24
switch {
case val.Minutes() < 1:
return fmt.Sprintf("%ds", int64(esd.seconds))
case val.Hours() < 1:
return fmt.Sprintf("%dm", int64(esd.minutes))
case val.Hours() < 24:
if esd.minutes == 0 {
return fmt.Sprintf("%dh", esd.hours)
}
return fmt.Sprintf("%dh/%dm", esd.hours, int64(esd.minutes))
default:
if esd.minutes == 0 && hoursLeft == 0 {
return formatDays(days)
}
return fmt.Sprintf("%s/%dh/%dm",
formatDays(days),
int64(hoursLeft),
esd.minutes,
)
}
}
func toYears(d time.Duration) float64 {
return d.Hours() / 24 / 365
}
func toDays(d time.Duration) float64 {
return d.Hours() / 24
}
func toHours(d time.Duration) float64 {
return d.Hours()
}
func toMinutes(d time.Duration) float64 {
return d.Minutes()
}
func toSeconds(d time.Duration) float64 {
return d.Seconds()
}

63
pkg/runexpr/run.go Normal file
View File

@@ -0,0 +1,63 @@
package runexpr
import (
"fmt"
"time"
"github.com/expr-lang/expr"
)
type Env struct {
Now func() time.Time
Dur, D func(string) time.Duration
Strf func(any, ...any) string
Diff func(a, b time.Time) time.Duration
Add func(time.Time, time.Duration) time.Time
Time, T func(string, ...string) time.Time
Human func(time.Duration) string
Years, Days, Hours, Minutes, Seconds func(time.Duration) float64
Clock func(time.Time) string
Unix func(time.Time) int64
TZ func(time.Time, any) time.Time
}
func RunExpr(code string) {
env := Env{
Now: time.Now,
Dur: parseDuration,
D: parseDuration,
Strf: strfTime,
Diff: timeDiff,
Add: timeAdd,
Time: newTime,
T: newTime,
Human: formatDuration,
Years: toYears,
Days: toDays,
Hours: toHours,
Minutes: toMinutes,
Seconds: toSeconds,
Clock: toClock,
Unix: toUnix,
TZ: toTZ,
}
options := []expr.Option{
expr.Env(Env{}),
expr.Operator("-", "Diff"), // custom operator to diff two time.Time's
expr.Operator("+", "Add"), // custom operator to add time.Duration to a time.Time
}
program, err := expr.Compile(code, options...)
if err != nil {
fmt.Printf("%v", err)
return
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
fmt.Println(output)
}

159
pkg/runexpr/time.go Normal file
View File

@@ -0,0 +1,159 @@
package runexpr
import (
"fmt"
"log"
"reflect"
"regexp"
"time"
"github.com/lestrrat-go/strftime"
)
var (
// matchDuration is a regex which matches ES durations, which
// supports days (in contrast to time.Duration which does not)
matchDuration = regexp.MustCompile(`(\d+)([ydhms])`)
timeFormats = []string{
time.RFC3339,
time.RFC1123Z,
time.RFC1123,
time.RFC850,
time.RFC822Z,
time.RFC822,
time.RubyDate,
time.UnixDate,
time.DateTime,
time.DateOnly,
time.TimeOnly,
"02.01.2006 15:04:05", // german
"02/01/2006 15:04:05",
"20060102",
"02.01.2006", // german
"20060102150405",
"15:04:05",
"15:04",
}
)
// setDefault# either returns the first element if item if set or
// replace. Types must match expect.
func setDefault(item []any, replace any, expect reflect.Type) any {
if reflect.TypeOf(replace) != expect {
log.Fatal("setDefault(): invalid types called")
}
if len(item) == 1 {
return item[0]
} else {
return replace
}
}
// strfTime formats a time.Time. Args are any,any to be able to use it in a pipe.
//
// eg:
//
// now | strftime("%D")
// strftime(now, "%D")
// "%d.%m" | strftime(now)
func strfTime(fst any, snd ...any) string {
var ts time.Time
var format string
switch firstVal := fst.(type) {
case string:
format = firstVal
ts = setDefault(snd, time.Now(), reflect.TypeFor[time.Time]()).(time.Time)
case time.Time:
ts = firstVal
format = setDefault(snd, "%D", reflect.TypeFor[string]()).(string)
}
if format == "" || ts.IsZero() {
log.Fatal("strftime(): invalid args")
}
formatted, err := strftime.Format(format, ts)
if err != nil {
log.Fatal(err)
}
return formatted
}
// timeDiff returns the time.Duration diff between time.Time a and
// b. It syncs timezones first and exchanges a+b if a>b
func timeDiff(a, b time.Time) time.Duration {
if a.Location() != b.Location() {
b = b.In(a.Location())
}
if a.After(b) {
a, b = b, a
}
return b.Sub(a)
}
// newTime returns a new tim.Time object if ts matches one of the
// predefined formats. If format is set, use this.
func newTime(ts string, format ...string) time.Time {
if len(format) > 0 {
t, err := time.Parse(format[0], ts)
if err == nil {
return t
}
}
for _, format := range timeFormats {
t, err := time.Parse(format, ts)
if err == nil {
return t
}
}
log.Fatal("new(): failed to parse timestamp")
return time.Time{}
}
// toClock returns the HH:MM:SS time part of a time.Time
func toClock(t time.Time) string {
h, m, s := t.Clock()
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
// timeAdd adds d to t
func timeAdd(t time.Time, d time.Duration) time.Time {
return t.Add(d)
}
// toUnix returns the epoch of t
func toUnix(t time.Time) int64 {
return t.Unix()
}
// toTZ converts t to given time zone
func toTZ(t time.Time, tz any) time.Time {
var z *time.Location
var err error
switch val := tz.(type) {
case string:
z, err = time.LoadLocation(val)
if err != nil {
log.Fatal(err)
}
case int:
f := fmt.Sprintf("UTC+%d", val)
if val < 0 {
f = fmt.Sprintf("UTC%d", val)
}
z = time.FixedZone(f, val*60*60)
}
return t.In(z)
}

View File

@@ -5,6 +5,6 @@ exec ts -e
stdout 'yesterday'
exec ts 3/1/2014
stdout 2014-01-03
stdout 'Fri Jan 03'