├── .prettierrc.yml
├── .gitignore
├── material
├── screenshot
│ ├── gotz.png
│ ├── gotz-15-1.png
│ ├── gotz-15-2.png
│ ├── gotz-15-3.png
│ └── gotz-live.png
└── icon
│ └── world.svg
├── tests
├── testdata
│ ├── static_inline.golden
│ ├── static_mono.golden
│ ├── static_sort.golden
│ ├── static_custom.golden
│ ├── static_default.golden
│ ├── static_hours12.golden
│ ├── static_sun-moon.golden
│ ├── static_tics.golden
│ ├── static_mono.json
│ ├── static_default.json
│ ├── static_hours12.json
│ ├── static_sun-moon.json
│ ├── static_tics.json
│ ├── static_inline.json
│ ├── static_sort.json
│ └── static_custom.json
├── request_test.go
└── plot_test.go
├── go.mod
├── version.go
├── .github
└── workflows
│ ├── build.yml
│ └── publish.yml
├── .goreleaser.yaml
├── LICENSE
├── core
├── auxiliary.go
├── sorting.go
├── format.go
├── configuration.go
├── args.go
└── plot.go
├── main.go
├── go.sum
└── README.md
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | tabWidth: 2
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | gotz
2 | gotz.exe
3 | build/
4 | dist/
5 |
--------------------------------------------------------------------------------
/material/screenshot/gotz.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merschformann/gotz/HEAD/material/screenshot/gotz.png
--------------------------------------------------------------------------------
/material/screenshot/gotz-15-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merschformann/gotz/HEAD/material/screenshot/gotz-15-1.png
--------------------------------------------------------------------------------
/material/screenshot/gotz-15-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merschformann/gotz/HEAD/material/screenshot/gotz-15-2.png
--------------------------------------------------------------------------------
/material/screenshot/gotz-15-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merschformann/gotz/HEAD/material/screenshot/gotz-15-3.png
--------------------------------------------------------------------------------
/material/screenshot/gotz-live.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/merschformann/gotz/HEAD/material/screenshot/gotz-live.png
--------------------------------------------------------------------------------
/tests/testdata/static_inline.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 ▒▒▒██████████|██████▒▒▒▒▒▒▒
3 | New York: Sat 24 Aug 1985 10:00 ▒▒▒███|█████████████▒▒▒▒▒▒
4 | Berlin : Sat 24 Aug 1985 16:00 ▒▒▒█████████████|███▒▒▒▒▒▒
5 | Shanghai: Sat 24 Aug 1985 22:00 ██████████████▒▒▒▒▒▒| ▒▒▒███
6 | Sydney : Sun 25 Aug 1985 00:00 ██████████▒▒▒▒▒▒▒ | ▒▒▒▒██████
7 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/merschformann/gotz
2 |
3 | go 1.23.0
4 |
5 | require (
6 | github.com/adrg/xdg v0.4.0
7 | github.com/gdamore/tcell/v2 v2.6.0
8 | github.com/tidwall/jsonc v0.3.2
9 | golang.org/x/term v0.5.0
10 | )
11 |
12 | require (
13 | github.com/gdamore/encoding v1.0.0 // indirect
14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
15 | github.com/mattn/go-runewidth v0.0.14 // indirect
16 | github.com/rivo/uniseg v0.4.3 // indirect
17 | golang.org/x/sys v0.5.0 // indirect
18 | golang.org/x/text v0.7.0 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/version.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | // Version, commit, and date are set by the release process.
4 | var (
5 | version = "v0.0.0"
6 | commit = "none"
7 | date = "1970-01-01T00:00:00Z"
8 | )
9 |
10 | type ReleaseInfo struct {
11 | Version string `json:"version"`
12 | Commit string `json:"commit"`
13 | Date string `json:"date"`
14 | }
15 |
16 | // GetReleaseInfo returns the version information.
17 | func GetReleaseInfo() ReleaseInfo {
18 | return ReleaseInfo{
19 | Version: version,
20 | Commit: commit,
21 | Date: date,
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/testdata/static_mono.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 |
3 | ####################################|###################################
4 | New York: Sat 24 Aug 1985 10:00 |
5 | ####################################|###################################
6 | Berlin : Sat 24 Aug 1985 16:00 |
7 | ####################################|###################################
8 | Shanghai: Sat 24 Aug 1985 22:00 |
9 | ####################################|###################################
10 | Sydney : Sun 25 Aug 1985 00:00 |
11 | ####################################|###################################
12 |
--------------------------------------------------------------------------------
/tests/testdata/static_sort.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 |
3 | ▒▒▒▒▒▒██████████████████|███████████▒▒▒▒▒▒▒▒▒▒▒▒
4 | Berlin : Sat 24 Aug 1985 16:00 |
5 | ▒▒▒▒▒▒████████████████████████|█████▒▒▒▒▒▒▒▒▒▒▒▒
6 | New York: Sat 24 Aug 1985 10:00 |
7 | ▒▒▒▒▒▒██████|███████████████████████▒▒▒▒▒▒▒▒▒▒▒▒
8 | Shanghai: Sat 24 Aug 1985 22:00 |
9 | ████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒| ▒▒▒▒▒▒██████
10 | Sydney : Sun 25 Aug 1985 00:00 |
11 | ██████████████████▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒████████████
12 |
--------------------------------------------------------------------------------
/tests/testdata/static_custom.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 |
3 | ____________OOOOOOXXXXXXXXXXXXXXXXXX|XXXXXXXXXXXOOOOOOOOOOOO____________
4 | New York: Sat 24 Aug 1985 10:00 |
5 | ________________________OOOOOOXXXXXX|XXXXXXXXXXXXXXXXXXXXXXXOOOOOOOOOOOO
6 | Berlin : Sat 24 Aug 1985 16:00 |
7 | ______OOOOOOXXXXXXXXXXXXXXXXXXXXXXXX|XXXXXOOOOOOOOOOOO__________________
8 | Shanghai: Sat 24 Aug 1985 22:00 |
9 | XXXXXXXXXXXXXXXXXXXXXXXXOOOOOOOOOOOO|_______________________OOOOOOXXXXXX
10 | Sydney : Sun 25 Aug 1985 00:00 |
11 | XXXXXXXXXXXXXXXXXXOOOOOOOOOOOO______|_________________OOOOOOXXXXXXXXXXXX
12 |
--------------------------------------------------------------------------------
/tests/testdata/static_default.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 |
3 | ▒▒▒▒▒▒██████████████████|███████████▒▒▒▒▒▒▒▒▒▒▒▒
4 | New York: Sat 24 Aug 1985 10:00 |
5 | ▒▒▒▒▒▒██████|███████████████████████▒▒▒▒▒▒▒▒▒▒▒▒
6 | Berlin : Sat 24 Aug 1985 16:00 |
7 | ▒▒▒▒▒▒████████████████████████|█████▒▒▒▒▒▒▒▒▒▒▒▒
8 | Shanghai: Sat 24 Aug 1985 22:00 |
9 | ████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒| ▒▒▒▒▒▒██████
10 | Sydney : Sun 25 Aug 1985 00:00 |
11 | ██████████████████▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒████████████
12 |
--------------------------------------------------------------------------------
/tests/testdata/static_hours12.golden:
--------------------------------------------------------------------------------
1 | now v 4:00PM
2 | Local : Sat 24 Aug 1985 2:00PM |
3 | ▒▒▒▒▒▒██████████████████|███████████▒▒▒▒▒▒▒▒▒▒▒▒
4 | New York: Sat 24 Aug 1985 10:00AM |
5 | ▒▒▒▒▒▒██████|███████████████████████▒▒▒▒▒▒▒▒▒▒▒▒
6 | Berlin : Sat 24 Aug 1985 4:00PM |
7 | ▒▒▒▒▒▒████████████████████████|█████▒▒▒▒▒▒▒▒▒▒▒▒
8 | Shanghai: Sat 24 Aug 1985 10:00PM |
9 | ████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒| ▒▒▒▒▒▒██████
10 | Sydney : Sun 25 Aug 1985 12:00AM |
11 | ██████████████████▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒████████████
12 |
--------------------------------------------------------------------------------
/tests/testdata/static_sun-moon.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 |
3 | ☾☾☾☾☾☾☾☾☾☾☾☾☼☼☼☼☼☼☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀|☀☀☀☀☀☀☀☀☀☀☀☼☼☼☼☼☼☼☼☼☼☼☼☾☾☾☾☾☾☾☾☾☾☾☾
4 | New York: Sat 24 Aug 1985 10:00 |
5 | ☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☼☼☼☼☼☼☀☀☀☀☀☀|☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☼☼☼☼☼☼☼☼☼☼☼☼
6 | Berlin : Sat 24 Aug 1985 16:00 |
7 | ☾☾☾☾☾☾☼☼☼☼☼☼☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀|☀☀☀☀☀☼☼☼☼☼☼☼☼☼☼☼☼☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾
8 | Shanghai: Sat 24 Aug 1985 22:00 |
9 | ☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☼☼☼☼☼☼☼☼☼☼☼☼|☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☼☼☼☼☼☼☀☀☀☀☀☀
10 | Sydney : Sun 25 Aug 1985 00:00 |
11 | ☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☀☼☼☼☼☼☼☼☼☼☼☼☼☾☾☾☾☾☾|☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☾☼☼☼☼☼☼☀☀☀☀☀☀☀☀☀☀☀☀
12 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on:
3 | push:
4 | pull_request:
5 | types: [opened, synchronize, reopened, ready_for_review]
6 |
7 | jobs:
8 | build:
9 | name: build
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: git clone
13 | uses: actions/checkout@v4
14 |
15 | - name: set up go
16 | uses: actions/setup-go@v5
17 | with:
18 | go-version: 1.24.x
19 |
20 | - name: go build
21 | run: go build -v
22 |
23 | - name: go test
24 | run: go test -v -cover -coverpkg=./... -race ./...
25 |
26 | - name: golangci-lint
27 | uses: golangci/golangci-lint-action@v6
28 | with:
29 | version: v1.64.5
30 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | goreleaser:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout code
16 | uses: actions/checkout@v4
17 |
18 | - name: Unshallow repo
19 | run: git fetch --prune --unshallow
20 |
21 | - name: Setup Go
22 | uses: actions/setup-go@v5
23 | with:
24 | go-version: 1.24.x
25 |
26 | - name: Run goreleaser
27 | uses: goreleaser/goreleaser-action@v4
28 | with:
29 | distribution: goreleaser
30 | args: release --clean
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 | BREW_TOKEN: ${{ secrets.BREW_BUMP_TOKEN }}
34 |
--------------------------------------------------------------------------------
/tests/testdata/static_tics.golden:
--------------------------------------------------------------------------------
1 | now v 16:00
2 | Local : Sat 24 Aug 1985 14:00 |
3 | ▒▒▒▒▒▒██████████████████|███████████▒▒▒▒▒▒▒▒▒▒▒▒
4 | New York: Sat 24 Aug 1985 10:00 |
5 | ▒▒▒▒▒▒██████|███████████████████████▒▒▒▒▒▒▒▒▒▒▒▒
6 | Berlin : Sat 24 Aug 1985 16:00 |
7 | ▒▒▒▒▒▒████████████████████████|█████▒▒▒▒▒▒▒▒▒▒▒▒
8 | Shanghai: Sat 24 Aug 1985 22:00 |
9 | ████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒| ▒▒▒▒▒▒██████
10 | Sydney : Sun 25 Aug 1985 00:00 |
11 | ██████████████████▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒████████████
12 | ^ ^ ^ ^ ^ ^ ^ ^
13 | 6 9 12 15 18 21 0 3
14 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | builds:
3 | - env:
4 | - CGO_ENABLED=0
5 | goos:
6 | - freebsd
7 | - windows
8 | - darwin
9 | - linux
10 | goarch:
11 | - amd64
12 | - arm
13 | - arm64
14 | - "386"
15 | ldflags:
16 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
17 | flags: -tags=timetzdata
18 | checksum:
19 | name_template: "checksums.txt"
20 | changelog:
21 | use: github-native
22 | sort: asc
23 | brews:
24 | - name: gotz
25 | homepage: "https://github.com/merschformann/gotz"
26 | description: "A simple CLI timezone conversion assistant, written in Go"
27 | repository:
28 | owner: merschformann
29 | name: homebrew-gotz
30 | token: "{{ .Env.BREW_TOKEN }}"
31 | commit_author:
32 | name: merschbotmann
33 | email: bot.merschformann@gmail.com
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Marius Merschformann
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/testdata/static_mono.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "mono",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": false,
45 | "stretch": true,
46 | "hours12": false,
47 | "live": false
48 | }
49 |
--------------------------------------------------------------------------------
/tests/testdata/static_default.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "rectangles",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": false,
45 | "stretch": true,
46 | "hours12": false,
47 | "live": false
48 | }
49 |
--------------------------------------------------------------------------------
/tests/testdata/static_hours12.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "rectangles",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": false,
45 | "stretch": true,
46 | "hours12": true,
47 | "live": false
48 | }
49 |
--------------------------------------------------------------------------------
/tests/testdata/static_sun-moon.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "sun-moon",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": false,
45 | "stretch": true,
46 | "hours12": false,
47 | "live": false
48 | }
49 |
--------------------------------------------------------------------------------
/tests/testdata/static_tics.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "rectangles",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": true,
45 | "stretch": true,
46 | "hours12": false,
47 | "live": false
48 | }
49 |
--------------------------------------------------------------------------------
/tests/testdata/static_inline.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "rectangles",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": false,
45 | "stretch": true,
46 | "inline": true,
47 | "hours12": false,
48 | "live": false
49 | }
50 |
--------------------------------------------------------------------------------
/tests/testdata/static_sort.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "rectangles",
23 | "colorize": false,
24 | "day_segments": {
25 | "morning": 6,
26 | "day": 8,
27 | "evening": 18,
28 | "night": 22
29 | },
30 | "coloring": {
31 | "StaticColorMorning": "red",
32 | "StaticColorDay": "yellow",
33 | "StaticColorEvening": "red",
34 | "StaticColorNight": "blue",
35 | "StaticColorForeground": "",
36 | "DynamicColorMorning": "red",
37 | "DynamicColorDay": "yellow",
38 | "DynamicColorEvening": "red",
39 | "DynamicColorNight": "blue",
40 | "DynamicColorForeground": "",
41 | "DynamicColorBackground": ""
42 | }
43 | },
44 | "tics": false,
45 | "stretch": true,
46 | "hours12": false,
47 | "live": false,
48 | "sorting": "name",
49 | "sort_local_top": true
50 | }
51 |
--------------------------------------------------------------------------------
/core/auxiliary.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "os"
7 | "strconv"
8 | "strings"
9 |
10 | "golang.org/x/term"
11 | )
12 |
13 | // getTerminalWidth returns the width of the terminal.
14 | func getTerminalWidth() int {
15 | width, _, err := term.GetSize(0)
16 | if err != nil || width < 24 {
17 | return 72
18 | }
19 | return width
20 | }
21 |
22 | // convertHexToRgb converts hex color code to rgb.
23 | func convertHexToRgb(hex string) (r, g, b uint8, err error) {
24 | hex = strings.TrimPrefix(hex, "#")
25 | rgb, err := strconv.ParseUint(hex, 16, 32)
26 | if err != nil {
27 | return 0, 0, 0, err
28 | }
29 | r = uint8(rgb >> 16)
30 | g = uint8((rgb & 0x00ff00) >> 8)
31 | b = uint8(rgb & 0x0000ff)
32 | return r, g, b, nil
33 | }
34 |
35 | // AskUser asks the user a yes/no question and returns true if the user answers
36 | // yes.
37 | func AskUser(question string) (bool, error) {
38 | // Ask the user
39 | fmt.Printf("%s (y/N): ", question)
40 | // Read user input
41 | reader := bufio.NewReader(os.Stdin)
42 | input, err := reader.ReadString('\n')
43 | if err != nil {
44 | return false, err
45 | }
46 | // Normalize input
47 | input = strings.ToLower(input)
48 | input = strings.TrimSpace(input)
49 | // Check input
50 | return input == "y" || input == "yes", nil
51 | }
52 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/merschformann/gotz/core"
8 | )
9 |
10 | func main() {
11 | // Get configuration
12 | config, err := core.Load()
13 | // If there was an error loading the config, offer the user the option to
14 | // reset it (or simply exit).
15 | if err != nil {
16 | fmt.Println("error loading configuration:", err)
17 | // Ask the user if they want to reset the config
18 | if ok, in_err := core.AskUser("Reset configuration?"); in_err != nil {
19 | fmt.Println("error asking user:", in_err)
20 | os.Exit(1)
21 | } else if ok {
22 | // Reset config
23 | config, in_err = core.SaveDefault()
24 | if in_err != nil {
25 | fmt.Println("error resetting configuration:", in_err)
26 | os.Exit(1)
27 | }
28 | } else {
29 | // Exit
30 | os.Exit(0)
31 | }
32 | }
33 | // Parse flags
34 | config, rt, changed, err := core.ParseFlags(config, GetReleaseInfo().Version)
35 | if err != nil {
36 | fmt.Println("error parsing flags:", err)
37 | os.Exit(1)
38 | }
39 | // Update config, if necessary
40 | if changed {
41 | err = config.Save()
42 | if err != nil {
43 | fmt.Println("error saving configuration update:", err)
44 | os.Exit(1)
45 | }
46 | }
47 | // Plot time
48 | err = core.Plot(config, rt)
49 | if err != nil {
50 | fmt.Println("error plotting time:", err)
51 | os.Exit(1)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/tests/testdata/static_custom.json:
--------------------------------------------------------------------------------
1 | {
2 | "config_version": "1.0",
3 | "timezones": [
4 | {
5 | "Name": "New York",
6 | "TZ": "America/New_York"
7 | },
8 | {
9 | "Name": "Berlin",
10 | "TZ": "Europe/Berlin"
11 | },
12 | {
13 | "Name": "Shanghai",
14 | "TZ": "Asia/Shanghai"
15 | },
16 | {
17 | "Name": "Sydney",
18 | "TZ": "Australia/Sydney"
19 | }
20 | ],
21 | "style": {
22 | "symbols": "custom",
23 | "custom_symbols": [
24 | {
25 | "Start": 6,
26 | "Symbol": "O"
27 | },
28 | {
29 | "Start": 8,
30 | "Symbol": "X"
31 | },
32 | {
33 | "Start": 18,
34 | "Symbol": "O"
35 | },
36 | {
37 | "Start": 22,
38 | "Symbol": "_"
39 | }
40 | ],
41 | "colorize": true,
42 | "day_segments": {
43 | "morning": 6,
44 | "day": 8,
45 | "evening": 18,
46 | "night": 22
47 | },
48 | "coloring": {
49 | "StaticColorMorning": "red",
50 | "StaticColorDay": "yellow",
51 | "StaticColorEvening": "red",
52 | "StaticColorNight": "blue",
53 | "StaticColorForeground": "",
54 | "DynamicColorMorning": "red",
55 | "DynamicColorDay": "yellow",
56 | "DynamicColorEvening": "red",
57 | "DynamicColorNight": "blue",
58 | "DynamicColorForeground": "",
59 | "DynamicColorBackground": ""
60 | }
61 | },
62 | "tics": false,
63 | "stretch": true,
64 | "hours12": false,
65 | "live": false
66 | }
67 |
--------------------------------------------------------------------------------
/tests/request_test.go:
--------------------------------------------------------------------------------
1 | package core_test
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/merschformann/gotz/core"
8 | )
9 |
10 | func TestParseRequest(t *testing.T) {
11 | defaultConfig := core.DefaultConfig()
12 | londonTZ, _ := time.LoadLocation("Europe/London")
13 | berlinTZ, _ := time.LoadLocation("Europe/Berlin")
14 | // Define test cases
15 | tests := []struct {
16 | name string
17 | input string
18 | expected time.Time
19 | }{
20 | {
21 | name: "London",
22 | input: "1100@Europe/London",
23 | expected: time.Date(2023, 10, 1, 11, 0, 0, 0, londonTZ),
24 | },
25 | {
26 | name: "Berlin",
27 | input: "7pm@Europe/Berlin",
28 | expected: time.Date(2023, 10, 1, 19, 0, 0, 0, berlinTZ),
29 | },
30 | {
31 | name: "BerlinIndexed",
32 | input: "7pm@2",
33 | expected: time.Date(2023, 10, 1, 19, 0, 0, 0, berlinTZ),
34 | },
35 | }
36 |
37 | for _, test := range tests {
38 | t.Run(test.name, func(t *testing.T) {
39 | // Parse the request
40 | parsedTime, err := core.ParseRequestTime(defaultConfig, test.input)
41 | if err != nil {
42 | t.Fatalf("Error parsing request: %v", err)
43 | }
44 | // Check if the parsed time matches the expected time (ignore the
45 | // date)
46 | if parsedTime.Hour() != test.expected.Hour() || parsedTime.Minute() != test.expected.Minute() {
47 | t.Errorf("Expected %v, got %v", test.expected, parsedTime)
48 | }
49 | // Check if the timezone matches the expected timezone
50 | if parsedTime.Location().String() != test.expected.Location().String() {
51 | t.Errorf("Expected timezone %v, got %v", test.expected.Location(), parsedTime.Location())
52 | }
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/core/sorting.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "sort"
5 | "time"
6 | )
7 |
8 | // Define sorting modes
9 | const (
10 | // SortingModeNone keeps the order of the timezones as they are defined.
11 | SortingModeNone = "none"
12 | // SortingModeOffset sorts the timezones by their offset.
13 | SortingModeOffset = "offset"
14 | // SortingModeName sorts the timezones by their name.
15 | SortingModeName = "name"
16 | // SortingModeDefault is the default sorting mode.
17 | SortingModeDefault = SortingModeNone
18 | )
19 |
20 | // isValidSortingMode checks if the given sorting mode is defined and valid.
21 | func isValidSortingMode(mode string) bool {
22 | switch mode {
23 | case SortingModeNone, SortingModeOffset, SortingModeName:
24 | return true
25 | default:
26 | return false
27 | }
28 | }
29 |
30 | // locationContainer is a container for a location with additional information.
31 | type locationContainer struct {
32 | location *time.Location
33 | description string
34 | offset int
35 | }
36 |
37 | // sortLocations sorts the given locations based on the given sorting mode.
38 | func sortLocations(locations []locationContainer, sortingMode string, localTop bool) {
39 | sort.Slice(locations, func(i, j int) bool {
40 | // If the local timezone should be kept at the top, check if one of the
41 | // locations is the local timezone.
42 | if localTop {
43 | if locations[i].location == time.Local {
44 | return true
45 | } else if locations[j].location == time.Local {
46 | return false
47 | }
48 | }
49 | // Sort based on the sorting mode
50 | switch sortingMode {
51 | case SortingModeOffset:
52 | return locations[i].offset < locations[j].offset
53 | case SortingModeName:
54 | return locations[i].description < locations[j].description
55 | default:
56 | return i < j
57 | }
58 | })
59 | }
60 |
--------------------------------------------------------------------------------
/tests/plot_test.go:
--------------------------------------------------------------------------------
1 | package core_test
2 |
3 | import (
4 | "encoding/json"
5 | "flag"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "testing"
11 | "time"
12 |
13 | "github.com/merschformann/gotz/core"
14 | )
15 |
16 | // update indicates whether to update the golden files instead of using them to
17 | // compare the output.
18 | var update = flag.Bool("update", false, "Update golden files")
19 |
20 | // readExpectation reads the golden file and returns its content.
21 | func readExpectation(goldenFile string) (string, error) {
22 | // Read expected output string from file
23 | expected, err := os.ReadFile(goldenFile)
24 | if err != nil {
25 | return "", err
26 | }
27 | return string(expected), nil
28 | }
29 |
30 | func TestTableStatic(t *testing.T) {
31 | // Get all test configurations
32 | testConfigurations, err := filepath.Glob("testdata/*.json")
33 | if err != nil {
34 | t.Fatal(err)
35 | }
36 |
37 | // Set local time to UTC for reproducibility
38 | time.Local = time.UTC
39 |
40 | // Specify test time
41 | loc, _ := time.LoadLocation("Europe/Berlin")
42 | testTime := time.Date(1985, 8, 24, 16, 0, 0, 0, loc)
43 |
44 | // Run all tests
45 | for _, configFile := range testConfigurations {
46 | t.Run(strings.Replace(configFile, ".json", "", -1), func(t *testing.T) {
47 | // Read configuration file
48 | var config core.Config
49 | data, err := os.ReadFile(configFile)
50 | if err != nil {
51 | t.Fatal(err)
52 | }
53 | // Unmarshal
54 | err = json.Unmarshal(data, &config)
55 | if err != nil {
56 | t.Fatal(err)
57 | }
58 | // Get expected output
59 | goldenFile := strings.Replace(configFile, ".json", ".golden", -1)
60 | expected, err := readExpectation(goldenFile)
61 | if err != nil && !*update {
62 | t.Fatal(err)
63 | }
64 | // Setup plotter (collect output in stringbuilder for comparison)
65 | sb := strings.Builder{}
66 | plotter := core.Plotter{
67 | Now: true,
68 | TerminalWidth: 72,
69 | PlotLine: func(t core.ContextType, line ...interface{}) {
70 | _ = t
71 | sb.WriteString(fmt.Sprint(line...) + "\n")
72 | },
73 | PlotString: func(t core.ContextType, msg string) {
74 | _ = t
75 | sb.WriteString(msg)
76 | },
77 | Symbols: core.GetSymbols(config.Style),
78 | }
79 | // Create plot
80 | err = core.PlotTime(plotter, config, testTime)
81 | if err != nil {
82 | t.Errorf("error plotting time: %s", err)
83 | }
84 | // Get actual output
85 | actual := sb.String()
86 | // Update golden file
87 | if *update {
88 | if err := os.WriteFile(goldenFile, []byte(actual), 0644); err != nil {
89 | t.Fatal(err)
90 | }
91 | } else {
92 | // Compare actual output with expected output
93 | if actual != expected {
94 | t.Errorf("\nExpected: %s\nActual: %s", expected, actual)
95 | }
96 | }
97 | })
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
2 | github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
6 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
7 | github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg=
8 | github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
9 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
10 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
11 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
12 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
15 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
16 | github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
17 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
18 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
19 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
20 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
21 | github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
22 | github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
23 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
25 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
26 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
27 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
28 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
29 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
30 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
31 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
32 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
33 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
34 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
35 | golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
36 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
37 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
38 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
39 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
40 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
41 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
42 | golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
43 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
45 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
46 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
47 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
48 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
49 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
50 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
51 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
52 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
54 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
55 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
56 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gotz
2 |
3 | go**tz** - a simple CLI timezone info tool.
4 |
5 | ## Installation
6 |
7 | ### Directly via [Go](https://go.dev/doc/install)
8 |
9 | ```bash
10 | go install github.com/merschformann/gotz@latest
11 | ```
12 |
13 | ### Binary
14 |
15 | Simply download the binary of the [latest release](https://github.com/merschformann/gotz/releases/latest/) (look for `gotz_*_OS_ARCH` - darwin is macOS), unpack the `gotz` binary and put it in a folder in your `$PATH`.
16 |
17 | ### Homebrew
18 |
19 | ```bash
20 | brew tap merschformann/gotz # only once
21 | brew install gotz
22 | ```
23 |
24 | ## Usage
25 |
26 | Show current time:
27 |
28 | ```bash
29 | gotz
30 | ```
31 |
32 | 
33 |
34 | Show arbitrary time:
35 |
36 | ```bash
37 | gotz 15
38 | ```
39 |
40 | 
41 |
42 | Show arbitrary time using different timezone (index based):
43 |
44 | ```bash
45 | gotz 15@2
46 | ```
47 |
48 | 
49 |
50 | Show arbitrary time using different timezone (explicit TZ code):
51 |
52 | ```bash
53 | gotz 15@Asia/Tokyo
54 | ```
55 |
56 | 
57 |
58 | Time can be one of the following formats:
59 |
60 | ```txt
61 | 15
62 | 15:04
63 | 15:04:05
64 | 3:04pm
65 | 3:04:05pm
66 | 3pm
67 | 1504
68 | 150405
69 | 2006-01-02T15:04:05
70 | ```
71 |
72 | Use live mode to continuously update the time (exit via _q_, _esc_ or _ctrl+c_). Activate once via:
73 |
74 | ```bash
75 | gotz --live true
76 | ```
77 |
78 | 
79 |
80 | (above also uses option `--inline false`; for styling see customization below)
81 |
82 | ## Basic configuration
83 |
84 | Set the timezones to be used by default:
85 |
86 | ```bash
87 | gotz --timezones "Office:America/New_York,Home:Europe/Berlin"
88 | ```
89 |
90 | (lookup timezones in the [timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) wiki page - _TZ identifier_ column)
91 |
92 | Set 12-hour format:
93 |
94 | ```bash
95 | gotz --hours12 true
96 | ```
97 |
98 | ## Customization
99 |
100 | The configuration is stored in `$XDG_CONFIG_HOME/gotz/config.json` (usually `~/.config/gotz/config.json` on most systems). It can be configured directly or via the arguments of the `gotz` command (see `gotz --help`). The configuration attributes are described in the following example:
101 |
102 | ```jsonc
103 | {
104 | // Tracks the version of the configuration file (automatically written on creation)
105 | "config_version": "1.0",
106 | // Configures the timezones to be shown
107 | "timezones": [
108 | // Timezones have a name (Name) and timezone code (TZ)
109 | { "Name": "Office", "TZ": "America/New_York" },
110 | { "Name": "Home", "TZ": "Europe/Berlin" },
111 | ],
112 | // Configures the style of the plot
113 | "style": {
114 | // Select symbols to use for the time blocks
115 | // (one of 'mono', 'rectangles', 'blocks', 'sun-moon' or 'custom')
116 | "symbols": "mono",
117 | // Define custom symbols (used if 'symbols' is 'custom')
118 | // Each symbol is used from its start time (hour in day as int) until the next symbol
119 | "custom_symbols": [
120 | { "Start": 6, "Symbol": "▓" },
121 | { "Start": 8, "Symbol": "█" },
122 | { "Start": 18, "Symbol": "▓" },
123 | { "Start": 22, "Symbol": "░" }
124 | ],
125 | // Indicates whether to use coloring at all
126 | "colorize": true,
127 | // Configures how the day is segmented
128 | "day_segments": {
129 | // Hour of the morning to start (0-23)
130 | "morning": 6,
131 | // Hour of the day (business hours / main time) to start (0-23)
132 | "day": 8,
133 | // Hour of the evening to start (0-23)
134 | "evening": 18,
135 | // Hour of the night to start (0-23)
136 | "night": 22
137 | },
138 | // Defines the colors for the segments
139 | // Static mode colors can be one of:
140 | // - >simple< color names like `red`, `green`, `cyan`, etc.
141 | // - terminal color codes like `\u001b[34m`, `\u001b[32m`, etc.
142 | // - hex codes like #DC143C, #00ff00, etc. (if true color is supported)
143 | // Dynamic mode colors
144 | // - tcell color names like crimson, green, etc.
145 | // - hex codes like #DC143C, #00ff00, etc.
146 | // Note that some symbols are not fully opaque and will show the background color, thus,
147 | // making the color appear darker (or lighter for white/light terminal backgrounds)
148 | "coloring": {
149 | // Color of the morning segment for static mode
150 | "StaticColorMorning": "#EC3620",
151 | // Color of the morning segment for static mode
152 | "StaticColorDay": "#F9C748",
153 | // Color of the morning segment for static mode
154 | "StaticColorEvening": "#EC3620",
155 | // Color of the morning segment for static mode
156 | "StaticColorNight": "#030D4D",
157 | // Foreground color overriding default for static mode (optional)
158 | "StaticColorForeground": "",
159 | // Color of the morning segment for dynamic mode
160 | "DynamicColorMorning": "#419AA8",
161 | // Color of the day segment for dynamic mode
162 | "DynamicColorDay": "#FFFFFF",
163 | // Color of the evening segment for dynamic mode
164 | "DynamicColorEvening": "#419AA8",
165 | // Color of the night segment for dynamic mode
166 | "DynamicColorNight": "#09293F",
167 | // Foreground color overriding default for dynamic mode (optional)
168 | "DynamicColorForeground": "",
169 | // Background color overriding default for dynamic mode (optional)
170 | "DynamicColorBackground": ""
171 | }
172 | },
173 | // Indicates whether to plot tics for the local time
174 | "tics": false,
175 | // Indicates whether to stretch across the full terminal width (causes inhomogeneous segment lengths)
176 | "stretch": true,
177 | // Inline indicates whether location and time info will be plotted on one line with the bars.
178 | "inline": true,
179 | // Indicates whether to colorize the blocks
180 | "hours12": false,
181 | // Indicates whether to use 12-hour format
182 | "live": false,
183 | // Selects the sorting of the timezones
184 | // (one of 'name' - lexicographically, 'offset' - TZ offset, 'none' - user defined)
185 | "sorting": "name",
186 | // Indicates whether to keep the local timezone on top when using sorting
187 | "sort_local_top": true
188 | }
189 | ```
190 |
191 | ## Why?
192 |
193 | Working in an international team is a lot of fun, but comes with the challenge of having to deal with timezones. Since I am not good at computing them quickly in my head, I decided to write a simple CLI tool to help me out. I hope it can be useful for other people as well.
194 | Thanks for the inspiration @[sebas](https://github.com/sebastian-quintero)!
195 |
--------------------------------------------------------------------------------
/material/icon/world.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
105 |
--------------------------------------------------------------------------------
/core/format.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "strings"
7 | "unicode/utf8"
8 |
9 | "github.com/gdamore/tcell/v2"
10 | )
11 |
12 | type ContextType string
13 |
14 | const (
15 | ContextNormal ContextType = "normal"
16 | ContextMorning ContextType = "morning"
17 | ContextDay ContextType = "day"
18 | ContextEvening ContextType = "evening"
19 | ContextNight ContextType = "night"
20 | )
21 |
22 | // getDaySegment returns the day segment for the given hour.
23 | func getDaySegment(seg DaySegmentation, hour int) ContextType {
24 | switch {
25 | case hour < seg.MorningHour || hour >= seg.NightHour:
26 | return ContextNight
27 | case hour < seg.DayHour:
28 | return ContextMorning
29 | case hour < seg.EveningHour:
30 | return ContextDay
31 | case hour < seg.NightHour:
32 | return ContextEvening
33 | default:
34 | panic(fmt.Sprintf("invalid hour: %d", hour))
35 | }
36 | }
37 |
38 | // Terminal color codes.
39 | const (
40 | ColorBlack string = "\u001b[30m"
41 | ColorWhite string = "\u001b[37m"
42 | ColorRed string = "\u001b[31m"
43 | ColorYellow string = "\u001b[33m"
44 | ColorMagenta string = "\u001b[35m"
45 | ColorGreen string = "\u001b[32m"
46 | ColorCyan string = "\u001b[36m"
47 | ColorBlue string = "\u001b[34m"
48 | ColorReset string = "\u001b[0m"
49 | )
50 |
51 | // NamedStaticColors defines all terminal colors supported by name.
52 | var NamedStaticColors = map[string]string{
53 | "black": ColorBlack,
54 | "white": ColorWhite,
55 | "red": ColorRed,
56 | "yellow": ColorYellow,
57 | "magenta": ColorMagenta,
58 | "green": ColorGreen,
59 | "blue": ColorBlue,
60 | "cyan": ColorCyan,
61 | }
62 |
63 | // getDynamicColorMap returns a map of dynamic colors for the given style
64 | // configuration.
65 | func getDynamicColorMap(sty PlotColors) map[ContextType]tcell.Style {
66 | // Define lookup function
67 | getColor := func(colorValue string) tcell.Color {
68 | // Check if color is hex color
69 | if strings.HasPrefix(colorValue, "#") {
70 | return tcell.GetColor(strings.ToLower(colorValue))
71 | }
72 | // Check if color is a named color
73 | if c, ok := tcell.ColorNames[strings.ToLower(colorValue)]; ok {
74 | return c
75 | }
76 | // Use default color
77 | return tcell.ColorDefault
78 | }
79 | // Get default foreground / background
80 | fg, bg, _ := tcell.StyleDefault.Decompose()
81 | if sty.DynamicColorForeground != "" {
82 | fg = getColor(sty.DynamicColorForeground)
83 | }
84 | if sty.DynamicColorBackground != "" {
85 | bg = getColor(sty.DynamicColorBackground)
86 | }
87 | baseStyle := tcell.StyleDefault.Background(bg).Foreground(fg)
88 | // Create dynamic color map
89 | dynamicColorMap := make(map[ContextType]tcell.Style)
90 | dynamicColorMap[ContextNormal] = baseStyle
91 | dynamicColorMap[ContextMorning] = baseStyle.Foreground(getColor(sty.DynamicColorMorning))
92 | dynamicColorMap[ContextDay] = baseStyle.Foreground(getColor(sty.DynamicColorDay))
93 | dynamicColorMap[ContextEvening] = baseStyle.Foreground(getColor(sty.DynamicColorEvening))
94 | dynamicColorMap[ContextNight] = baseStyle.Foreground(getColor(sty.DynamicColorNight))
95 | return dynamicColorMap
96 | }
97 |
98 | // getStaticColorMap returns a map of static colors for the given style
99 | // configuration.
100 | func getStaticColorMap(sty PlotColors) map[ContextType]string {
101 | // Define lookup function
102 | getColor := func(colorValue string) string {
103 | // Check if color is a named color
104 | if color, ok := NamedStaticColors[colorValue]; ok {
105 | return color
106 | }
107 | // Check if color is hex color
108 | if strings.HasPrefix(colorValue, "#") {
109 | r, g, b, err := convertHexToRgb(strings.ToLower(colorValue))
110 | if err != nil {
111 | panic(err)
112 | }
113 | return fmt.Sprintf("\u001b[38;2;%d;%d;%dm", r, g, b)
114 | }
115 | // At this point color must be a valid terminal color code
116 | return colorValue
117 | }
118 | // Create static color map
119 | staticColorMap := make(map[ContextType]string)
120 | staticColorMap[ContextNormal] = getColor(sty.StaticColorForeground) // Override of background not supported in static mode
121 | staticColorMap[ContextMorning] = getColor(sty.StaticColorMorning)
122 | staticColorMap[ContextDay] = getColor(sty.StaticColorDay)
123 | staticColorMap[ContextEvening] = getColor(sty.StaticColorEvening)
124 | staticColorMap[ContextNight] = getColor(sty.StaticColorNight)
125 | return staticColorMap
126 | }
127 |
128 | // Define symbol modes
129 | const (
130 | // SymbolModeRectangles uses different kinds of rectangles to represent the
131 | // hours
132 | SymbolModeRectangles = "rectangles"
133 | // SymbolModeSunMoon uses the sun and moon symbols to represent the hours.
134 | SymbolModeSunMoon = "sun-moon"
135 | // SymbolModeMono uses a single character to represent the hours (use
136 | // coloring instead).
137 | SymbolModeMono = "mono"
138 | // SymbolModeBlocks uses all blocks to represent the hours.
139 | SymbolModeBlocks = "blocks"
140 | // SymbolModeCustom uses a custom user-defined symbols to represent the
141 | // hours.
142 | SymbolModeCustom = "custom"
143 | // SymbolModeDefault is the default symbol mode.
144 | SymbolModeDefault = SymbolModeRectangles
145 | )
146 |
147 | var (
148 | // SunMoonSymbols is a map of day segment to sun/moon symbol.
149 | SunMoonSymbols = map[ContextType]string{
150 | ContextNight: "☾",
151 | ContextMorning: "☼",
152 | ContextDay: "☀",
153 | ContextEvening: "☼",
154 | }
155 | // RectangleSymbols is a map of day segment to rectangle symbol.
156 | RectangleSymbols = map[ContextType]string{
157 | ContextNight: " ",
158 | ContextMorning: "▒",
159 | ContextDay: "█",
160 | ContextEvening: "▒",
161 | }
162 | )
163 |
164 | // checkSymbolMode checks if the given symbol mode is valid.
165 | func checkSymbolMode(mode string) error {
166 | switch mode {
167 | case SymbolModeRectangles, SymbolModeSunMoon, SymbolModeMono, SymbolModeBlocks, SymbolModeCustom:
168 | return nil
169 | default:
170 | return fmt.Errorf("invalid symbols: %s", mode)
171 | }
172 | }
173 |
174 | // checkSymbolMode does a small sanity check on the symbol definition.
175 | func checkSymbolConfig(sty Style) error {
176 | if sty.Symbols == SymbolModeCustom {
177 | if len(sty.CustomSymbols) <= 0 {
178 | return fmt.Errorf("custom symbols not defined")
179 | }
180 | seenHours := map[int]bool{}
181 | for _, s := range sty.CustomSymbols {
182 | if utf8.RuneCountInString(s.Symbol) != 1 {
183 | return fmt.Errorf("custom symbol %s is not a single character", s.Symbol)
184 | }
185 | if _, ok := seenHours[s.Start]; ok {
186 | return fmt.Errorf("duplicate custom symbol for hour %d", s.Start)
187 | }
188 | seenHours[s.Start] = true
189 | }
190 | }
191 | return nil
192 | }
193 |
194 | func GetSymbols(sty Style) []string {
195 | symbols := make([]string, 24)
196 | switch sty.Symbols {
197 | default:
198 | fallthrough
199 | case SymbolModeRectangles:
200 | for h := range symbols {
201 | symbols[h] = RectangleSymbols[getDaySegment(sty.DaySegmentation, h)]
202 | }
203 | case SymbolModeSunMoon:
204 | for h := range symbols {
205 | symbols[h] = SunMoonSymbols[getDaySegment(sty.DaySegmentation, h)]
206 | }
207 | case SymbolModeMono:
208 | for h := range symbols {
209 | symbols[h] = "#"
210 | }
211 | case SymbolModeBlocks:
212 | for h := range symbols {
213 | symbols[h] = "█"
214 | }
215 | case SymbolModeCustom:
216 | // Sort custom symbols by hour
217 | customSymbols := make([]TimeSymbol, len(sty.CustomSymbols))
218 | copy(customSymbols, sty.CustomSymbols)
219 | sort.Slice(customSymbols, func(i, j int) bool {
220 | return customSymbols[i].Start < customSymbols[j].Start
221 | })
222 | // Start with the symbol the previous day ends with
223 | currentSym, currentIdx := customSymbols[len(customSymbols)-1].Symbol, -1
224 | for h := range symbols {
225 | // Find the next custom symbol
226 | if currentIdx < len(customSymbols)-1 && h == customSymbols[currentIdx+1].Start {
227 | currentSym, currentIdx = customSymbols[currentIdx+1].Symbol, currentIdx+1
228 | }
229 | symbols[h] = currentSym
230 | }
231 | }
232 | return symbols
233 | }
234 |
--------------------------------------------------------------------------------
/core/configuration.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "time"
9 |
10 | "github.com/adrg/xdg"
11 | "github.com/tidwall/jsonc"
12 | )
13 |
14 | // ConfigVersion is the current version of the configuration file.
15 | const ConfigVersion = "1.0"
16 |
17 | // Config is the configuration struct.
18 | type Config struct {
19 | // Version is the version of the configuration file.
20 | ConfigVersion string `json:"config_version"`
21 |
22 | // All timezones to display.
23 | Timezones []Location `json:"timezones"`
24 |
25 | // Style defines the style of the timezone plot.
26 | Style Style `json:"style"`
27 |
28 | // Indicates whether to plot tics on the time axis.
29 | Tics bool `json:"tics"`
30 | // Indicates whether to stretch across the terminal width at cost of
31 | // accuracy.
32 | Stretch bool `json:"stretch"`
33 | // Inline indicates whether location and time info will be plotted on one
34 | // line with the bars.
35 | Inline bool `json:"inline"`
36 | // Indicates whether to use the 24-hour clock.
37 | Hours12 bool `json:"hours12"`
38 |
39 | // Indicates whether to continuously update.
40 | Live bool `json:"live"`
41 |
42 | // Defines the mode for sorting the timezones.
43 | Sorting string `json:"sorting"`
44 | // SortLocalTop indicates whether the local timezone should be kept at the
45 | // top (independent of the sorting mode).
46 | SortLocalTop bool `json:"sort_local_top"`
47 | }
48 |
49 | // Location describes a timezone the user wants to display.
50 | type Location struct {
51 | // Descriptive name of the timezone.
52 | Name string
53 | // Machine-readable timezone name.
54 | TZ string
55 | }
56 |
57 | type Style struct {
58 | // Defines the symbols to be used.
59 | Symbols string `json:"symbols"`
60 | // Defines the symbols to be used in custom mode.
61 | CustomSymbols []TimeSymbol `json:"custom_symbols,omitempty"`
62 | // Indicates whether to use colors.
63 | Colorize bool `json:"colorize"`
64 | // Defines how the day is split up into different ranges.
65 | DaySegmentation DaySegmentation `json:"day_segments"`
66 | // Defines the colors to be used in the plot.
67 | Coloring PlotColors `json:"coloring"`
68 | }
69 |
70 | // DaySegmentation defines how to segment the day.
71 | type DaySegmentation struct {
72 | // MorningHour is the hour at which the morning starts.
73 | MorningHour int `json:"morning"`
74 | // DayHour is the hour at which the day starts (basically business hours).
75 | DayHour int `json:"day"`
76 | // EveningHour is the hour at which the evening starts.
77 | EveningHour int `json:"evening"`
78 | // NightHour is the hour at which the night starts.
79 | NightHour int `json:"night"`
80 | }
81 |
82 | // TimeSymbol defines a symbol to be used from a start time until another symbol
83 | // is reached.
84 | type TimeSymbol struct {
85 | // Start of the time range.
86 | Start int
87 | // Symbol to be used for the time.
88 | Symbol string
89 | }
90 |
91 | // PlotColors defines the colors to be used in the plot.
92 | type PlotColors struct {
93 | // StaticColorMorning is the color to use for the morning segment.
94 | StaticColorMorning string
95 | // StaticColorDay is the color to use for the day segment.
96 | StaticColorDay string
97 | // StaticColorEvening is the color to use for the evening segment.
98 | StaticColorEvening string
99 | // StaticColorNight is the color to use for the night segment.
100 | StaticColorNight string
101 | // StaticColorForeground is the color to use for the foreground.
102 | StaticColorForeground string
103 |
104 | // DynamicColorMorning is the color to use for the morning segment (in live mode).
105 | DynamicColorMorning string
106 | // DynamicColorDay is the color to use for the morning segment (in live mode).
107 | DynamicColorDay string
108 | // DynamicColorEvening is the color to use for the morning segment (in live mode).
109 | DynamicColorEvening string
110 | // DynamicColorNight is the color to use for the morning segment (in live mode).
111 | DynamicColorNight string
112 | // DynamicColorForeground is the color to use for the foreground (in live mode).
113 | DynamicColorForeground string
114 | // DynamicColorBackground is the color to use for the background (in live mode).
115 | DynamicColorBackground string
116 | }
117 |
118 | // DefaultConfig configuration generator.
119 | func DefaultConfig() Config {
120 | tzs := []Location{}
121 | // Add some default locations
122 | ny, _ := time.LoadLocation("America/New_York")
123 | tzs = append(tzs, Location{"New York", ny.String()})
124 | london, _ := time.LoadLocation("Europe/Berlin")
125 | tzs = append(tzs, Location{"Berlin", london.String()})
126 | shanghai, _ := time.LoadLocation("Asia/Shanghai")
127 | tzs = append(tzs, Location{"Shanghai", shanghai.String()})
128 | sydney, _ := time.LoadLocation("Australia/Sydney")
129 | tzs = append(tzs, Location{"Sydney", sydney.String()})
130 | // Return default configuration
131 | return Config{
132 | ConfigVersion: ConfigVersion,
133 | Timezones: tzs,
134 | Style: Style{
135 | Symbols: SymbolModeDefault,
136 | CustomSymbols: []TimeSymbol{
137 | {6, "▓"},
138 | {8, "█"},
139 | {18, "▓"},
140 | {22, "░"},
141 | },
142 | Colorize: false,
143 | DaySegmentation: DaySegmentation{
144 | MorningHour: 6,
145 | DayHour: 8,
146 | EveningHour: 18,
147 | NightHour: 22,
148 | },
149 | Coloring: PlotColors{
150 | StaticColorMorning: "red",
151 | StaticColorDay: "yellow",
152 | StaticColorEvening: "red",
153 | StaticColorNight: "blue",
154 | StaticColorForeground: "", // don't override terminal foreground color
155 | DynamicColorMorning: "red",
156 | DynamicColorDay: "yellow",
157 | DynamicColorEvening: "red",
158 | DynamicColorNight: "blue",
159 | DynamicColorForeground: "", // don't override foreground color
160 | DynamicColorBackground: "", // don't override background color
161 | },
162 | },
163 | Tics: false,
164 | Stretch: true,
165 | Inline: true,
166 | }
167 | }
168 |
169 | // defaultConfigFile is the path of the default configuration file.
170 | func defaultConfigFile() string {
171 | configFilePath, err := xdg.ConfigFile("gotz/config.json")
172 | if err != nil {
173 | panic(fmt.Sprintf("Could not get user config directory: %s", err))
174 | }
175 | return configFilePath
176 | }
177 |
178 | // Load configuration from file.
179 | func Load() (Config, error) {
180 | // If no configuration file exists, create one
181 | if _, err := os.Stat(defaultConfigFile()); os.IsNotExist(err) {
182 | return SaveDefault()
183 | }
184 | // Read configuration file
185 | var config Config
186 | data, err := os.ReadFile(defaultConfigFile())
187 | if err != nil {
188 | return config, errors.New("Error reading config file: " + err.Error())
189 | }
190 | // Unmarshal
191 | err = json.Unmarshal(jsonc.ToJSON(data), &config)
192 | if err != nil {
193 | return config, errors.New("Error unmarshaling config file: " + err.Error())
194 | }
195 | // Check version
196 | if config.ConfigVersion != ConfigVersion {
197 | version := config.ConfigVersion
198 | if version == "" {
199 | version = "unknown"
200 | }
201 | return config, errors.New("Config file version " + version + " is not supported")
202 | }
203 | // Validate
204 | err = config.validate()
205 | if err != nil {
206 | return config, errors.New("Error validating config file: " + err.Error())
207 | }
208 | return config, nil
209 | }
210 |
211 | // SaveDefault creates a default config and immediately saves it.
212 | func SaveDefault() (Config, error) {
213 | c := DefaultConfig()
214 | return c, c.Save()
215 | }
216 |
217 | // Save configuration to file.
218 | func (c *Config) Save() error {
219 | // Marshal and pretty-print
220 | data, err := json.MarshalIndent(c, "", " ")
221 | if err != nil {
222 | return err
223 | }
224 | // Write file
225 | err = os.WriteFile(defaultConfigFile(), data, 0644)
226 | if err != nil {
227 | return err
228 | }
229 | return nil
230 | }
231 |
232 | // validate validates the configuration.
233 | func (c Config) validate() error {
234 | // Check whether symbol configuration is valid
235 | return checkSymbolConfig(c.Style)
236 | }
237 |
--------------------------------------------------------------------------------
/core/args.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "os"
7 | "strconv"
8 | "strings"
9 | "time"
10 | )
11 |
12 | // parseArgs parses the command line arguments and applies them to the given configuration.
13 | func ParseFlags(startConfig Config, appVersion string) (Config, time.Time, bool, error) {
14 | // Define version flag
15 | version := flag.Bool("version", false, "print version and exit")
16 | // Check for any changes
17 | var changed bool
18 | // Define configuration flags
19 | var timezones, symbols, tics, stretch, inline, colorize, hours12, live, sorting, sortLocalTop string
20 | flag.StringVar(
21 | &timezones,
22 | "timezones",
23 | "",
24 | "timezones to display, comma-separated (for example: 'America/New_York,Europe/London,Asia/Shanghai' or named 'Office:America/New_York,Home:Europe/London' "+
25 | " - for TZ names see TZ database name in https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)",
26 | )
27 | flag.StringVar(
28 | &symbols,
29 | "symbols",
30 | "",
31 | "symbols to use for time blocks (one of: "+
32 | SymbolModeRectangles+", "+
33 | SymbolModeSunMoon+", "+
34 | SymbolModeMono+")",
35 | )
36 | flag.StringVar(
37 | &tics,
38 | "tics",
39 | "",
40 | "indicates whether to use local time tics on the time axis (one of: true, false)",
41 | )
42 | flag.StringVar(
43 | &stretch,
44 | "stretch",
45 | "",
46 | "indicates whether to stretch across the terminal width at cost of accuracy (one of: true, false)",
47 | )
48 | flag.StringVar(
49 | &inline,
50 | "inline",
51 | "",
52 | "indicates whether to display time info and bars in one line (one of: true, false)",
53 | )
54 | flag.StringVar(
55 | &colorize,
56 | "colorize",
57 | "",
58 | "indicates whether to colorize the symbols (one of: true, false)",
59 | )
60 | flag.StringVar(
61 | &hours12,
62 | "hours12",
63 | "",
64 | "indicates whether to use 12-hour clock (one of: true, false)",
65 | )
66 | flag.StringVar(
67 | &live,
68 | "live",
69 | "",
70 | "indicates whether to display time live (quit via 'q' or 'Ctrl+C') (one of: true, false)",
71 | )
72 | flag.StringVar(
73 | &sorting,
74 | "sorting",
75 | SortingModeDefault,
76 | "indicates how to sort the timezones (one of: "+
77 | SortingModeNone+", "+
78 | SortingModeOffset+", "+
79 | SortingModeName+")",
80 | )
81 | flag.StringVar(
82 | &sortLocalTop,
83 | "sort-local-top",
84 | "",
85 | "indicates whether to keep the local timezone at the top (one of: true, false)",
86 | )
87 |
88 | // Define direct flags
89 | var requestTime string
90 | var rt time.Time = time.Time{}
91 | flag.StringVar(
92 | &requestTime,
93 | "time",
94 | "",
95 | "time to display (e.g. 20:00 or 2000 or 20 or 8pm)",
96 | )
97 |
98 | // Parse flags
99 | flag.Parse()
100 |
101 | // Check for version flag
102 | if *version {
103 | fmt.Println(appVersion)
104 | os.Exit(0)
105 | }
106 |
107 | // Handle configuration
108 | if timezones != "" {
109 | changed = true
110 | tzs, err := parseTimezones(timezones)
111 | if err != nil {
112 | return startConfig, rt, changed, err
113 | }
114 | startConfig.Timezones = tzs
115 | }
116 | if symbols != "" {
117 | changed = true
118 | startConfig.Style.Symbols = symbols
119 | symbolError := checkSymbolMode(startConfig.Style.Symbols)
120 | if symbolError != nil {
121 | return startConfig, rt, changed, symbolError
122 | }
123 | }
124 | if tics != "" {
125 | changed = true
126 | if strings.ToLower(tics) == "true" {
127 | startConfig.Tics = true
128 | } else if strings.ToLower(tics) == "false" {
129 | startConfig.Tics = false
130 | } else {
131 | return startConfig, rt, changed, fmt.Errorf("invalid value for tics: %s", tics)
132 | }
133 | }
134 | if stretch != "" {
135 | changed = true
136 | if strings.ToLower(stretch) == "true" {
137 | startConfig.Stretch = true
138 | } else if strings.ToLower(stretch) == "false" {
139 | startConfig.Stretch = false
140 | } else {
141 | return startConfig, rt, changed, fmt.Errorf("invalid value for stretch: %s", stretch)
142 | }
143 | }
144 | if inline != "" {
145 | changed = true
146 | if strings.ToLower(inline) == "true" {
147 | startConfig.Inline = true
148 | } else if strings.ToLower(inline) == "false" {
149 | startConfig.Inline = false
150 | } else {
151 | return startConfig, rt, changed, fmt.Errorf("invalid value for inline: %s", inline)
152 | }
153 | }
154 | if colorize != "" {
155 | changed = true
156 | if strings.ToLower(colorize) == "true" {
157 | startConfig.Style.Colorize = true
158 | } else if strings.ToLower(colorize) == "false" {
159 | startConfig.Style.Colorize = false
160 | } else {
161 | return startConfig, rt, changed, fmt.Errorf("invalid value for colorize: %s", colorize)
162 | }
163 | }
164 | if hours12 != "" {
165 | changed = true
166 | if strings.ToLower(hours12) == "true" {
167 | startConfig.Hours12 = true
168 | } else if strings.ToLower(hours12) == "false" {
169 | startConfig.Hours12 = false
170 | } else {
171 | return startConfig, rt, changed, fmt.Errorf("invalid value for hours12: %s", hours12)
172 | }
173 | }
174 | if live != "" {
175 | changed = true
176 | if strings.ToLower(live) == "true" {
177 | startConfig.Live = true
178 | } else if strings.ToLower(live) == "false" {
179 | startConfig.Live = false
180 | } else {
181 | return startConfig, rt, changed, fmt.Errorf("invalid value for live: %s", live)
182 | }
183 | }
184 | if sorting != "" {
185 | changed = true
186 | if !isValidSortingMode(sorting) {
187 | return startConfig, rt, changed, fmt.Errorf("invalid sorting mode: %s", sorting)
188 | }
189 | startConfig.Sorting = sorting
190 | }
191 | if sortLocalTop != "" {
192 | changed = true
193 | if strings.ToLower(sortLocalTop) == "true" {
194 | startConfig.SortLocalTop = true
195 | } else if strings.ToLower(sortLocalTop) == "false" {
196 | startConfig.SortLocalTop = false
197 | } else {
198 | return startConfig, rt, changed, fmt.Errorf("invalid value for sort-local-top: %s", sortLocalTop)
199 | }
200 | }
201 |
202 | // Handle direct flags
203 | if requestTime != "" {
204 | // Parse time
205 | rTime, err := ParseRequestTime(startConfig, requestTime)
206 | if err != nil {
207 | return startConfig, rt, changed, err
208 | }
209 | rt = rTime
210 | }
211 |
212 | // Handle last argument as time, if it starts with a digit
213 | if flag.NArg() > 0 {
214 | // Get last argument
215 | lastArg := flag.Arg(flag.NArg() - 1)
216 | // If last argument is a time, parse it
217 | if len(lastArg) > 0 && lastArg[0] >= '0' && lastArg[0] <= '9' {
218 | // Parse time
219 | rTime, err := ParseRequestTime(startConfig, lastArg)
220 | if err != nil {
221 | return startConfig, rt, changed, err
222 | }
223 | rt = rTime
224 | }
225 | }
226 |
227 | return startConfig, rt, changed, nil
228 | }
229 |
230 | // parseTimezones parses a comma-separated list of timezones.
231 | func parseTimezones(timezones string) ([]Location, error) {
232 | var timezoneList []Location
233 | for _, timezone := range strings.Split(timezones, ",") {
234 | // Skip empty timezones
235 | if timezone == "" {
236 | continue
237 | }
238 |
239 | if strings.Contains(timezone, ":") {
240 | // Handle named timezones
241 | parts := strings.Split(timezone, ":")
242 | if len(parts) != 2 {
243 | return timezoneList, fmt.Errorf("invalid timezone: %s", timezone)
244 | }
245 | if !checkTimezoneLocation(parts[1]) {
246 | return timezoneList, fmt.Errorf("invalid timezone: %s", timezone)
247 | }
248 | timezoneList = append(timezoneList, Location{
249 | Name: parts[0],
250 | TZ: parts[1],
251 | })
252 | } else {
253 | // Handle simple timezones
254 | if !checkTimezoneLocation(timezone) {
255 | return timezoneList, fmt.Errorf("invalid timezone: %s", timezone)
256 | }
257 | timezoneList = append(timezoneList, Location{
258 | Name: timezone,
259 | TZ: timezone,
260 | })
261 | }
262 | }
263 | return timezoneList, nil
264 | }
265 |
266 | // checkTimezoneLocation checks if a timezone name is valid.
267 | func checkTimezoneLocation(timezone string) bool {
268 | _, err := time.LoadLocation(timezone)
269 | return err == nil
270 | }
271 |
272 | // inputTimeFormat defines accepted time formats.
273 | type inputTimeFormat struct {
274 | // The format string.
275 | Format string
276 | // Indicates whether the input declared a date too.
277 | Date bool
278 | // Indicates whether the input declared a timezone too.
279 | TZInfo bool
280 | }
281 |
282 | // ParseRequestTime parses a requested time in various formats. Furthermore, it
283 | // reads an optional timezone index and uses its timezone instead of local.
284 | func ParseRequestTime(config Config, t string) (time.Time, error) {
285 | tzSeparator := "@"
286 | tz := time.Local
287 | // Check whether a different time zone than the local one was specified.
288 | if strings.Contains(t, tzSeparator) {
289 | // Split time and timezone
290 | parts := strings.Split(t, tzSeparator)
291 | if len(parts) != 2 {
292 | return time.Time{}, fmt.Errorf("invalid time format: %s (should be