├── .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 | ![preview](material/screenshot/gotz.png) 33 | 34 | Show arbitrary time: 35 | 36 | ```bash 37 | gotz 15 38 | ``` 39 | 40 | ![preview](material/screenshot/gotz-15-1.png) 41 | 42 | Show arbitrary time using different timezone (index based): 43 | 44 | ```bash 45 | gotz 15@2 46 | ``` 47 | 48 | ![preview](material/screenshot/gotz-15-2.png) 49 | 50 | Show arbitrary time using different timezone (explicit TZ code): 51 | 52 | ```bash 53 | gotz 15@Asia/Tokyo 54 | ``` 55 | 56 | ![preview](material/screenshot/gotz-15-3.png) 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 | ![preview](material/screenshot/gotz-live.png) 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 | 5 | 6 | 9 | 10 | 14 | 17 | 18 | 20 | 21 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 63 | 66 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 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