11 Commits
v0.0.1 ... expr

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
71e36c36d3 enhanced examples 2025-09-24 14:05:41 +02:00
T.v.Dein
95051af097 Use interfaces, add unit tests, add duration diff support (#3)
* use interface for Duration and Time printing, fix diff ts parsing
* add unit tests and gpl
* added tests, add support for duration diff+add
* bump version
* catch setenv errors
* fix ci job name
2025-09-24 14:01:39 +02:00
25 changed files with 1329 additions and 339 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-gfn
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}}

2
.gitignore vendored
View File

@@ -30,3 +30,5 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
ts

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

View File

@@ -16,7 +16,7 @@
#
# no need to modify anything below
tool = gfn
tool = ts
VERSION = $(shell grep VERSION config.go | head -1 | cut -d '"' -f2)
archs = darwin freebsd linux windows
PREFIX = /usr/local
@@ -37,11 +37,10 @@ install: buildlocal
install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/
clean:
rm -rf $(tool) coverage.out testdata t/out
rm -rf $(tool) coverage.* testdata t/out
test: clean
mkdir -p t/out
go test ./... $(ARGS)
go test -cover ./... $(ARGS)
testlint: test lint
@@ -60,7 +59,8 @@ singletest:
cover-report:
go test ./... -cover -coverprofile=coverage.out
go tool cover -html=coverage.out
go tool cover -html=coverage.out -o coverage.html
chromium coverage.html
goupdate:
go get -t -u=patch ./...
@@ -72,8 +72,8 @@ release:
gh release create v$(VERSION) --generate-notes
show-versions: buildlocal
@echo "### gfn version:"
@./gfn -V
@echo "### ts version:"
@./ts -V
@echo
@echo "### go module versions:"

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/

135
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,6 +147,7 @@ Usage: ts <time string> [<time string>]
-e --examples Show examples or supported inputs.
```
## Installation
The tool does not have any dependencies. Just download the binary for
@@ -28,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,
@@ -69,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

@@ -1,3 +1,20 @@
/*
Copyright © 2025 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/>.
*/
package cmd
import (
@@ -6,6 +23,7 @@ import (
"io"
"log"
"os"
"time"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
@@ -14,12 +32,12 @@ import (
)
const (
VERSIONstring = "0.0.1"
VERSIONstring = "0.0.5"
Usage string = `This is ts, a timestamp tool.
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)
-a --add Add two timestamps (second parameter must be a duration)
-f --format For diffs: duration, hour, min, sec, msec
For timestamps: datetime, rfc3339, date, time, unix, string
string is a strftime(1) format string. datetime is
@@ -54,9 +72,16 @@ noon Yesterday at 10:15am Mon, 02 Jan 2006 15:
1:05pm Next dec 22nd at 3pm 3 days ago 1999AD
10:25:10am Next December 25th at 7:30am UTC-7 Three days ago 1999 AD
1:05:10pm Next December 23rd AT 5:25 PM 1 day from now 2008CE
10:25 Last December 23rd AT 5:25 PM 1 week ago 2008 CE`
10:25 Last December 23rd AT 5:25 PM 1 week ago 2008 CE
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 {
@@ -68,9 +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
// 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) {
@@ -92,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)
@@ -105,11 +136,38 @@ 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)
if err != nil {
Die("failed write to output file handle", err)
}
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

@@ -1,14 +1,32 @@
/*
Copyright © 2025 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/>.
*/
package cmd
import (
"fmt"
"io"
"log"
"os"
"codeberg.org/scip/ts/pkg/runexpr"
)
func Die(err error) int {
log.Fatal("Error: ", err.Error())
func Die(format string, err error) int {
fmt.Fprintf(os.Stderr, format+": %s\n", err)
return 1
}
@@ -16,21 +34,20 @@ func Die(err error) int {
func Main(output io.Writer) int {
conf, err := InitConfig(output)
if err != nil {
return Die(err)
fmt.Println(1)
return Die("failed to initialize", err)
}
if conf.Examples {
_, err := fmt.Fprintln(output, Examples)
if err != nil {
Die(err)
}
os.Exit(0)
if conf.Expr != "" {
runexpr.RunExpr(conf.Expr)
return 0
}
tp := NewTP(conf)
if err := tp.ProcessTimestamps(); err != nil {
return Die(err)
return Die("failed to process timestamp[s]", err)
}
return 0

View File

@@ -1,31 +1,58 @@
/*
Copyright © 2025 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/>.
*/
package cmd
import (
"errors"
"fmt"
"log"
"regexp"
"strconv"
"time"
"github.com/araddon/dateparse"
"github.com/ijt/go-anytime"
"github.com/itlightning/dateparse"
"github.com/jinzhu/now"
modnow "github.com/jinzhu/now"
)
type TimestampProccessor struct {
Config
Reference time.Time
}
func NewTP(conf *Config) *TimestampProccessor {
func NewTP(conf *Config, ref ...time.Time) *TimestampProccessor {
// we add some pre-defined formats to modnow
formats := []string{
time.UnixDate, time.RubyDate,
time.RFC1123, time.RFC1123Z, time.RFC3339, time.RFC3339Nano,
time.RFC822, time.RFC822Z, time.RFC850,
"Mon Jan 02 15:04:05 PM MST 2006", // linux date
"Mo. 02 Jan. 2006 15:04:05 MST", // freebsd date (fails, see golang/go/issues/75576)
}
now.TimeFormats = append(now.TimeFormats, formats...)
modnow.TimeFormats = append(modnow.TimeFormats, formats...)
return &TimestampProccessor{Config: *conf}
tp := &TimestampProccessor{Config: *conf, Reference: conf.refTime}
if len(ref) == 1 {
// overwritten externally by unit test
tp.Reference = ref[0]
}
return tp
}
func (tp *TimestampProccessor) ProcessTimestamps() error {
@@ -33,141 +60,161 @@ func (tp *TimestampProccessor) ProcessTimestamps() error {
case 1:
return tp.SingleTimestamp(tp.Args[0])
case 2:
return tp.Calc(tp.Args[0], tp.Args[1])
return tp.DualTimestamps(tp.Args[0], tp.Args[1])
}
return nil
}
// a post processor for ParseTimestamp() to apply custom time zone, if any
func (tp *TimestampProccessor) Parse(timestamp string) (time.Time, error) {
ts, err := tp.ParseTimestamp(timestamp)
if err != nil {
return ts, err
}
if tp.tz != "" {
// apply custom timezone
zone, _ := time.LoadLocation(tp.tz)
ts = ts.In(zone)
}
return ts, nil
}
// Parse uses 3 different timestamp parser modules to provide maximum flexibility
func (tp *TimestampProccessor) ParseTimestamp(timestamp string) (time.Time, error) {
ts, err := anytime.Parse(timestamp, tp.Reference)
if err == nil {
return ts, nil
}
// anytime failed, try module modnow
ts, err = modnow.Parse(timestamp)
if err == nil {
return ts, nil
}
// modnow failed, try module dateparse
return dateparse.ParseAny(timestamp)
}
func (tp *TimestampProccessor) SingleTimestamp(timestamp string) error {
ts, err := tp.Parse(timestamp)
if err != nil {
return err
}
tp.Print(ts)
tp.Print(TPdatetime{TimestampProccessor: *tp, Data: ts})
//tp.Print(ts)
return nil
}
// Parse uses 3 different timestamp parser modules to provide the maximum flexibility
func (tp *TimestampProccessor) Parse(timestamp string) (time.Time, error) {
reference := time.Now()
ts, err := anytime.Parse(timestamp, reference)
if err == nil {
return ts, nil
func (tp *TimestampProccessor) DualTimestamps(timestampA, timestampB string) error {
tsA, err := tp.Parse(timestampA)
if err != nil {
return err
}
// anytime failed, try module now
ts, err = now.Parse(timestamp)
durB, err := duration2int(timestampB)
if err == nil {
return ts, nil
// calculate with a duration
tp.CalcDuration(tsA, durB)
return nil
}
// now failed, try module dateparse
return dateparse.ParseAny(timestamp)
tsB, err := tp.Parse(timestampB)
if err != nil {
return err
}
tp.CalcDiff(tsA, tsB)
return nil
}
func (tp *TimestampProccessor) Calc(timestampA, timestampB string) error {
now := time.Now()
tsA, err := anytime.Parse(timestampA, now)
if err != nil {
return err
}
tsB, err := anytime.Parse(timestampB, now)
if err != nil {
return err
}
func (tp *TimestampProccessor) CalcDiff(tsA time.Time, tsB time.Time) {
switch tp.Mode {
case ModeDiff:
var diff time.Duration
// avoid negative results
if tsA.Unix() > tsB.Unix() {
diff = tsA.Sub(tsB)
} else {
diff = tsB.Sub(tsA)
}
tp.Print(diff)
tp.Print(TPduration{TimestampProccessor: *tp, Data: diff})
case ModeAdd:
seconds := (tsB.Hour() * 3600) + (tsB.Minute() * 60) + tsB.Second()
tp.Print(tsA.Add(time.Duration(seconds) * time.Second))
}
sum := tsA.Add(time.Duration(seconds) * time.Second)
return nil
tp.Print(TPdatetime{TimestampProccessor: *tp, Data: sum})
}
}
func (tp *TimestampProccessor) Print(msg any) {
var repr string
func (tp *TimestampProccessor) CalcDuration(tsA time.Time, durB time.Duration) {
var datetime time.Time
switch msg := msg.(type) {
case string:
repr = msg
case time.Time:
repr = tp.StringTime(msg)
case time.Duration:
repr = tp.StringDuration(msg)
switch tp.Mode {
case ModeDiff:
datetime = tsA.Add(-durB)
case ModeAdd:
datetime = tsA.Add(durB)
}
_, err := fmt.Fprintln(tp.Output, repr)
tp.Print(TPdatetime{TimestampProccessor: *tp, Data: datetime})
}
func (tp *TimestampProccessor) Print(ts TimestampWriter) {
_, err := fmt.Fprintln(tp.Output, ts.String())
if err != nil {
log.Fatalf("failed to print to given output handle: %s", err)
Die("failed to print to given output handle", err)
}
}
func (tp *TimestampProccessor) StringDuration(msg time.Duration) string {
var unit string
/*
We could use time.ParseDuration(), but this doesn't support days.
if tp.Unit {
switch tp.Format {
case "d", "day", "days":
unit = " days"
case "h", "hour", "hours":
unit = " hours"
case "m", "min", "mins", "minutes":
unit = " minutes"
case "s", "sec", "secs", "seconds":
unit = " seconds"
case "ms", "msec", "msecs", "milliseconds":
unit = " milliseconds"
We could also use github.com/xhit/go-str2duration/v2, which does
the job, but it's just another dependency, just for this little
gem. And we don't need a time.Time value.
Convert a duration into seconds (int).
Valid time units are "s", "m", "h" and "d".
Valid inputs: 2h5m (2 hours and 5 min), 10d12h (10 and a half days)
*/
func duration2int(duration string) (time.Duration, error) {
re := regexp.MustCompile(`(\d+)([dhms])`)
seconds := 0
found := false
for _, match := range re.FindAllStringSubmatch(duration, -1) {
if len(match) == 3 {
found = true
v, _ := strconv.Atoi(match[1])
switch match[2][0] {
case 'd':
seconds += v * 86400
case 'h':
seconds += v * 3600
case 'm':
seconds += v * 60
case 's':
seconds += v
}
}
}
// duration, days, hour, min, sec, msec
switch tp.Format {
case "d", "day", "days":
return fmt.Sprintf("%.02f%s", msg.Hours()/24+(msg.Minutes()/60), unit)
case "h", "hour", "hours":
return fmt.Sprintf("%.02f%s", msg.Hours(), unit)
case "m", "min", "mins", "minutes":
return fmt.Sprintf("%.02f%s", msg.Minutes(), unit)
case "s", "sec", "secs", "seconds":
return fmt.Sprintf("%.02f%s", msg.Seconds(), unit)
case "ms", "msec", "msecs", "milliseconds":
return fmt.Sprintf("%d%s", msg.Milliseconds(), unit)
case "dur", "duration":
fallthrough
default:
return msg.String()
}
}
func (tp *TimestampProccessor) StringTime(msg time.Time) string {
// datetime(default), date, time, unix, string
switch tp.Format {
case "rfc3339":
return msg.Format(time.RFC3339)
case "date":
return msg.Format("2006-01-02")
case "time":
return msg.Format("03:04:05")
case "unix":
return fmt.Sprintf("%d", msg.Unix())
case "datetime":
fallthrough
case "":
return msg.String()
default:
return msg.Format(tp.Format)
if !found {
return 0, errors.New("failed to parse duration")
}
return time.Duration(seconds) * time.Second, nil
}

154
cmd/times_test.go Normal file
View File

@@ -0,0 +1,154 @@
/*
Copyright © 2025 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/>.
*/
package cmd
import (
"bytes"
"fmt"
"log"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// reference time for our tests
var now = time.Date(2025, 9, 25, 12, 30, 00, 0, time.UTC)
// return fixed date at given params
func dateAtTime(dateFrom time.Time, hour int, min int, sec int) time.Time {
t := dateFrom
return time.Date(t.Year(), t.Month(), t.Day(), hour, min, sec, 0, t.Location())
}
func setUTC() {
// make sure to have a consistent environment
if err := os.Setenv("TZ", "UTC"); err != nil {
log.Fatal(err)
}
loc, _ := time.LoadLocation("UTC")
time.Local = loc
}
func TestParseTimestamps(t *testing.T) {
setUTC()
var datetimes = []struct {
input string
want time.Time
}{
// some timestamps from ijt/go-anytime/anytime_test.go
{`a minute from now`, now.Add(time.Minute)},
{`5 minutes ago`, now.Add(-5 * time.Minute)},
{`an hour from now`, now.Add(time.Hour)},
{`Yesterday 10am`, dateAtTime(now.AddDate(0, 0, -1), 10, 0, 0)},
{`Mon Jan 2 15:04:05 2006`, time.Date(2006, 1, 2, 15, 4, 5, 0, now.Location())},
{`1 day from now`, now.Add(24 * time.Hour)},
{`One year ago`, now.AddDate(-1, 0, 0)},
{`03:15`, dateAtTime(now, 3, 15, 0)},
{`Wed Sep 25 12:30:00 PM UTC 2025`, now},
{`Wed Sep 25 00:30:00 PM CEST 2025`, now},
{`Wed Sep 25 2025 13:30:00 GMT+0100 (GMT Daylight Time)`, now},
}
for _, tt := range datetimes {
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)
// writer.String()
ts, err := tp.Parse(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.want, ts)
err = tp.ProcessTimestamps()
assert.NoError(t, err)
assert.EqualValues(t, tt.want.Format(DefaultFormat)+"\n", writer.String())
})
}
}
func preDiff(tsA, tsB time.Time) time.Duration {
var diff time.Duration
// avoid negative results
if tsA.Unix() > tsB.Unix() {
diff = tsA.Sub(tsB)
} else {
diff = tsB.Sub(tsA)
}
return diff
}
func TestDiffTimestamps(t *testing.T) {
setUTC()
var datetimes = []struct {
A string
B string
want time.Duration
}{
{`now`, `11:30`, preDiff(now, dateAtTime(now, 11, 30, 00))},
{`11:30`, `now`, preDiff(now, dateAtTime(now, 11, 30, 00))},
}
for _, tt := range datetimes {
testname := fmt.Sprintf("diff-%s-%s", strings.ReplaceAll(tt.A, " ", "-"), strings.ReplaceAll(tt.B, " ", "-"))
t.Run(testname, func(t *testing.T) {
var writer bytes.Buffer
tp := NewTP(&Config{Args: []string{tt.A, tt.B}, Output: &writer, Mode: ModeDiff}, now)
err := tp.ProcessTimestamps()
assert.NoError(t, err)
assert.EqualValues(t, tt.want.String()+"\n", writer.String())
})
}
}
func TestAddTimestamps(t *testing.T) {
setUTC()
var datetimes = []struct {
A string
B string
want time.Time
}{
{`now`, `01:30`, dateAtTime(now, 14, 00, 00)},
{`now`, `2h`, dateAtTime(now, 14, 30, 00)},
{`now`, `12d4h`, dateAtTime(now.Add(time.Hour*24*12), 16, 30, 00)},
{`now`, `45m`, dateAtTime(now, 13, 15, 00)},
{`now`, `1d10s`, dateAtTime(now.Add(time.Hour*24*1), 12, 30, 10)},
}
for _, tt := range datetimes {
testname := fmt.Sprintf("diff-%s-%s", strings.ReplaceAll(tt.A, " ", "-"), strings.ReplaceAll(tt.B, " ", "-"))
t.Run(testname, func(t *testing.T) {
var writer bytes.Buffer
tp := NewTP(&Config{Args: []string{tt.A, tt.B}, Output: &writer, Mode: ModeAdd}, now)
err := tp.ProcessTimestamps()
assert.NoError(t, err)
assert.EqualValues(t, tt.want.Format(DefaultFormat)+"\n", writer.String())
})
}
}

94
cmd/writer.go Normal file
View File

@@ -0,0 +1,94 @@
/*
Copyright © 2025 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/>.
*/
package cmd
import (
"fmt"
"time"
)
type TimestampWriter interface {
String() string
}
type TPduration struct {
TimestampProccessor
Data time.Duration
}
type TPdatetime struct {
TimestampProccessor
Data time.Time
}
func (duration TPduration) String() string {
var unit string
if duration.Unit {
switch duration.Format {
case "d", "day", "days":
unit = " days"
case "h", "hour", "hours":
unit = " hours"
case "m", "min", "mins", "minutes":
unit = " minutes"
case "s", "sec", "secs", "seconds":
unit = " seconds"
case "ms", "msec", "msecs", "milliseconds":
unit = " milliseconds"
}
}
// duration, days, hour, min, sec, msec
switch duration.Format {
case "d", "day", "days":
return fmt.Sprintf("%.02f%s", duration.Data.Hours()/24, unit)
case "h", "hour", "hours":
return fmt.Sprintf("%.02f%s", duration.Data.Hours(), unit)
case "m", "min", "mins", "minutes":
return fmt.Sprintf("%.02f%s", duration.Data.Minutes(), unit)
case "s", "sec", "secs", "seconds":
return fmt.Sprintf("%.02f%s", duration.Data.Seconds(), unit)
case "ms", "msec", "msecs", "milliseconds":
return fmt.Sprintf("%d%s", duration.Data.Milliseconds(), unit)
case "dur", "duration":
fallthrough
default:
return duration.Data.String()
}
}
func (datetime TPdatetime) String() string {
// datetime(default), date, time, unix, string
switch datetime.Format {
case "rfc3339":
return datetime.Data.Format(time.RFC3339)
case "date":
return datetime.Data.Format("2006-01-02")
case "time":
return datetime.Data.Format("03:04:05")
case "unix":
return fmt.Sprintf("%d", datetime.Data.Unix())
case "datetime":
fallthrough
case "":
return datetime.Data.Format(DefaultFormat)
default:
return datetime.Data.Format(datetime.Format)
}
}

81
cmd/writer_test.go Normal file
View File

@@ -0,0 +1,81 @@
/*
Copyright © 2025 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/>.
*/
package cmd
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDuration(t *testing.T) {
var tests = []struct {
format string
duration time.Duration
want string
}{
{"day", time.Hour * 24, "1.00 days"},
{"dur", time.Hour * 24, "24h0m0s"},
{"hour", time.Hour * 24, "24.00 hours"},
{"min", time.Minute * 20, "20.00 minutes"},
{"min", time.Minute*20 + time.Second*30, "20.50 minutes"},
{"sec", time.Second * 30, "30.00 seconds"},
{"ms", time.Second * 30, "30000 milliseconds"},
}
for _, tt := range tests {
testname := fmt.Sprintf("formatduration-%s", tt.format)
t.Run(testname, func(t *testing.T) {
tpdur := TPduration{Data: tt.duration}
tpdur.Unit = true
tpdur.Format = tt.format
out := tpdur.String()
assert.Equal(t, tt.want, out)
})
}
}
func TestDatetime(t *testing.T) {
var now = time.Date(2025, 9, 25, 12, 30, 00, 0, time.UTC)
var tests = []struct {
format string
datetime time.Time
want string
}{
{"rfc3339", now, "2025-09-25T12:30:00Z"},
{"date", now, "2025-09-25"},
{"time", now, "12:30:00"},
{"unix", now, "1758803400"},
{"datetime", now, "Thu Sep 25 12:30:00 UTC 2025"},
}
for _, tt := range tests {
testname := fmt.Sprintf("formatdatetime-%s", tt.format)
t.Run(testname, func(t *testing.T) {
tpdat := TPdatetime{Data: tt.datetime}
tpdat.Format = tt.format
out := tpdat.String()
assert.Equal(t, tt.want, out)
})
}
}

32
go.mod
View File

@@ -1,19 +1,31 @@
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/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/spf13/pflag v1.0.10 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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
)

39
go.sum
View File

@@ -1,22 +1,57 @@
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
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=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

19
main.go
View File

@@ -1,9 +1,26 @@
/*
Copyright © 2025 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/>.
*/
package main
import (
"os"
"github.com/tlinden/ts/cmd"
"codeberg.org/scip/ts/cmd"
)
func main() {

38
main_test.go Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright © 2025 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/>.
*/
package main
import (
"testing"
"github.com/rogpeppe/go-internal/testscript"
)
// see https://bitfieldconsulting.com/golang/test-scripts
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){
"ts": main,
})
}
func Test_TS(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "t",
})
}

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)
}

10
t/simple.txtar Normal file
View File

@@ -0,0 +1,10 @@
exec ts -h
stdout 'This is ts'
exec ts -e
stdout 'yesterday'
exec ts 3/1/2014
stdout 'Fri Jan 03'