├── .gitignore ├── images ├── logo.png ├── search.png ├── currency.png ├── coin-page.png ├── main-page.png ├── portfolio.png ├── currency-full.png ├── change-duration.png ├── history-duration.png ├── portfolio-edit.png ├── portfolio-page.png └── cryptgo.drawio ├── Dockerfile ├── .github └── workflows │ ├── go.yml │ ├── license_check.yml │ ├── docker-image-stable.yml │ └── docker-image-dev.yml ├── cryptgo.go ├── pkg ├── utils │ ├── symbols.go │ ├── ops.go │ ├── tick.go │ ├── round.go │ ├── sort.go │ └── metadata.go ├── display │ ├── utilitywidgets │ │ ├── utility.go │ │ ├── changePercent.go │ │ ├── changeInterval.go │ │ ├── portfolio.go │ │ └── currency.go │ ├── allcoin │ │ ├── init.go │ │ └── allCoin.go │ ├── portfolio │ │ ├── init.go │ │ └── portfolio.go │ └── coin │ │ ├── init.go │ │ └── coin.go ├── api │ ├── coinID.go │ ├── types.go │ ├── getAllData.go │ └── getCoinData.go └── widgets │ ├── barGraph.go │ ├── lineGraph.go │ ├── help.go │ ├── table.go │ └── editBox.go ├── go.mod ├── Makefile ├── cmd ├── portfolio.go └── root.go ├── scripts └── license_check.go ├── README.md ├── LICENSE └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | cryptgo 2 | Arch 3 | .vscode 4 | log 5 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/search.png -------------------------------------------------------------------------------- /images/currency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/currency.png -------------------------------------------------------------------------------- /images/coin-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/coin-page.png -------------------------------------------------------------------------------- /images/main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/main-page.png -------------------------------------------------------------------------------- /images/portfolio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/portfolio.png -------------------------------------------------------------------------------- /images/currency-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/currency-full.png -------------------------------------------------------------------------------- /images/change-duration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/change-duration.png -------------------------------------------------------------------------------- /images/history-duration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/history-duration.png -------------------------------------------------------------------------------- /images/portfolio-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/portfolio-edit.png -------------------------------------------------------------------------------- /images/portfolio-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gituser143/cryptgo/HEAD/images/portfolio-page.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16 as builder 2 | WORKDIR /app 3 | COPY . . 4 | RUN CGO_ENABLED=0 GOOS=linux \ 5 | go build --ldflags "-s -w" -a -o ./output/cryptgo ./cryptgo.go 6 | 7 | FROM alpine:latest 8 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup 9 | USER appuser 10 | ENV HOME /home/appuser/ 11 | COPY --from=builder /app/output/cryptgo /app/cryptgo 12 | 13 | ENTRYPOINT ["/app/cryptgo"] 14 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.16 19 | 20 | - name: Build and Test 21 | run: make all 22 | -------------------------------------------------------------------------------- /.github/workflows/license_check.yml: -------------------------------------------------------------------------------- 1 | name: Go License Check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: license check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.16 20 | 21 | - name: License Check 22 | run: make license 23 | -------------------------------------------------------------------------------- /cryptgo.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import "github.com/Gituser143/cryptgo/cmd" 20 | 21 | func main() { 22 | cmd.Execute() 23 | } 24 | -------------------------------------------------------------------------------- /pkg/utils/symbols.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | const ( 20 | // UpArrow provides the symbol for increase 21 | UpArrow = "▲" 22 | // DownArrow provides the symbol for decrease 23 | DownArrow = "▼" 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/display/utilitywidgets/utility.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utilitywidgets 18 | 19 | type Utility int 20 | 21 | const ( 22 | None Utility = iota 23 | Help 24 | Portfolio 25 | Change 26 | Duration 27 | Currency 28 | ) 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Gituser143/cryptgo 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cjbassi/gotop v0.0.0-20200829004927-65d76af83079 7 | github.com/fsnotify/fsnotify v1.4.9 // indirect 8 | github.com/gizak/termui/v3 v3.1.0 9 | github.com/gorilla/websocket v1.4.2 10 | github.com/kr/pretty v0.2.1 // indirect 11 | github.com/mattn/go-runewidth v0.0.12 12 | github.com/mitchellh/go-homedir v1.1.0 13 | github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d 14 | github.com/pelletier/go-toml v1.8.1 // indirect 15 | github.com/spf13/afero v1.2.2 // indirect 16 | github.com/spf13/cobra v1.1.3 17 | github.com/spf13/viper v1.7.1 18 | github.com/stretchr/testify v1.6.1 // indirect 19 | github.com/superoo7/go-gecko v1.0.0 20 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 21 | golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 // indirect 22 | golang.org/x/text v0.3.6 // indirect 23 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-stable.yml: -------------------------------------------------------------------------------- 1 | name: Publish Stable Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v2 15 | 16 | - name: Extract metadata (tags, labels) for Docker 17 | id: meta 18 | uses: docker/metadata-action@v3 19 | with: 20 | images: bhargavsnv/cryptgo 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | 28 | - name: Build and push Docker image 29 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 30 | with: 31 | context: . 32 | push: true 33 | tags: ${{ steps.meta.outputs.tags }} 34 | labels: ${{ steps.meta.outputs.labels }} 35 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-dev.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | push_to_registry: 9 | name: Push Docker image to Docker Hub 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repo 13 | uses: actions/checkout@v2 14 | 15 | - name: Log in to Docker Hub 16 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 17 | with: 18 | username: ${{ secrets.DOCKER_USERNAME }} 19 | password: ${{ secrets.DOCKER_PASSWORD }} 20 | 21 | - name: Extract metadata (tags, labels) for Docker 22 | id: meta 23 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 24 | with: 25 | images: bhargavsnv/cryptgo 26 | 27 | - name: Build and push Docker image 28 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 29 | with: 30 | context: . 31 | push: true 32 | tags: ${{ steps.meta.outputs.tags }} 33 | labels: ${{ steps.meta.outputs.labels }} 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= latest 2 | ARG ?= 3 | 4 | ##@ General 5 | 6 | help: ## Display this help. 7 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 8 | 9 | ##@ Development 10 | fmt: ## Run go fmt against code. 11 | go fmt ./... 12 | 13 | vet: ## Run go vet against code. 14 | go vet ./... 15 | 16 | license: ## Run license check. 17 | go run scripts/license_check.go 18 | 19 | test: fmt vet license ## Run unit tests. 20 | go test -v ./... 21 | 22 | ##@ Build 23 | deps: ## Install dependencies 24 | go mod download 25 | 26 | build: ## Build binary. 27 | go build cryptgo.go 28 | 29 | all: deps test ## Install deps, test and build binary. 30 | make build 31 | 32 | ##@ Docker 33 | docker-build: ## Build docker image. 34 | docker build -t bhargavsnv/cryptgo . 35 | 36 | docker-pull: ## Pull docker image. 37 | docker pull bhargavsnv/cryptgo:$(VERSION) 38 | 39 | docker-run: ## Run docker image. 40 | docker run -u $(id -u):$(id -g) -v "$(HOME):/home/appuser/" --rm -it bhargavsnv/cryptgo:$(VERSION) $(ARG) 41 | -------------------------------------------------------------------------------- /images/cryptgo.drawio: -------------------------------------------------------------------------------- 1 | 7Vddb9sgFP01ltqHSDF23eRxddpN2ipt7aT2ldjERsXgYVwn+/W7GHD8kXZfnRqpkyIZ7oV74Zx7gHhBXGzfS1zm1yIlzEPzdOsFKw8hP0TI0795ujOWxWJuDJmkqR20N9zS78Qa3bCapqQaDFRCMEXLoTERnJNEDWxYStEMh20EG2YtcUYmhtsEs6n1jqYqt7tA53v7B0Kz3GX2o6XxFNgNtjupcpyKpmcKLr0glkIo0yq2MWEaPIeLmXf1hLdbmCRc/cqE5uN9U325+lQtr9kND+n8csVmNsojZrXdsIciBvEuCqKwxjXHsiImQfSt1ou9qNVmtth3oZXZbzuzKjHX+1U7C6IbmAgmpBe8A6fM1iew3Bia7nPahgCGuJptcEHZzgzNCXskiia456/aOtFeH5XbvsMk1R4uZIFZz9dYnrQzhLSthxGliJzBmhPKs+lMRbZqhhnNuPElgDaRPR/laUuAds7dWlqPkphXG4jlonJivI2Q6TBjN3GNk4dMipqnsxFaKFwYoFC4tI0zh1lKq5JhixfljLpMGyawGqUf03auA8BkrUCQsGMRCskQ6chFA05Ru0iia0sj2eRUkVvYkvY2EAdsuSoAxJUPTY2/1TaKumiPRAJQT5az34kEThcioCClXqWb4E4Ie7D4S9tv9jJdBtaW9yQauYnYHg1ZF3uvHmhYAf2GmNBETF9FCYZYUC0I30G7lgflMapsFNmi+Clhx8YMGjLTSnzEDFoeYKaj8MWZCZ5nBr0RZsLg6JgJn2cmeCPMnIdHx8zZhJkVPAkoq/T9hteAXI8ag3IsWF3wynBxcoP5g7mpql2xFsy0S0kBVnuT5eaFwTNryeF5pi/FtrPGDHM3thRSweONCk+fLmenR8fg+NQLDzAYzQ8wGPwzBt1F16PwTshKgekzkfplQmT1BwKbqMtPDUl+Y7+F/e5atv4+wbCwVrXEigpukiTtSRF3lXR8pTEWd/T64vanD/8L8r8yXrsyDh0aL1YZ0N3/22x9vf/sweUP -------------------------------------------------------------------------------- /pkg/utils/ops.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | // MinFloat64 returns minimum float from a given number of floats 20 | func MinFloat64(a ...float64) float64 { 21 | var min float64 22 | if len(a) > 0 { 23 | min = a[0] 24 | } else { 25 | return 0 26 | } 27 | 28 | for _, val := range a { 29 | if val < min { 30 | min = val 31 | } 32 | } 33 | return min 34 | } 35 | 36 | // MaxFloat64 returns maximum float from a given number of floats 37 | func MaxFloat64(a ...float64) float64 { 38 | var max float64 39 | if len(a) > 0 { 40 | max = a[0] 41 | } else { 42 | return 0 43 | } 44 | for _, val := range a { 45 | if val > max { 46 | max = val 47 | } 48 | } 49 | return max 50 | } 51 | -------------------------------------------------------------------------------- /pkg/utils/tick.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | import ( 20 | "context" 21 | "time" 22 | ) 23 | 24 | // LoopTick runs a given action in a loop in periods of 't' duration. It exits 25 | // when the context is cancelled 26 | func LoopTick(ctx context.Context, t time.Duration, action func(errChan chan error)) error { 27 | ticker := time.NewTicker(t) 28 | defer ticker.Stop() 29 | 30 | errChan := make(chan error) 31 | 32 | for { 33 | 34 | // Run action 35 | go action(errChan) 36 | 37 | select { 38 | // Return if context is cancelled 39 | case <-ctx.Done(): 40 | return ctx.Err() 41 | case err := <-errChan: 42 | if err != nil { 43 | return err 44 | } 45 | // Break select every tick 46 | case <-ticker.C: 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/portfolio.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cmd 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/Gituser143/cryptgo/pkg/api" 23 | "github.com/Gituser143/cryptgo/pkg/display/portfolio" 24 | "github.com/spf13/cobra" 25 | "golang.org/x/sync/errgroup" 26 | ) 27 | 28 | // portfolioCmd represents the portfolio command 29 | var portfolioCmd = &cobra.Command{ 30 | Use: "portfolio", 31 | Short: "Track your portfolio", 32 | Long: `The portfolio command helps track your own portfolio in real time`, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | // Context and errgroup used to manage routines 35 | eg, ctx := errgroup.WithContext(context.Background()) 36 | dataChannel := make(chan api.AssetData) 37 | 38 | // Flag to determine if data must be sent when viewing per coin prices 39 | sendData := true 40 | 41 | // Fetch Coin Assets 42 | eg.Go(func() error { 43 | return api.GetAssets(ctx, dataChannel, &sendData) 44 | }) 45 | 46 | // Display UI for portfolio 47 | eg.Go(func() error { 48 | return portfolio.DisplayPortfolio(ctx, dataChannel, &sendData) 49 | }) 50 | 51 | if err := eg.Wait(); err != nil { 52 | if err.Error() != "UI Closed" { 53 | return err 54 | } 55 | } 56 | return nil 57 | }, 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(portfolioCmd) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/utils/round.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | import "math" 20 | 21 | var ( 22 | kilo = math.Pow(10, 3) 23 | mega = math.Pow(10, 6) 24 | giga = math.Pow(10, 9) 25 | tera = math.Pow(10, 12) 26 | peta = math.Pow(10, 15) 27 | ) 28 | 29 | func roundOffNearestTen(num float64, divisor float64) float64 { 30 | x := num / divisor 31 | return math.Round(x*10) / 10 32 | } 33 | 34 | // RoundValues rounds off a pair of given floats to Thousands (kilo), 35 | // Millions (mega) or Billions (giga). 36 | func RoundValues(num1, num2 float64) ([]float64, string) { 37 | nums := []float64{} 38 | var units string 39 | var n float64 40 | if num1 > num2 { 41 | n = num1 42 | } else { 43 | n = num2 44 | } 45 | 46 | switch { 47 | case n < kilo: 48 | nums = append(nums, num1) 49 | nums = append(nums, num2) 50 | units = "" 51 | 52 | case n < mega: 53 | nums = append(nums, roundOffNearestTen(num1, kilo)) 54 | nums = append(nums, roundOffNearestTen(num2, kilo)) 55 | units = "K" 56 | 57 | case n < giga: 58 | nums = append(nums, roundOffNearestTen(num1, mega)) 59 | nums = append(nums, roundOffNearestTen(num2, mega)) 60 | units = "M" 61 | 62 | case n < tera: 63 | nums = append(nums, roundOffNearestTen(num1, giga)) 64 | nums = append(nums, roundOffNearestTen(num2, giga)) 65 | units = "B" 66 | 67 | case n >= peta: 68 | nums = append(nums, roundOffNearestTen(num1, tera)) 69 | nums = append(nums, roundOffNearestTen(num2, tera)) 70 | units = "T" 71 | 72 | default: 73 | return []float64{num1, num2}, "" 74 | } 75 | 76 | return nums, units 77 | } 78 | -------------------------------------------------------------------------------- /scripts/license_check.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "io/ioutil" 22 | "log" 23 | "os" 24 | "path/filepath" 25 | "strings" 26 | ) 27 | 28 | const license = `/* 29 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 30 | 31 | Licensed under the Apache License, Version 2.0 (the "License"); 32 | you may not use this file except in compliance with the License. 33 | You may obtain a copy of the License at 34 | 35 | http://www.apache.org/licenses/LICENSE-2.0 36 | 37 | Unless required by applicable law or agreed to in writing, software 38 | distributed under the License is distributed on an "AS IS" BASIS, 39 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 40 | See the License for the specific language governing permissions and 41 | limitations under the License. 42 | */` 43 | 44 | func main() { 45 | verified := true 46 | err := filepath.Walk(".", 47 | func(path string, info os.FileInfo, err error) error { 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if !info.IsDir() && strings.HasSuffix(path, ".go") { 53 | bs, err := ioutil.ReadFile(path) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | stringData := string(bs) 59 | if !strings.HasPrefix(stringData, license) { 60 | verified = false 61 | log.Println("License not verified in", path) 62 | } 63 | } 64 | 65 | return nil 66 | }) 67 | 68 | if err != nil { 69 | log.Println(err) 70 | } 71 | 72 | if !verified { 73 | os.Exit(1) 74 | } else { 75 | fmt.Println("All .go files have license verified.") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/display/utilitywidgets/changePercent.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utilitywidgets 18 | 19 | import ( 20 | "github.com/Gituser143/cryptgo/pkg/widgets" 21 | ui "github.com/gizak/termui/v3" 22 | ) 23 | 24 | var durationRows = [][]string{{"1 Hour"}, {"24 Hours"}, {"7 Days"}, {"14 Days"}, {"30 Days"}, {"200 Days"}, {"1 Year"}} 25 | 26 | // DurationMap maps duration strings to the format required by coinGecko API 27 | var DurationMap = map[string]string{ 28 | "1 Hour": "1h", 29 | "24 Hours": "24h", 30 | "7 Days": "7d", 31 | "14 Days": "14d", 32 | "30 Days": "30d", 33 | "200 Days": "200d", 34 | "1 Year": "1y", 35 | } 36 | 37 | // ChangePercentageDurationTable holds a table which helps change percentage duration values 38 | type ChangePercentageDurationTable struct { 39 | *widgets.Table 40 | } 41 | 42 | // NewChangePercentPage creates, initialises and returns a pointer to 43 | // an instance of CurrencyTable 44 | func NewChangePercentPage() *ChangePercentageDurationTable { 45 | c := &ChangePercentageDurationTable{ 46 | Table: widgets.NewTable(), 47 | } 48 | 49 | c.Table.Title = " Select Duration for Percentage Change " 50 | c.Table.Header = []string{"Duration"} 51 | c.Table.Rows = durationRows 52 | c.Table.CursorColor = ui.ColorCyan 53 | c.Table.ShowCursor = true 54 | c.Table.ColWidths = []int{5} 55 | c.Table.ColResizer = func() { 56 | x := c.Table.Inner.Dx() 57 | c.Table.ColWidths = []int{ 58 | x, 59 | } 60 | } 61 | return c 62 | } 63 | 64 | // Resize helps rresize the ChangePercentageDurationTable according to terminal dimensions 65 | func (c *ChangePercentageDurationTable) Resize(termWidth, termHeight int) { 66 | textWidth := 50 67 | 68 | textHeight := len(c.Table.Rows) + 3 69 | x := (termWidth - textWidth) / 2 70 | y := (termHeight - textHeight) / 2 71 | if x < 0 { 72 | x = 0 73 | textWidth = termWidth 74 | } 75 | if y < 0 { 76 | y = 0 77 | textHeight = termHeight 78 | } 79 | 80 | c.Table.SetRect(x, y, textWidth+x, textHeight+y) 81 | } 82 | 83 | // Draw puts the required text into the widget 84 | func (c *ChangePercentageDurationTable) Draw(buf *ui.Buffer) { 85 | c.Table.Draw(buf) 86 | } 87 | -------------------------------------------------------------------------------- /pkg/display/utilitywidgets/changeInterval.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utilitywidgets 18 | 19 | import ( 20 | "github.com/Gituser143/cryptgo/pkg/widgets" 21 | ui "github.com/gizak/termui/v3" 22 | ) 23 | 24 | var intervalRows = [][]string{{"24 Hours"}, {"7 Days"}, {"14 Days"}, {"30 Days"}, {"90 Days"}, {"180 Days"}, {"1 Year"}, {"5 Years"}} 25 | 26 | // IntervalMap maps given interval string to format required by CoinGecko API 27 | var IntervalMap = map[string]string{ 28 | "24 Hours": "24hr", 29 | "7 Days": "7d", 30 | "14 Days": "14d", 31 | "30 Days": "30d", 32 | "90 Days": "90d", 33 | "180 Days": "180d", 34 | "1 Year": "1yr", 35 | "5 Years": "5yr", 36 | } 37 | 38 | // ChangeIntervalDurationTable holds a table to help user change duration intervals 39 | type ChangeIntervalDurationTable struct { 40 | *widgets.Table 41 | } 42 | 43 | // NewChangeIntervalPage returns a pointer to an instance of 44 | // ChangeIntervalDurationTable 45 | func NewChangeIntervalPage() *ChangeIntervalDurationTable { 46 | c := &ChangeIntervalDurationTable{ 47 | Table: widgets.NewTable(), 48 | } 49 | 50 | c.Table.Title = " Select Duration for Coin History Interval" 51 | c.Table.Header = []string{"Duration"} 52 | c.Table.Rows = intervalRows 53 | c.Table.CursorColor = ui.ColorCyan 54 | c.Table.ShowCursor = true 55 | c.Table.ColWidths = []int{5} 56 | c.Table.ColResizer = func() { 57 | x := c.Table.Inner.Dx() 58 | c.Table.ColWidths = []int{ 59 | x, 60 | } 61 | } 62 | return c 63 | } 64 | 65 | // Resize helps resize the ChangeIntervalDurationTable according to terminal dimensions 66 | func (c *ChangeIntervalDurationTable) Resize(termWidth, termHeight int) { 67 | textWidth := 50 68 | 69 | textHeight := len(c.Table.Rows) + 3 70 | x := (termWidth - textWidth) / 2 71 | y := (termHeight - textHeight) / 2 72 | if x < 0 { 73 | x = 0 74 | textWidth = termWidth 75 | } 76 | if y < 0 { 77 | y = 0 78 | textHeight = termHeight 79 | } 80 | 81 | c.Table.SetRect(x, y, textWidth+x, textHeight+y) 82 | } 83 | 84 | // Draw puts the required text into the widget 85 | func (c *ChangeIntervalDurationTable) Draw(buf *ui.Buffer) { 86 | c.Table.Draw(buf) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/api/coinID.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import ( 20 | "encoding/json" 21 | "net/http" 22 | "strings" 23 | "sync" 24 | ) 25 | 26 | // NewCoinIDMap returns an instance of CoinIDMap 27 | func NewCoinIDMap() CoinIDMap { 28 | c := make(CoinIDMap) 29 | return c 30 | } 31 | 32 | // Populate updates values into the CoinIDMap 33 | func (c CoinIDMap) Populate() { 34 | 35 | var m sync.Mutex 36 | var wg sync.WaitGroup 37 | 38 | wg.Add(2) 39 | 40 | // Get CoinCapIDs 41 | go func(IDMap CoinIDMap, m *sync.Mutex, wg *sync.WaitGroup) { 42 | defer wg.Done() 43 | url := "https://api.coincap.io/v2/assets?limit=2000" 44 | method := "GET" 45 | 46 | client := &http.Client{} 47 | 48 | req, err := http.NewRequest(method, url, nil) 49 | if err != nil { 50 | return 51 | } 52 | 53 | res, err := client.Do(req) 54 | if err != nil { 55 | return 56 | } 57 | defer res.Body.Close() 58 | 59 | coinCapData := CoinCapData{} 60 | 61 | err = json.NewDecoder(res.Body).Decode(&coinCapData) 62 | if err != nil { 63 | return 64 | } 65 | 66 | for _, val := range coinCapData.Data { 67 | m.Lock() 68 | if _, ok := IDMap[val.Symbol]; ok { 69 | IDMap[val.Symbol] = CoinID{ 70 | CoinCapID: val.ID, 71 | CoinGeckoID: IDMap[val.Symbol].CoinGeckoID, 72 | } 73 | } else { 74 | IDMap[val.Symbol] = CoinID{ 75 | CoinCapID: val.ID, 76 | } 77 | } 78 | m.Unlock() 79 | } 80 | }(c, &m, &wg) 81 | 82 | go func(IDMap CoinIDMap, m *sync.Mutex, wg *sync.WaitGroup) { 83 | defer wg.Done() 84 | 85 | coinPtr, err := getTopNCoins(250) 86 | if err != nil { 87 | return 88 | } 89 | 90 | for _, val := range coinPtr { 91 | symbol := strings.ToUpper(val.Symbol) 92 | m.Lock() 93 | if _, ok := IDMap[symbol]; ok { 94 | IDMap[symbol] = CoinID{ 95 | CoinGeckoID: val.ID, 96 | CoinCapID: IDMap[symbol].CoinCapID, 97 | } 98 | } else { 99 | IDMap[symbol] = CoinID{ 100 | CoinGeckoID: val.ID, 101 | } 102 | } 103 | m.Unlock() 104 | } 105 | }(c, &m, &wg) 106 | 107 | wg.Wait() 108 | } 109 | -------------------------------------------------------------------------------- /pkg/api/types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import geckoTypes "github.com/superoo7/go-gecko/v3/types" 20 | 21 | // CoinData Holds data pertaining to a single coin. 22 | // This is used to serve per coin details. 23 | // It additionally holds a map of favourite coins. 24 | type CoinData struct { 25 | Type string 26 | PriceHistory []float64 27 | MinPrice float64 28 | MaxPrice float64 29 | Details CoinDetails 30 | Favourites map[string]float64 31 | } 32 | 33 | // CoinDetails holds information about a coin 34 | type CoinDetails struct { 35 | Name string 36 | Symbol string 37 | Rank string 38 | BlockTime string 39 | MarketCap float64 40 | Website string 41 | Explorers [][]string 42 | ATH float64 43 | ATHDate string 44 | ATL float64 45 | ATLDate string 46 | High24 float64 47 | Low24 float64 48 | TotalVolume float64 49 | ChangePercents [][]string 50 | TotalSupply float64 51 | CurrentSupply float64 52 | LastUpdate string 53 | } 54 | 55 | // AssetData is used to hold details of multiple coins and the price history 56 | // of top ranked coins along with their names 57 | type AssetData struct { 58 | TopCoinData [][]float64 59 | MaxPrices []float64 60 | MinPrices []float64 61 | TopCoins []string 62 | AllCoinData geckoTypes.CoinsMarket 63 | } 64 | 65 | // CoinCapAsset is used to marshal asset data from coinCap APIs 66 | type CoinCapAsset struct { 67 | ID string `json:"id"` 68 | Rank string `json:"rank"` 69 | Symbol string `json:"symbol"` 70 | Name string `json:"name"` 71 | Supply string `json:"supply"` 72 | MaxSupply string `json:"maxSupply"` 73 | MarketCapUsd string `json:"marketCapUsd"` 74 | VolumeUsd24Hr string `json:"volumeUsd24Hr"` 75 | PriceUsd string `json:"priceUsd"` 76 | ChangePercent24Hr string `json:"changePercent24Hr"` 77 | Vwap24Hr string `json:"vwap24Hr"` 78 | Explorer string `json:"explorer"` 79 | } 80 | 81 | // CoinCapData is used to marshall multiple assets from CoinCap APIs 82 | type CoinCapData struct { 83 | Data []CoinCapAsset `json:"data"` 84 | Timestamp uint `json:"timestamp"` 85 | } 86 | 87 | // CoinID holds the ID of a coin as stored in CoinGecko and CoinCap 88 | type CoinID struct { 89 | CoinGeckoID string 90 | CoinCapID string 91 | } 92 | 93 | // CoinIDMap maps a symbol to it's respective ID 94 | type CoinIDMap map[string]CoinID 95 | -------------------------------------------------------------------------------- /pkg/widgets/barGraph.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package widgets 18 | 19 | import ( 20 | "fmt" 21 | "image" 22 | 23 | rw "github.com/mattn/go-runewidth" 24 | 25 | ui "github.com/gizak/termui/v3" 26 | ) 27 | 28 | // BarChart represnts a custom UI widget 29 | type BarChart struct { 30 | NumFormatter func(float64) string 31 | Labels []string 32 | BarColors []ui.Color 33 | LabelStyles []ui.Style 34 | NumStyles []ui.Style 35 | Data []float64 36 | ui.Block 37 | BarWidth int 38 | BarGap int 39 | MaxVal float64 40 | } 41 | 42 | // NewBarChart initialises and returns a pointer to a BarChart instance 43 | func NewBarChart() *BarChart { 44 | return &BarChart{ 45 | Block: *ui.NewBlock(), 46 | BarColors: ui.Theme.BarChart.Bars, 47 | NumStyles: ui.Theme.BarChart.Nums, 48 | LabelStyles: ui.Theme.BarChart.Labels, 49 | NumFormatter: func(n float64) string { return fmt.Sprint(n) }, 50 | BarGap: 1, 51 | BarWidth: 3, 52 | } 53 | } 54 | 55 | // Draw helps draw the barChart onto the UI 56 | func (b *BarChart) Draw(buf *ui.Buffer) { 57 | b.Block.Draw(buf) 58 | 59 | maxVal := b.MaxVal 60 | if maxVal == 0 { 61 | maxVal, _ = ui.GetMaxFloat64FromSlice(b.Data) 62 | if maxVal == 0 { 63 | maxVal = 1 64 | } 65 | } 66 | 67 | barXCoordinate := b.Inner.Min.X 68 | 69 | for i, data := range b.Data { 70 | // draw bar 71 | height := int((data / maxVal) * float64(b.Inner.Dy()-1)) 72 | for x := barXCoordinate; x < ui.MinInt(barXCoordinate+b.BarWidth, b.Inner.Max.X); x++ { 73 | for y := b.Inner.Max.Y - 2; y > (b.Inner.Max.Y-2)-height; y-- { 74 | c := ui.NewCell(' ', ui.NewStyle(ui.ColorClear, ui.SelectColor(b.BarColors, i))) 75 | buf.SetCell(c, image.Pt(x, y)) 76 | } 77 | } 78 | 79 | // draw label 80 | if i < len(b.Labels) { 81 | labelXCoordinate := barXCoordinate + 82 | int((float64(b.BarWidth) / 2)) - 83 | int((float64(rw.StringWidth(b.Labels[i])) / 2)) 84 | buf.SetString( 85 | b.Labels[i], 86 | ui.SelectStyle(b.LabelStyles, i), 87 | image.Pt(labelXCoordinate, b.Inner.Max.Y-1), 88 | ) 89 | } 90 | 91 | // draw number 92 | numberXCoordinate := barXCoordinate + int((float64(b.BarWidth) / 2)) 93 | if numberXCoordinate <= b.Inner.Max.X { 94 | buf.SetString( 95 | b.NumFormatter(data), 96 | ui.NewStyle( 97 | ui.SelectStyle(b.NumStyles, i+1).Fg, 98 | ui.SelectColor(b.BarColors, i), 99 | ui.SelectStyle(b.NumStyles, i+1).Modifier, 100 | ), 101 | image.Pt(numberXCoordinate, b.Inner.Max.Y-2), 102 | ) 103 | } 104 | 105 | barXCoordinate += (b.BarWidth + b.BarGap) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/utils/sort.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | import ( 20 | "sort" 21 | "strconv" 22 | "strings" 23 | ) 24 | 25 | // SortData helps sort table rows. It sorts the table based on values given 26 | // in the sortIdx column and sorts ascending if sortAsc is true. 27 | // sortCase is set to identify the set of 'less' functions to use to 28 | // sort the selected column by. 29 | func SortData(data [][]string, sortIdx int, sortAsc bool, sortCase string) { 30 | 31 | if sortIdx < 0 { 32 | return 33 | } 34 | 35 | // Define less functions 36 | intSort := func(i, j int) bool { 37 | x, _ := strconv.Atoi(data[i][sortIdx]) 38 | y, _ := strconv.Atoi(data[j][sortIdx]) 39 | if sortAsc { 40 | return x < y 41 | } 42 | return x > y 43 | } 44 | 45 | strSort := func(i, j int) bool { 46 | if sortAsc { 47 | return data[i][sortIdx] < data[j][sortIdx] 48 | } 49 | return data[i][sortIdx] > data[j][sortIdx] 50 | } 51 | 52 | floatSort := func(i, j int) bool { 53 | x1 := data[i][sortIdx] 54 | y1 := data[j][sortIdx] 55 | x, _ := strconv.ParseFloat(x1, 32) 56 | y, _ := strconv.ParseFloat(y1, 32) 57 | if sortAsc { 58 | return x < y 59 | } 60 | return x > y 61 | } 62 | 63 | changeSort := func(i, j int) bool { 64 | x1 := strings.Split(data[i][sortIdx], " ") 65 | y1 := strings.Split(data[j][sortIdx], " ") 66 | x, _ := strconv.ParseFloat(x1[1], 64) 67 | if string(x1[0]) == DownArrow { 68 | x = -x 69 | } 70 | 71 | y, _ := strconv.ParseFloat(y1[1], 64) 72 | if string(y1[0]) == DownArrow { 73 | y = -y 74 | } 75 | 76 | if sortAsc { 77 | return x < y 78 | } 79 | return x > y 80 | } 81 | 82 | // Set function map 83 | sortFuncs := make(map[int]func(i, j int) bool) 84 | switch sortCase { 85 | case "COINS": 86 | sortFuncs = map[int]func(i, j int) bool{ 87 | 0: intSort, // Rank 88 | 1: strSort, // Symbol 89 | 2: floatSort, // Price 90 | 3: changeSort, // Change % 91 | } 92 | 93 | case "FAVOURITES": 94 | sortFuncs = map[int]func(i, j int) bool{ 95 | 0: strSort, // Symbol 96 | 1: floatSort, // Price 97 | } 98 | 99 | case "PORTFOLIO": 100 | sortFuncs = map[int]func(i, j int) bool{ 101 | 0: intSort, // Rank 102 | 1: strSort, // Symbol 103 | 2: floatSort, // Price 104 | 3: changeSort, // Change % 105 | 4: floatSort, // Holding 106 | 5: floatSort, // Balance 107 | 6: floatSort, // Holding % 108 | } 109 | 110 | default: 111 | sortFuncs[sortIdx] = strSort 112 | } 113 | 114 | if _, ok := sortFuncs[sortIdx]; !ok { 115 | sortIdx = 0 116 | } 117 | 118 | // Sort data 119 | sort.Slice(data, sortFuncs[sortIdx]) 120 | } 121 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cmd 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | 24 | "github.com/spf13/cobra" 25 | 26 | "golang.org/x/sync/errgroup" 27 | 28 | "github.com/Gituser143/cryptgo/pkg/api" 29 | "github.com/Gituser143/cryptgo/pkg/display/allcoin" 30 | homedir "github.com/mitchellh/go-homedir" 31 | "github.com/spf13/viper" 32 | ) 33 | 34 | var cfgFile string 35 | 36 | // rootCmd represents the base command when called without any subcommands 37 | var rootCmd = &cobra.Command{ 38 | Use: "cryptgo", 39 | Short: "A terminal application to watch crypto prices!", 40 | Long: `Crytpgo is a TUI based application written purely in Go to monitor and observe cryptocurrency prices in real time!`, 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | 43 | // Context and errgroup used to manage routines 44 | eg, ctx := errgroup.WithContext(context.Background()) 45 | dataChannel := make(chan api.AssetData) 46 | 47 | // Flag to determine if data must be sent when viewing per coin prices 48 | sendData := true 49 | 50 | // Fetch Coin Assets 51 | eg.Go(func() error { 52 | return api.GetAssets(ctx, dataChannel, &sendData) 53 | }) 54 | 55 | // Display UI for overall coins 56 | eg.Go(func() error { 57 | return allcoin.DisplayAllCoins(ctx, dataChannel, &sendData) 58 | }) 59 | 60 | if err := eg.Wait(); err != nil { 61 | if err.Error() != "UI Closed" { 62 | return err 63 | } 64 | } 65 | return nil 66 | }, 67 | } 68 | 69 | // Execute adds all child commands to the root command and sets flags appropriately. 70 | // This is called by main.main(). It only needs to happen once to the rootCmd. 71 | func Execute() { 72 | cobra.CheckErr(rootCmd.Execute()) 73 | } 74 | 75 | func init() { 76 | cobra.OnInitialize(initConfig) 77 | 78 | // Here you will define your flags and configuration settings. 79 | // Cobra supports persistent flags, which, if defined here, 80 | // will be global for your application. 81 | 82 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cryptgo.yaml)") 83 | } 84 | 85 | // initConfig reads in config file and ENV variables if set. 86 | func initConfig() { 87 | if cfgFile != "" { 88 | // Use config file from the flag. 89 | viper.SetConfigFile(cfgFile) 90 | } else { 91 | // Find home directory. 92 | home, err := homedir.Dir() 93 | cobra.CheckErr(err) 94 | 95 | // Search config in home directory with name ".cryptgo" (without extension). 96 | viper.AddConfigPath(home) 97 | viper.SetConfigName(".cryptgo") 98 | } 99 | 100 | viper.AutomaticEnv() // read in environment variables that match 101 | 102 | // If a config file is found, read it in. 103 | if err := viper.ReadInConfig(); err == nil { 104 | fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/display/utilitywidgets/portfolio.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utilitywidgets 18 | 19 | import ( 20 | "fmt" 21 | "net/http" 22 | "strings" 23 | "sync" 24 | 25 | "github.com/Gituser143/cryptgo/pkg/utils" 26 | "github.com/Gituser143/cryptgo/pkg/widgets" 27 | ui "github.com/gizak/termui/v3" 28 | gecko "github.com/superoo7/go-gecko/v3" 29 | ) 30 | 31 | // PortfolioTable holds a table which helps display a mini portfolio 32 | type PortfolioTable struct { 33 | *widgets.Table 34 | } 35 | 36 | // NewPortfolioPage creates, initialises and returns a pointer to an instance of PortfolioTable 37 | func NewPortfolioPage() *PortfolioTable { 38 | p := &PortfolioTable{ 39 | Table: widgets.NewTable(), 40 | } 41 | 42 | p.Table.Title = " Portfolio " 43 | p.Table.Header = []string{"Coin", "Symbol", "Price", "Holding", "Balance"} 44 | p.Table.CursorColor = ui.ColorCyan 45 | p.Table.ShowCursor = true 46 | p.Table.ColWidths = []int{5, 5, 5, 5, 5} 47 | p.Table.ColResizer = func() { 48 | x := p.Table.Inner.Dx() 49 | p.Table.ColWidths = []int{ 50 | x / 5, 51 | x / 5, 52 | x / 5, 53 | x / 5, 54 | x / 5, 55 | } 56 | } 57 | return p 58 | } 59 | 60 | // Resize helps resize the PortfolioTable according to terminal dimensions 61 | func (p *PortfolioTable) Resize(termWidth, termHeight int) { 62 | textWidth := 100 63 | 64 | textHeight := len(p.Table.Rows) + 3 65 | x := (termWidth - textWidth) / 2 66 | y := (termHeight - textHeight) / 2 67 | if x < 0 { 68 | x = 0 69 | textWidth = termWidth 70 | } 71 | if y < 0 { 72 | y = 0 73 | textHeight = termHeight 74 | } 75 | 76 | p.Table.SetRect(x, y, textWidth+x, textHeight+y) 77 | } 78 | 79 | // Draw puts the required text into the widget 80 | func (p *PortfolioTable) Draw(buf *ui.Buffer) { 81 | p.Table.Draw(buf) 82 | } 83 | 84 | // UpdateRows data 85 | func (p *PortfolioTable) UpdateRows(portfolio map[string]float64, currency string, currencyVal float64) { 86 | 87 | var wg sync.WaitGroup 88 | var m sync.Mutex 89 | 90 | client := &http.Client{} 91 | 92 | geckoClient := gecko.NewClient(client) 93 | 94 | rows := [][]string{} 95 | sum := 0.0 96 | for coin, amt := range portfolio { 97 | wg.Add(1) 98 | go func(coin string, amt float64, wg *sync.WaitGroup, m *sync.Mutex) { 99 | defer wg.Done() 100 | 101 | data, err := geckoClient.CoinsID(coin, false, false, true, false, false, false) 102 | if err != nil { 103 | return 104 | } 105 | 106 | p := data.MarketData.CurrentPrice["usd"] 107 | 108 | row := []string{ 109 | data.Name, 110 | strings.ToUpper(data.Symbol), 111 | fmt.Sprintf("%.2f", p/currencyVal), 112 | fmt.Sprintf("%.6f", amt), 113 | fmt.Sprintf("%.4f", p*amt/currencyVal), 114 | } 115 | 116 | m.Lock() 117 | sum += p * amt / currencyVal 118 | rows = append(rows, row) 119 | m.Unlock() 120 | 121 | }(coin, amt, &wg, &m) 122 | } 123 | 124 | wg.Wait() 125 | 126 | p.Header[2] = fmt.Sprintf("Price (%s)", currency) 127 | p.Header[4] = fmt.Sprintf("Balance (%s)", currency) 128 | p.Rows = rows 129 | p.Title = fmt.Sprintf(" Portfolio: %.4f %s ", sum, currency) 130 | utils.SortData(p.Rows, 4, false, "PORTFOLIO") 131 | } 132 | -------------------------------------------------------------------------------- /pkg/display/allcoin/init.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package allcoin 18 | 19 | import ( 20 | "github.com/Gituser143/cryptgo/pkg/widgets" 21 | ui "github.com/gizak/termui/v3" 22 | ) 23 | 24 | // allCoinPage holds UI items for the home page 25 | type allCoinPage struct { 26 | Grid *ui.Grid 27 | CoinTable *widgets.Table 28 | TopCoinGraphs []*widgets.LineGraph 29 | FavouritesTable *widgets.Table 30 | } 31 | 32 | // newallCoinPage creates, initialises and returns a pointer to an instance of allCoinPage 33 | func newAllCoinPage() *allCoinPage { 34 | coinGraphs := []*widgets.LineGraph{} 35 | for i := 0; i < 3; i++ { 36 | coinGraphs = append(coinGraphs, widgets.NewLineGraph()) 37 | } 38 | 39 | page := &allCoinPage{ 40 | Grid: ui.NewGrid(), 41 | CoinTable: widgets.NewTable(), 42 | TopCoinGraphs: coinGraphs, 43 | FavouritesTable: widgets.NewTable(), 44 | } 45 | 46 | page.init() 47 | 48 | return page 49 | } 50 | 51 | // init initialises the widgets of an allCoinPage 52 | func (page *allCoinPage) init() { 53 | // Initialise CoinTable 54 | page.CoinTable.Title = " Coins " 55 | page.CoinTable.BorderStyle.Fg = ui.ColorCyan 56 | page.CoinTable.TitleStyle.Fg = ui.ColorClear 57 | page.CoinTable.Header = []string{"Rank", "Symbol", "Price", "Change %", "Supply / MaxSupply"} 58 | page.CoinTable.ColResizer = func() { 59 | x := page.CoinTable.Inner.Dx() 60 | page.CoinTable.ColWidths = []int{ 61 | ui.MaxInt(8, x/5), 62 | ui.MaxInt(8, x/5), 63 | ui.MaxInt(15, x/5), 64 | ui.MaxInt(5, x/5), 65 | ui.MaxInt(20, x/5), 66 | } 67 | } 68 | page.CoinTable.ShowCursor = true 69 | page.CoinTable.CursorColor = ui.ColorCyan 70 | page.CoinTable.ChangeCol[3] = true 71 | 72 | // Initialise Favourites table 73 | page.FavouritesTable.Title = " Favourites " 74 | page.FavouritesTable.BorderStyle.Fg = ui.ColorCyan 75 | page.FavouritesTable.TitleStyle.Fg = ui.ColorClear 76 | page.FavouritesTable.Header = []string{"Symbol", "Price"} 77 | page.FavouritesTable.ColResizer = func() { 78 | x := page.FavouritesTable.Inner.Dx() 79 | page.FavouritesTable.ColWidths = []int{ 80 | 4 * x / 10, 81 | 6 * x / 10, 82 | } 83 | } 84 | page.FavouritesTable.CursorColor = ui.ColorCyan 85 | 86 | // Initialise Top Coin Graphs 87 | for i := 0; i < 3; i++ { 88 | page.TopCoinGraphs[i].TitleStyle = ui.NewStyle(ui.ColorClear) 89 | page.TopCoinGraphs[i].HorizontalScale = 1 90 | page.TopCoinGraphs[i].LineColors["Max"] = ui.ColorGreen 91 | page.TopCoinGraphs[i].LineColors["Min"] = ui.ColorRed 92 | page.TopCoinGraphs[i].LineColors["Value"] = ui.ColorBlue 93 | page.TopCoinGraphs[i].BorderStyle.Fg = ui.ColorCyan 94 | page.TopCoinGraphs[i].Data["Max"] = []float64{} 95 | page.TopCoinGraphs[i].Data["Min"] = []float64{} 96 | } 97 | 98 | // Set Grid layout 99 | w, h := ui.TerminalDimensions() 100 | page.Grid.Set( 101 | ui.NewRow(0.33, 102 | ui.NewCol(0.33, page.TopCoinGraphs[0]), 103 | ui.NewCol(0.33, page.TopCoinGraphs[1]), 104 | ui.NewCol(0.34, page.TopCoinGraphs[2]), 105 | ), 106 | ui.NewRow(0.67, 107 | ui.NewCol(0.33, page.FavouritesTable), 108 | ui.NewCol(0.67, page.CoinTable), 109 | ), 110 | ) 111 | 112 | page.Grid.SetRect(0, 0, w, h) 113 | 114 | } 115 | -------------------------------------------------------------------------------- /pkg/api/getAllData.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "github.com/Gituser143/cryptgo/pkg/utils" 25 | gecko "github.com/superoo7/go-gecko/v3" 26 | geckoTypes "github.com/superoo7/go-gecko/v3/types" 27 | ) 28 | 29 | func getTopNCoins(n int) (geckoTypes.CoinsMarket, error) { 30 | geckoClient := gecko.NewClient(nil) 31 | 32 | vsCurrency := "usd" 33 | ids := []string{} 34 | 35 | if n > 1000 { 36 | return nil, fmt.Errorf("page size limit is 1000") 37 | } 38 | 39 | perPage := n 40 | page := 1 41 | 42 | sparkline := true 43 | 44 | pcp := geckoTypes.PriceChangePercentageObject 45 | priceChangePercentage := []string{pcp.PCP1h, pcp.PCP24h, pcp.PCP7d, pcp.PCP14d, pcp.PCP30d, pcp.PCP200d, pcp.PCP1y} 46 | 47 | order := geckoTypes.OrderTypeObject.MarketCapDesc 48 | coinDataPointer, err := geckoClient.CoinsMarket(vsCurrency, ids, order, perPage, page, sparkline, priceChangePercentage) 49 | 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | coinData := *coinDataPointer 55 | 56 | return coinData, nil 57 | } 58 | 59 | // GetPercentageChangeForDuration returns price change percentage given a 60 | // CoinsMarketItem and a duration, If the specified duration does not exist, 24 61 | // Hour change percent is returned 62 | func GetPercentageChangeForDuration(coinData geckoTypes.CoinsMarketItem, duration string) float64 { 63 | 64 | m := map[string]*float64{ 65 | "1h": coinData.PriceChangePercentage1hInCurrency, 66 | "24h": coinData.PriceChangePercentage24hInCurrency, 67 | "7d": coinData.PriceChangePercentage7dInCurrency, 68 | "14d": coinData.PriceChangePercentage14dInCurrency, 69 | "30d": coinData.PriceChangePercentage30dInCurrency, 70 | "200d": coinData.PriceChangePercentage200dInCurrency, 71 | "1y": coinData.PriceChangePercentage1yInCurrency, 72 | } 73 | 74 | if percentageDuration, isPresent := m[duration]; isPresent && percentageDuration != nil { 75 | return *percentageDuration 76 | } 77 | return coinData.PriceChangePercentage24h 78 | } 79 | 80 | // GetAssets serves data about top 100 coins for the main page 81 | func GetAssets(ctx context.Context, dataChannel chan AssetData, sendData *bool) error { 82 | 83 | return utils.LoopTick(ctx, time.Duration(10)*time.Second, func(errChan chan error) { 84 | var finalErr error 85 | var data AssetData 86 | 87 | defer func() { 88 | if finalErr != nil { 89 | errChan <- finalErr 90 | } 91 | }() 92 | 93 | if *sendData { 94 | // Fetch Data 95 | coinsData, err := getTopNCoins(250) 96 | if err != nil { 97 | finalErr = err 98 | return 99 | } 100 | 101 | topCoinData := make([][]float64, 3) 102 | topCoins := make([]string, 3) 103 | maxPrices := make([]float64, 3) 104 | minPrices := make([]float64, 3) 105 | 106 | // Set Prices, Max and Min 107 | for i := 0; i < 3; i++ { 108 | val := coinsData[i] 109 | topCoins[i] = val.Name 110 | topCoinData[i] = val.SparklineIn7d.Price 111 | maxPrices[i] = utils.MaxFloat64(topCoinData[i]...) 112 | minPrices[i] = utils.MinFloat64(topCoinData[i]...) 113 | 114 | // Clean data for graph 115 | for index := range topCoinData[i] { 116 | topCoinData[i][index] -= minPrices[i] 117 | } 118 | } 119 | 120 | // Aggregate data 121 | data = AssetData{ 122 | AllCoinData: coinsData, 123 | MaxPrices: maxPrices, 124 | MinPrices: minPrices, 125 | TopCoinData: topCoinData, 126 | TopCoins: topCoins, 127 | } 128 | 129 | // Send Data 130 | select { 131 | case <-ctx.Done(): 132 | finalErr = ctx.Err() 133 | return 134 | case dataChannel <- data: 135 | } 136 | } else { 137 | select { 138 | case <-ctx.Done(): 139 | finalErr = ctx.Err() 140 | return 141 | default: 142 | } 143 | } 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /pkg/widgets/lineGraph.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package widgets 18 | 19 | import ( 20 | "image" 21 | "sort" 22 | 23 | drawille "github.com/cjbassi/gotop/src/termui/drawille-go" 24 | ui "github.com/gizak/termui/v3" 25 | ) 26 | 27 | // LineGraph implements a line graph of data points. 28 | type LineGraph struct { 29 | *ui.Block 30 | 31 | Data map[string][]float64 32 | Labels map[string]string 33 | 34 | HorizontalScale int 35 | MaxVal float64 36 | 37 | LineColors map[string]ui.Color 38 | DefaultLineColor ui.Color 39 | } 40 | 41 | // NewLineGraph creates and returns a lineGraph instance 42 | func NewLineGraph() *LineGraph { 43 | return &LineGraph{ 44 | Block: ui.NewBlock(), 45 | 46 | Data: make(map[string][]float64), 47 | Labels: make(map[string]string), 48 | 49 | HorizontalScale: 5, 50 | 51 | LineColors: make(map[string]ui.Color), 52 | } 53 | } 54 | 55 | // Draw draws the LineGraph onto the UI 56 | func (l *LineGraph) Draw(buf *ui.Buffer) { 57 | l.Block.Draw(buf) 58 | // we render each data point on to the canvas then copy over the braille to the buffer at the end 59 | // fyi braille characters have 2x4 dots for each character 60 | c := drawille.NewCanvas() 61 | // used to keep track of the braille colors until the end when we render the braille to the buffer 62 | colors := make([][]ui.Color, l.Inner.Dx()+2) 63 | for i := range colors { 64 | colors[i] = make([]ui.Color, l.Inner.Dy()+2) 65 | } 66 | 67 | // sort the series so that overlapping data will overlap the same way each time 68 | seriesList := make([]string, len(l.Data)) 69 | i := 0 70 | l.MaxVal = 1 71 | for seriesName := range l.Data { 72 | for _, val := range l.Data[seriesName] { 73 | if val > l.MaxVal { 74 | l.MaxVal = val 75 | } 76 | } 77 | seriesList[i] = seriesName 78 | i++ 79 | } 80 | sort.Strings(seriesList) 81 | 82 | // draw lines in reverse order so that the first color defined in the colorscheme is on top 83 | for i := len(seriesList) - 1; i >= 0; i-- { 84 | seriesName := seriesList[i] 85 | seriesData := l.Data[seriesName] 86 | seriesLineColor, ok := l.LineColors[seriesName] 87 | if !ok { 88 | seriesLineColor = l.DefaultLineColor 89 | } 90 | 91 | // coordinates of last point 92 | lastY, lastX := -1, -1 93 | // assign colors to `colors` and lines/points to the canvas 94 | for i := len(seriesData) - 1; i >= 0; i-- { 95 | x := ((l.Inner.Dx() + 1) * 2) - 1 - (((len(seriesData) - 1) - i) * l.HorizontalScale) 96 | y := ((l.Inner.Dy() + 1) * 4) - 1 - int((float64((l.Inner.Dy())*4)-1)*(seriesData[i]/float64(l.MaxVal))) 97 | if x < 0 { 98 | // render the line to the last point up to the wall 99 | if x > 0-l.HorizontalScale { 100 | for _, p := range drawille.Line(lastX, lastY, x, y) { 101 | if p.X > 0 { 102 | c.Set(p.X, p.Y) 103 | colors[p.X/2][p.Y/4] = seriesLineColor 104 | } 105 | } 106 | } 107 | break 108 | } 109 | if lastY == -1 { // if this is the first point 110 | c.Set(x, y) 111 | colors[x/2][y/4] = seriesLineColor 112 | } else { 113 | c.DrawLine(lastX, lastY, x, y) 114 | for _, p := range drawille.Line(lastX, lastY, x, y) { 115 | colors[p.X/2][p.Y/4] = seriesLineColor 116 | } 117 | } 118 | lastX, lastY = x, y 119 | } 120 | 121 | // copy braille and colors to buffer 122 | for y, line := range c.Rows(c.MinX(), c.MinY(), c.MaxX(), c.MaxY()) { 123 | for x, char := range line { 124 | x /= 3 // idk why but it works 125 | if x == 0 { 126 | continue 127 | } 128 | if char != 10240 { // empty braille character 129 | buf.SetCell( 130 | ui.NewCell(char, ui.NewStyle(colors[x][y])), 131 | image.Pt(l.Inner.Min.X+x-1, l.Inner.Min.Y+y-1), 132 | ) 133 | } 134 | } 135 | } 136 | } 137 | 138 | // renders key/label ontop 139 | for i, seriesName := range seriesList { 140 | if i+2 > l.Inner.Dy() { 141 | continue 142 | } 143 | seriesLineColor, ok := l.LineColors[seriesName] 144 | if !ok { 145 | seriesLineColor = l.DefaultLineColor 146 | } 147 | 148 | // render key ontop, but let braille be drawn over space characters 149 | str := seriesName + " " + l.Labels[seriesName] 150 | for k, char := range str { 151 | if char != ' ' { 152 | buf.SetCell( 153 | ui.NewCell(char, ui.NewStyle(seriesLineColor)), 154 | image.Pt(l.Inner.Min.X+2+k, l.Inner.Min.Y+i+1), 155 | ) 156 | } 157 | } 158 | 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /pkg/display/portfolio/init.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package portfolio 18 | 19 | import ( 20 | "math" 21 | 22 | "github.com/Gituser143/cryptgo/pkg/widgets" 23 | ui "github.com/gizak/termui/v3" 24 | ) 25 | 26 | // portfolioPage holds UI items for the portfolio page 27 | type portfolioPage struct { 28 | Grid *ui.Grid 29 | DetailsTable *widgets.Table 30 | CoinTable *widgets.Table 31 | BestPerformerTable *widgets.Table 32 | WorstPerformerTable *widgets.Table 33 | } 34 | 35 | // performer holds best and worst perfomer details 36 | type performer struct { 37 | BestVal float64 38 | BestCoin string 39 | WorstVal float64 40 | WorstCoin string 41 | } 42 | 43 | // getEmptyPerformers returns a map with required durations as keys and empty performers 44 | func getEmptyPerformers() map[string]performer { 45 | m := map[string]performer{ 46 | "1h": {BestVal: math.Inf(-1), WorstVal: math.Inf(1)}, 47 | "24h": {BestVal: math.Inf(-1), WorstVal: math.Inf(1)}, 48 | "7d": {BestVal: math.Inf(-1), WorstVal: math.Inf(1)}, 49 | "30d": {BestVal: math.Inf(-1), WorstVal: math.Inf(1)}, 50 | "1y": {BestVal: math.Inf(-1), WorstVal: math.Inf(1)}, 51 | } 52 | 53 | return m 54 | } 55 | 56 | func newPortfolioPage() *portfolioPage { 57 | page := &portfolioPage{ 58 | Grid: ui.NewGrid(), 59 | DetailsTable: widgets.NewTable(), 60 | CoinTable: widgets.NewTable(), 61 | BestPerformerTable: widgets.NewTable(), 62 | WorstPerformerTable: widgets.NewTable(), 63 | } 64 | 65 | page.init() 66 | 67 | return page 68 | } 69 | 70 | func (page *portfolioPage) init() { 71 | // Initialise Details table 72 | page.DetailsTable.Title = " Details " 73 | page.DetailsTable.BorderStyle.Fg = ui.ColorCyan 74 | page.DetailsTable.TitleStyle.Fg = ui.ColorClear 75 | page.DetailsTable.Header = []string{"Balance", ""} 76 | page.DetailsTable.ColResizer = func() { 77 | x := page.DetailsTable.Inner.Dx() 78 | page.DetailsTable.ColWidths = []int{ 79 | x / 2, 80 | x / 2, 81 | } 82 | } 83 | page.DetailsTable.ShowCursor = false 84 | page.DetailsTable.CursorColor = ui.ColorCyan 85 | 86 | // Initialise CoinTable 87 | page.CoinTable.Title = " Coins " 88 | page.CoinTable.BorderStyle.Fg = ui.ColorCyan 89 | page.CoinTable.TitleStyle.Fg = ui.ColorClear 90 | page.CoinTable.Header = []string{"Rank", "Symbol", "Price", "Change % (1d)", "Holding", "Balance", "Holding %"} 91 | page.CoinTable.ColResizer = func() { 92 | x := page.CoinTable.Inner.Dx() 93 | page.CoinTable.ColWidths = []int{ 94 | ui.MaxInt(5, 1*(x/10)), 95 | ui.MaxInt(5, 1*(x/10)), 96 | 2 * (x / 10), 97 | 2 * (x / 10), 98 | ui.MaxInt(5, 1*(x/10)), 99 | 2 * (x / 10), 100 | ui.MaxInt(5, 1*(x/10)), 101 | } 102 | } 103 | page.CoinTable.ShowCursor = true 104 | page.CoinTable.CursorColor = ui.ColorCyan 105 | page.CoinTable.ChangeCol[3] = true 106 | 107 | // Initialise Best Performer Table 108 | page.BestPerformerTable.Title = " Best Performers " 109 | page.BestPerformerTable.BorderStyle.Fg = ui.ColorCyan 110 | page.BestPerformerTable.TitleStyle.Fg = ui.ColorClear 111 | page.BestPerformerTable.Header = []string{"Time", "Coin", "Change"} 112 | page.BestPerformerTable.ColResizer = func() { 113 | x := page.BestPerformerTable.Inner.Dx() 114 | page.BestPerformerTable.ColWidths = []int{ 115 | 3 * x / 10, 116 | 3 * x / 10, 117 | 3 * x / 10, 118 | } 119 | } 120 | page.BestPerformerTable.CursorColor = ui.ColorCyan 121 | page.BestPerformerTable.ChangeCol[2] = true 122 | 123 | // Initialise Worst Performer Table 124 | page.WorstPerformerTable.Title = " Worst Performers " 125 | page.WorstPerformerTable.BorderStyle.Fg = ui.ColorCyan 126 | page.WorstPerformerTable.TitleStyle.Fg = ui.ColorClear 127 | page.WorstPerformerTable.Header = []string{"Time", "Coin", "Change"} 128 | page.WorstPerformerTable.ColResizer = func() { 129 | x := page.WorstPerformerTable.Inner.Dx() 130 | page.WorstPerformerTable.ColWidths = []int{ 131 | 3 * x / 10, 132 | 3 * x / 10, 133 | 3 * x / 10, 134 | } 135 | } 136 | page.WorstPerformerTable.CursorColor = ui.ColorCyan 137 | page.WorstPerformerTable.ChangeCol[2] = true 138 | 139 | // Set Grid layout 140 | w, h := ui.TerminalDimensions() 141 | page.Grid.Set( 142 | ui.NewRow(0.3, 143 | ui.NewCol(0.2, page.DetailsTable), 144 | ui.NewCol(0.4, page.BestPerformerTable), 145 | ui.NewCol(0.4, page.WorstPerformerTable), 146 | ), 147 | ui.NewRow(0.7, page.CoinTable), 148 | ) 149 | 150 | page.Grid.SetRect(0, 0, w, h) 151 | } 152 | -------------------------------------------------------------------------------- /pkg/utils/metadata.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utils 18 | 19 | import ( 20 | "encoding/json" 21 | "os" 22 | ) 23 | 24 | // Metadata holds persistent information to be stored to disk 25 | type Metadata struct { 26 | Favourites map[string]bool `json:"favourites"` 27 | Currency string `json:"currency"` 28 | Portfolio map[string]float64 `json:"portfolio"` 29 | } 30 | 31 | // Currency holds currency data when fetched from CoinCap 32 | type Currency struct { 33 | ID string `json:"id"` 34 | Symbol string `json:"symbol"` 35 | CurrencySymbol string `json:"currencySymbol"` 36 | Type string `json:"type"` 37 | RateUSD string `json:"rateUsd"` 38 | } 39 | 40 | // AllCurrencyData holds data of a group of currencies fetched from CoinCap 41 | type AllCurrencyData struct { 42 | Data []Currency `json:"data"` 43 | Timestamp uint `json:"timestamp"` 44 | } 45 | 46 | // GetFavourites reads stored favourite coin details from 47 | // ~/.cryptgo-data.json and returns a map. 48 | func GetFavourites() map[string]bool { 49 | metadata := Metadata{} 50 | 51 | // Get home directory 52 | homeDir, err := os.UserHomeDir() 53 | if err != nil { 54 | return map[string]bool{} 55 | } 56 | 57 | // Check if metadata file exists 58 | configPath := homeDir + "/.cryptgo-data.json" 59 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 60 | return map[string]bool{} 61 | } 62 | 63 | // Open file 64 | configFile, err := os.Open(configPath) 65 | if err != nil { 66 | return map[string]bool{} 67 | } 68 | 69 | // Read content 70 | err = json.NewDecoder(configFile).Decode(&metadata) 71 | if err != nil { 72 | return map[string]bool{} 73 | } 74 | 75 | if len(metadata.Favourites) > 0 { 76 | return metadata.Favourites 77 | } 78 | 79 | return map[string]bool{} 80 | } 81 | 82 | // GetPortfolio reads stored portfolio details from 83 | // ~/.cryptgo-data.json and returns a map. 84 | func GetPortfolio() map[string]float64 { 85 | metadata := Metadata{} 86 | 87 | // Get home directory 88 | homeDir, err := os.UserHomeDir() 89 | if err != nil { 90 | return map[string]float64{} 91 | } 92 | 93 | // Check if metadta file exists 94 | configPath := homeDir + "/.cryptgo-data.json" 95 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 96 | return map[string]float64{} 97 | } 98 | 99 | // Open file 100 | configFile, err := os.Open(configPath) 101 | if err != nil { 102 | return map[string]float64{} 103 | } 104 | 105 | // Read content 106 | err = json.NewDecoder(configFile).Decode(&metadata) 107 | if err != nil { 108 | return map[string]float64{} 109 | } 110 | 111 | if len(metadata.Portfolio) > 0 { 112 | return metadata.Portfolio 113 | } 114 | 115 | return map[string]float64{} 116 | } 117 | 118 | // GetCurrencyID returns the currencyID stored from metadata 119 | func GetCurrencyID() string { 120 | metadata := Metadata{} 121 | 122 | // Get home directory 123 | homeDir, err := os.UserHomeDir() 124 | if err != nil { 125 | return "united-states-dollar" 126 | } 127 | 128 | // Check if metadta file exists 129 | configPath := homeDir + "/.cryptgo-data.json" 130 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 131 | return "united-states-dollar" 132 | } 133 | 134 | // Open file 135 | configFile, err := os.Open(configPath) 136 | if err != nil { 137 | return "united-states-dollar" 138 | } 139 | 140 | // Read content 141 | err = json.NewDecoder(configFile).Decode(&metadata) 142 | if err != nil { 143 | return "united-states-dollar" 144 | } 145 | 146 | return metadata.Currency 147 | } 148 | 149 | // SaveMetadata exports favourites, currency and portfolio to disk. 150 | // Data is saved on ~/.cryptgo-data.json 151 | func SaveMetadata(favourites map[string]bool, currency string, portfolio map[string]float64) error { 152 | // Get Home directory 153 | homeDir, err := os.UserHomeDir() 154 | if err != nil { 155 | return err 156 | } 157 | 158 | // configPath and hidden path are used explicitly because we 159 | // get a permission denied error on trying to write/create 160 | // to a hidden file 161 | configPath := homeDir + "/cryptgo-data.json" 162 | hiddenPath := homeDir + "/.cryptgo-data.json" 163 | 164 | // Create data 165 | metadata := Metadata{ 166 | Favourites: favourites, 167 | Currency: currency, 168 | Portfolio: portfolio, 169 | } 170 | 171 | data, err := json.MarshalIndent(metadata, "", "\t") 172 | if err != nil { 173 | return err 174 | } 175 | 176 | // Write to file 177 | err = os.WriteFile(configPath, data, 0666) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | // Hide file 183 | err = os.Rename(configPath, hiddenPath) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /pkg/widgets/help.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package widgets 18 | 19 | import ( 20 | ui "github.com/gizak/termui/v3" 21 | ) 22 | 23 | var allKeybindings = [][]string{ 24 | {"Quit: q or "}, 25 | {""}, 26 | {"Table Navigation"}, 27 | {" - k and : up"}, 28 | {" - j and : down"}, 29 | {" - : half page up"}, 30 | {" - : half page down"}, 31 | {" - : full page up"}, 32 | {" - : full page down"}, 33 | {" - gg and : jump to top"}, 34 | {" - G and : jump to bottom"}, 35 | {" - f: focus favourites table"}, 36 | {" - F: focus coin table"}, 37 | {""}, 38 | {"Searching/Filtering"}, 39 | {" - /: Open search box"}, 40 | {" - Esc: Clear filter text"}, 41 | {""}, 42 | {"Sorting"}, 43 | {" - Use column number to sort ascending."}, 44 | {" - Use to sort descending."}, 45 | {" - Eg: 1 to sort ascending on 1st Col and F1 for descending"}, 46 | {""}, 47 | {"Actions"}, 48 | {" - c: Select Currency (from popular list)"}, 49 | {" - C: Select Currency (from full list)"}, 50 | {" - e: Add/Edit coin to Portfolio"}, 51 | {" - P: View portfolio"}, 52 | {" - s: Star, save to favourites"}, 53 | {" - S: UnStar,remove from favourites"}, 54 | {" - : View Coin Information"}, 55 | {" - %: Select Duration for Percentage Change"}, 56 | {""}, 57 | {"To close this prompt: "}, 58 | } 59 | 60 | var coinKeybindings = [][]string{ 61 | {"Quit: q or "}, 62 | {""}, 63 | {"Table Navigation"}, 64 | {" - d Change Interval Duration"}, 65 | {" - k and : up"}, 66 | {" - j and : down"}, 67 | {" - : half page up"}, 68 | {" - : half page down"}, 69 | {" - : full page up"}, 70 | {" - : full page down"}, 71 | {" - gg and : jump to top"}, 72 | {" - G and : jump to bottom"}, 73 | {" - f: focus favourites table"}, 74 | {" - F: focus interval table"}, 75 | {""}, 76 | {"Sorting"}, 77 | {" - Use column number to sort ascending."}, 78 | {" - Use to sort descending."}, 79 | {" - Eg: 1 to sort ascending on 1st Col and F1 for descending"}, 80 | {""}, 81 | {""}, 82 | {"To close this prompt: "}, 83 | } 84 | 85 | var portfolioKeybindings = [][]string{ 86 | {"Quit: q or "}, 87 | {""}, 88 | {"Table Navigation"}, 89 | {" - k and : up"}, 90 | {" - j and : down"}, 91 | {" - : half page up"}, 92 | {" - : half page down"}, 93 | {" - : full page up"}, 94 | {" - : full page down"}, 95 | {" - gg and : jump to top"}, 96 | {" - G and : jump to bottom"}, 97 | {""}, 98 | {"Sorting"}, 99 | {" - Use column number to sort ascending."}, 100 | {" - Use to sort descending."}, 101 | {" - Eg: 1 to sort ascending on 1st Col and F1 for descending"}, 102 | {""}, 103 | {"Actions"}, 104 | {" - c: Select Currency (from popular list)"}, 105 | {" - C: Select Currency (from full list)"}, 106 | {" - e: Add/Edit coin to Portfolio"}, 107 | {" - : View Coin Information"}, 108 | {""}, 109 | {"To close this prompt: "}, 110 | } 111 | 112 | // HelpMenu is a wrapper widget around a List meant 113 | // to display the help menu for a command 114 | type HelpMenu struct { 115 | *Table 116 | Keybindings [][]string 117 | } 118 | 119 | // NewHelpMenu is a constructor for the HelpMenu type 120 | func NewHelpMenu() *HelpMenu { 121 | return &HelpMenu{ 122 | Table: NewTable(), 123 | } 124 | } 125 | 126 | // Resize resizes the widget based on specified width 127 | // and height 128 | func (help *HelpMenu) Resize(termWidth, termHeight int) { 129 | textWidth := 50 130 | for _, line := range help.Keybindings { 131 | if textWidth < len(line[0]) { 132 | textWidth = len(line[0]) + 2 133 | } 134 | } 135 | textHeight := len(help.Keybindings) + 3 136 | x := (termWidth - textWidth) / 2 137 | y := (termHeight - textHeight) / 2 138 | if x < 0 { 139 | x = 0 140 | textWidth = termWidth 141 | } 142 | if y < 0 { 143 | y = 0 144 | textHeight = termHeight 145 | } 146 | 147 | help.Table.SetRect(x, y, textWidth+x, textHeight+y) 148 | } 149 | 150 | // Draw puts the required text into the widget 151 | func (help *HelpMenu) Draw(buf *ui.Buffer) { 152 | help.Table.Title = " Keybindings " 153 | help.Table.Rows = help.Keybindings 154 | help.Table.BorderStyle.Fg = ui.ColorCyan 155 | help.Table.BorderStyle.Bg = ui.ColorClear 156 | help.Table.ColResizer = func() { 157 | x := help.Table.Inner.Dx() 158 | help.Table.ColWidths = []int{x} 159 | } 160 | help.Table.Draw(buf) 161 | } 162 | 163 | // SelectHelpMenu selects the appropriate text 164 | // based on the command for which the help page 165 | // is needed 166 | func (help *HelpMenu) SelectHelpMenu(page string) { 167 | help.IsHelp = true 168 | switch page { 169 | case "ALL": 170 | help.Keybindings = allKeybindings 171 | case "COIN": 172 | help.Keybindings = coinKeybindings 173 | case "PORTFOLIO": 174 | help.Keybindings = portfolioKeybindings 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /pkg/display/coin/init.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package coin 18 | 19 | import ( 20 | "github.com/Gituser143/cryptgo/pkg/widgets" 21 | ui "github.com/gizak/termui/v3" 22 | ) 23 | 24 | // coinPage holds UI items for a coin page 25 | type coinPage struct { 26 | Grid *ui.Grid 27 | FavouritesTable *widgets.Table 28 | ValueGraph *widgets.LineGraph 29 | DetailsTable *widgets.Table 30 | ChangesTable *widgets.Table 31 | PriceBox *widgets.Table 32 | ExplorerTable *widgets.Table 33 | SupplyChart *widgets.BarChart 34 | } 35 | 36 | // newcoinPage creates, initialises and returns a pointer to an instance of coinPage 37 | func newCoinPage() *coinPage { 38 | page := &coinPage{ 39 | Grid: ui.NewGrid(), 40 | FavouritesTable: widgets.NewTable(), 41 | ValueGraph: widgets.NewLineGraph(), 42 | DetailsTable: widgets.NewTable(), 43 | ChangesTable: widgets.NewTable(), 44 | PriceBox: widgets.NewTable(), 45 | ExplorerTable: widgets.NewTable(), 46 | SupplyChart: widgets.NewBarChart(), 47 | } 48 | page.init() 49 | 50 | return page 51 | } 52 | 53 | // init initialises the widgets of an coinPage 54 | func (page *coinPage) init() { 55 | // Initialise Favourites table 56 | page.FavouritesTable.Title = " Favourites " 57 | page.FavouritesTable.BorderStyle.Fg = ui.ColorCyan 58 | page.FavouritesTable.TitleStyle.Fg = ui.ColorClear 59 | page.FavouritesTable.Header = []string{"Symbol", "Price"} 60 | page.FavouritesTable.ColResizer = func() { 61 | x := page.FavouritesTable.Inner.Dx() 62 | page.FavouritesTable.ColWidths = []int{ 63 | 4 * x / 10, 64 | 6 * x / 10, 65 | } 66 | } 67 | page.FavouritesTable.CursorColor = ui.ColorCyan 68 | 69 | // Initialise Value Graph 70 | page.ValueGraph.TitleStyle = ui.NewStyle(ui.ColorClear) 71 | page.ValueGraph.HorizontalScale = 1 72 | page.ValueGraph.LineColors["Max"] = ui.ColorGreen 73 | page.ValueGraph.LineColors["Min"] = ui.ColorRed 74 | page.ValueGraph.LineColors["Value"] = ui.ColorBlue 75 | page.ValueGraph.BorderStyle.Fg = ui.ColorCyan 76 | page.ValueGraph.Data["Max"] = []float64{} 77 | page.ValueGraph.Data["Min"] = []float64{} 78 | 79 | // Initialise Details Table 80 | page.DetailsTable.Title = " Details " 81 | page.DetailsTable.BorderStyle.Fg = ui.ColorCyan 82 | page.DetailsTable.TitleStyle.Fg = ui.ColorClear 83 | page.DetailsTable.ColResizer = func() { 84 | x := page.DetailsTable.Inner.Dx() 85 | page.DetailsTable.ColWidths = []int{ 86 | 4 * x / 10, 87 | 6 * x / 10, 88 | } 89 | } 90 | 91 | // Initialise Change Table 92 | page.ChangesTable.Title = " Changes " 93 | page.ChangesTable.BorderStyle.Fg = ui.ColorCyan 94 | page.ChangesTable.BorderStyle.Bg = ui.ColorClear 95 | page.ChangesTable.Header = []string{"Interval", "Change"} 96 | page.ChangesTable.ColResizer = func() { 97 | x := page.ChangesTable.Inner.Dx() 98 | page.ChangesTable.ColWidths = []int{ 99 | 4 * x / 10, 100 | 6 * x / 10, 101 | } 102 | } 103 | page.ChangesTable.ChangeCol[1] = true 104 | page.ChangesTable.ShowCursor = false 105 | 106 | // Initialise Price Box 107 | page.PriceBox.Title = " Live Price " 108 | page.PriceBox.BorderStyle.Fg = ui.ColorCyan 109 | page.PriceBox.TitleStyle.Fg = ui.ColorClear 110 | page.PriceBox.Header = []string{"Price", "24H High", "24H Low"} 111 | page.PriceBox.ColResizer = func() { 112 | x := page.PriceBox.Inner.Dx() 113 | page.PriceBox.ColWidths = []int{ 114 | 4 * x / 10, 115 | 3 * x / 10, 116 | 3 * x / 10, 117 | } 118 | } 119 | page.PriceBox.Rows = [][]string{{"NA", "", ""}} 120 | page.PriceBox.ColColor[1] = ui.ColorGreen 121 | page.PriceBox.ColColor[2] = ui.ColorRed 122 | 123 | // Initialise Explorer Table 124 | page.ExplorerTable.Title = " Explorers " 125 | page.ExplorerTable.BorderStyle.Fg = ui.ColorCyan 126 | page.ExplorerTable.TitleStyle.Fg = ui.ColorClear 127 | page.ExplorerTable.Header = []string{"Links"} 128 | page.ExplorerTable.ColResizer = func() { 129 | x := page.ExplorerTable.Inner.Dx() 130 | page.ExplorerTable.ColWidths = []int{x} 131 | } 132 | page.ExplorerTable.CursorColor = ui.ColorCyan 133 | 134 | // Initialise Bar Graph 135 | page.SupplyChart.Title = " Supply " 136 | page.SupplyChart.Data = []float64{0, 0} 137 | page.SupplyChart.Labels = []string{"Supply", "Max Supply"} 138 | page.SupplyChart.BorderStyle.Fg = ui.ColorCyan 139 | page.SupplyChart.TitleStyle.Fg = ui.ColorClear 140 | page.SupplyChart.BarWidth = 9 141 | page.SupplyChart.BarColors = []ui.Color{ui.ColorGreen, ui.ColorCyan} 142 | page.SupplyChart.LabelStyles = []ui.Style{ui.NewStyle(ui.ColorClear)} 143 | page.SupplyChart.NumStyles = []ui.Style{ui.NewStyle(ui.ColorBlack)} 144 | 145 | // Set Grid layout 146 | w, h := ui.TerminalDimensions() 147 | page.Grid.Set( 148 | ui.NewCol(0.33, 149 | ui.NewRow(0.5, page.FavouritesTable), 150 | ui.NewRow(0.5, page.DetailsTable), 151 | ), 152 | ui.NewCol(0.67, 153 | ui.NewRow(0.5, page.ValueGraph), 154 | ui.NewRow(0.5, 155 | ui.NewCol(0.5, 156 | ui.NewRow(0.4, page.PriceBox), 157 | ui.NewRow(0.6, page.ChangesTable), 158 | ), 159 | ui.NewCol(0.5, 160 | ui.NewRow(0.5, page.ExplorerTable), 161 | ui.NewRow(0.5, page.SupplyChart), 162 | ), 163 | ), 164 | ), 165 | ) 166 | 167 | page.Grid.SetRect(0, 0, w, h) 168 | } 169 | -------------------------------------------------------------------------------- /pkg/display/utilitywidgets/currency.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package utilitywidgets 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "strconv" 24 | 25 | "github.com/Gituser143/cryptgo/pkg/utils" 26 | "github.com/Gituser143/cryptgo/pkg/widgets" 27 | ui "github.com/gizak/termui/v3" 28 | ) 29 | 30 | // SingleCurrency holds data of a currency. Used for API fetches 31 | type SingleCurrency struct { 32 | ID string `json:"id"` 33 | Symbol string `json:"symbol"` 34 | CurrencySymbol string `json:"currencySymbol"` 35 | Type string `json:"type"` 36 | RateUSD string `json:"rateUSD"` 37 | } 38 | 39 | // AllCurrencyData holds details of currencies when all are fetched from the API 40 | type AllCurrencyData struct { 41 | Data []SingleCurrency `json:"data"` 42 | Timestamp uint `json:"timestamp"` 43 | } 44 | 45 | // CurrencyTable is a widget used to display currencyies, symbols and rates 46 | type CurrencyTable struct { 47 | *widgets.Table 48 | IDMap *CurrencyIDMap 49 | } 50 | 51 | // Currency holds information of a single currency, it used to populate currencyIDMaps 52 | type CurrencyValue struct { 53 | Symbol string 54 | RateUSD float64 55 | Type string 56 | } 57 | 58 | // CurrencyIDMap maps a currency Id to it's symbol and price in USD 59 | type CurrencyIDMap map[string]CurrencyValue 60 | 61 | // NewCurrencyIDMap creates and returns an instance of CurrencyIDMap 62 | func NewCurrencyIDMap() CurrencyIDMap { 63 | c := make(CurrencyIDMap) 64 | return c 65 | } 66 | 67 | // Populate fetches currency rates and populates the map 68 | func (c CurrencyIDMap) Populate() { 69 | url := "https://api.coincap.io/v2/rates" 70 | method := "GET" 71 | 72 | client := &http.Client{} 73 | 74 | // Create Request 75 | req, err := http.NewRequest(method, url, nil) 76 | if err != nil { 77 | return 78 | } 79 | 80 | // Send Request and get response 81 | res, err := client.Do(req) 82 | if err != nil { 83 | res.Body.Close() 84 | return 85 | } 86 | 87 | data := utils.AllCurrencyData{} 88 | 89 | // Read response 90 | err = json.NewDecoder(res.Body).Decode(&data) 91 | res.Body.Close() 92 | if err != nil { 93 | return 94 | } 95 | 96 | // Iterate over currencies 97 | for _, curr := range data.Data { 98 | currencyID := curr.ID 99 | rate, err := strconv.ParseFloat(curr.RateUSD, 64) 100 | if err == nil { 101 | 102 | c[currencyID] = CurrencyValue{ 103 | Symbol: fmt.Sprintf("%s %s", curr.Symbol, curr.CurrencySymbol), 104 | RateUSD: rate, 105 | Type: curr.Type, 106 | } 107 | } 108 | } 109 | } 110 | 111 | // Get returns the symbol and USD rate for a given currency ID 112 | // If the given currency ID does not exist in the Map, values 113 | // for US Dollar are returned 114 | func (c *CurrencyTable) Get(currencyID string) (string, string, float64) { 115 | if val, ok := (*c.IDMap)[currencyID]; ok { 116 | return currencyID, val.Symbol, val.RateUSD 117 | } 118 | 119 | return "united-states-dollar", "USD $", 1 120 | 121 | } 122 | 123 | // NewCurrencyPage creates, initialises and returns a pointer to an instance of CurrencyTable 124 | func NewCurrencyPage() *CurrencyTable { 125 | idMap := NewCurrencyIDMap() 126 | idMap.Populate() 127 | 128 | c := &CurrencyTable{ 129 | Table: widgets.NewTable(), 130 | IDMap: &idMap, 131 | } 132 | 133 | c.Table.Title = " Select Currency " 134 | c.Table.Header = []string{"Currency", "Symbol", "Type", "USD rate"} 135 | c.Table.CursorColor = ui.ColorCyan 136 | c.Table.ShowCursor = true 137 | c.Table.ColWidths = []int{5, 5, 5, 5} 138 | c.Table.ColResizer = func() { 139 | x := c.Table.Inner.Dx() 140 | c.Table.ColWidths = []int{ 141 | 4 * x / 10, 142 | 2 * x / 10, 143 | 2 * x / 10, 144 | 2 * x / 10, 145 | } 146 | } 147 | 148 | return c 149 | } 150 | 151 | // Resize resizes the Currency Table as per given terminal dimensions 152 | func (c *CurrencyTable) Resize(termWidth, termHeight int) { 153 | textWidth := 80 154 | 155 | textHeight := len(c.Table.Rows) + 3 156 | x := (termWidth - textWidth) / 2 157 | y := (termHeight - textHeight) / 2 158 | if x < 0 { 159 | x = 0 160 | textWidth = termWidth 161 | } 162 | if y < 0 { 163 | y = 0 164 | textHeight = termHeight 165 | } 166 | 167 | c.Table.SetRect(x, y, textWidth+x, textHeight+y) 168 | } 169 | 170 | // Draw puts the required text into the widget 171 | func (c *CurrencyTable) Draw(buf *ui.Buffer) { 172 | if len(c.Table.Rows) == 0 { 173 | c.Table.Title = " Unable to fetch currencies, please close and retry " 174 | } else { 175 | c.Table.Title = " Select Currency " 176 | } 177 | c.Table.Draw(buf) 178 | } 179 | 180 | // UpdateRows fetches rates of all currencies and updates them as rows in the table 181 | func (c *CurrencyTable) UpdateRows(allCurrencies bool) { 182 | currencies := map[string]bool{ 183 | "united-states-dollar": true, 184 | "euro": true, 185 | "japanese-yen": true, 186 | "british-pound-sterling": true, 187 | "indian-rupee": true, 188 | "australian-dollar": true, 189 | "canadian-dollar": true, 190 | "chinese-yuan-renminbi": true, 191 | } 192 | 193 | c.IDMap.Populate() 194 | 195 | rows := make([][]string, 0) 196 | 197 | if allCurrencies { 198 | // Iterate over all currencies 199 | for currencyID, currency := range *c.IDMap { 200 | // Aggregate data 201 | row := []string{ 202 | currencyID, 203 | currency.Symbol, 204 | currency.Type, 205 | fmt.Sprintf("%.4f", currency.RateUSD), 206 | } 207 | 208 | rows = append(rows, row) 209 | } 210 | } else { 211 | // Iterate over selected currencies 212 | for currencyID := range currencies { 213 | currency := (*c.IDMap)[currencyID] 214 | // Aggregate data 215 | row := []string{ 216 | currencyID, 217 | currency.Symbol, 218 | currency.Type, 219 | fmt.Sprintf("%.4f", currency.RateUSD), 220 | } 221 | 222 | rows = append(rows, row) 223 | } 224 | } 225 | 226 | // Update table rows and sort alphabetically 227 | c.Table.Rows = rows 228 | utils.SortData(c.Table.Rows, 0, true, "CURRENCY") 229 | } 230 | -------------------------------------------------------------------------------- /pkg/widgets/table.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package widgets 18 | 19 | import ( 20 | "fmt" 21 | "image" 22 | "log" 23 | "strings" 24 | 25 | "github.com/Gituser143/cryptgo/pkg/utils" 26 | ui "github.com/gizak/termui/v3" 27 | ) 28 | 29 | // Table represents a custom table widget 30 | type Table struct { 31 | *ui.Block 32 | 33 | Header []string 34 | Rows [][]string 35 | 36 | // Different Styles for Header and Rows 37 | HeaderStyle ui.Style 38 | RowStyle ui.Style 39 | 40 | ColWidths []int 41 | ColGap int 42 | PadLeft int 43 | 44 | ShowCursor bool 45 | CursorColor ui.Color 46 | 47 | ShowLocation bool 48 | 49 | UniqueCol int // the column used to uniquely identify each table row 50 | SelectedItem string // used to keep the cursor on the correct item if the data changes 51 | SelectedRow int 52 | TopRow int // used to indicate where in the table we are scrolled at 53 | ChangeCol map[int]bool // Used to set if a column represents change 54 | ColColor map[int]ui.Color // Set a custom colour to a column 55 | ColResizer func() 56 | 57 | IsHelp bool 58 | } 59 | 60 | // NewTable returns a new Table instance 61 | func NewTable() *Table { 62 | return &Table{ 63 | Block: ui.NewBlock(), 64 | HeaderStyle: ui.NewStyle(ui.ColorClear, ui.ColorClear, ui.ModifierBold), 65 | RowStyle: ui.NewStyle(ui.Theme.Default.Fg), 66 | SelectedRow: 0, 67 | TopRow: 0, 68 | UniqueCol: 0, 69 | ColResizer: func() {}, 70 | ChangeCol: make(map[int]bool), 71 | ColColor: make(map[int]ui.Color), 72 | CursorColor: ui.ColorCyan, 73 | } 74 | } 75 | 76 | // Draw helps draw the Table onto the UI 77 | func (t *Table) Draw(buf *ui.Buffer) { 78 | t.Block.Draw(buf) 79 | 80 | if t.ShowLocation { 81 | t.drawLocation(buf) 82 | } 83 | 84 | t.ColResizer() 85 | 86 | // finds exact column starting position 87 | colXPos := []int{} 88 | cur := 1 + t.PadLeft 89 | for _, w := range t.ColWidths { 90 | colXPos = append(colXPos, cur) 91 | cur += w 92 | cur += t.ColGap 93 | } 94 | 95 | // prints header 96 | for i, h := range t.Header { 97 | width := t.ColWidths[i] 98 | if width == 0 { 99 | continue 100 | } 101 | // don't render column if it doesn't fit in widget 102 | if width > (t.Inner.Dx()-colXPos[i])+1 { 103 | continue 104 | } 105 | buf.SetString( 106 | h, 107 | t.HeaderStyle, 108 | image.Pt(t.Inner.Min.X+colXPos[i]-1, t.Inner.Min.Y), 109 | ) 110 | } 111 | 112 | if t.TopRow < 0 { 113 | log.Printf("table widget TopRow value less than 0. TopRow: %v", t.TopRow) 114 | return 115 | } 116 | // prints each row 117 | for rowNum := t.TopRow; rowNum < t.TopRow+t.Inner.Dy()-1 && rowNum < len(t.Rows); rowNum++ { 118 | row := t.Rows[rowNum] 119 | y := (rowNum + 2) - t.TopRow 120 | // prints cursor 121 | style := t.RowStyle 122 | if t.IsHelp { 123 | if len(t.Rows[rowNum][0]) > 0 && string(t.Rows[rowNum][0][0]) != " " { 124 | style = t.HeaderStyle 125 | } 126 | } 127 | if t.ShowCursor { 128 | if (t.SelectedItem == "" && rowNum == t.SelectedRow) || (t.SelectedItem != "" && t.SelectedItem == row[t.UniqueCol]) { 129 | style.Fg = t.CursorColor 130 | style.Modifier = ui.ModifierReverse 131 | for _, width := range t.ColWidths { 132 | if width == 0 { 133 | continue 134 | } 135 | buf.SetString( 136 | strings.Repeat(" ", t.Inner.Dx()), 137 | style, 138 | image.Pt(t.Inner.Min.X, t.Inner.Min.Y+y-1), 139 | ) 140 | } 141 | t.SelectedItem = row[t.UniqueCol] 142 | t.SelectedRow = rowNum 143 | } 144 | } 145 | // prints each col of the row 146 | tempFgColor := style.Fg 147 | tempBgColor := style.Bg 148 | for i, width := range t.ColWidths { 149 | style.Fg = tempFgColor 150 | style.Bg = tempBgColor 151 | // Change Foreground color if the column number is in the ColColor list 152 | if _, ok := t.ChangeCol[i]; ok { 153 | if rowNum == t.SelectedRow && t.ShowCursor { 154 | style.Fg = t.CursorColor 155 | } else { 156 | rowData := strings.Split(t.Rows[rowNum][i], " ") 157 | style.Fg = ui.ColorGreen 158 | if string(rowData[0]) == utils.DownArrow { 159 | style.Fg = ui.ColorRed 160 | } 161 | } 162 | } else if val, ok := t.ColColor[i]; ok { 163 | if rowNum == t.SelectedRow && t.ShowCursor { 164 | style.Fg = t.CursorColor 165 | } else { 166 | style.Fg = val 167 | } 168 | } 169 | if width == 0 { 170 | continue 171 | } 172 | // don't render column if width is greater than distance to end of widget 173 | if width > (t.Inner.Dx()-colXPos[i])+1 { 174 | continue 175 | } 176 | r := ui.TrimString(row[i], width) 177 | buf.SetString( 178 | r, 179 | style, 180 | image.Pt(t.Inner.Min.X+colXPos[i]-1, t.Inner.Min.Y+y-1), 181 | ) 182 | } 183 | } 184 | } 185 | 186 | func (t *Table) drawLocation(buf *ui.Buffer) { 187 | total := len(t.Rows) 188 | topRow := t.TopRow + 1 189 | bottomRow := t.TopRow + t.Inner.Dy() - 1 190 | if bottomRow > total { 191 | bottomRow = total 192 | } 193 | 194 | loc := fmt.Sprintf(" %d - %d of %d ", topRow, bottomRow, total) 195 | 196 | width := len(loc) 197 | buf.SetString(loc, t.TitleStyle, image.Pt(t.Max.X-width-2, t.Min.Y)) 198 | } 199 | 200 | // Scrolling /////////////////////////////////////////////////////////////////// 201 | 202 | // calcPos is used to calculate the cursor position and the current view into the table. 203 | func (t *Table) calcPos() { 204 | t.SelectedItem = "" 205 | 206 | if t.SelectedRow < 0 { 207 | t.SelectedRow = 0 208 | } 209 | if t.SelectedRow < t.TopRow { 210 | t.TopRow = t.SelectedRow 211 | } 212 | 213 | if t.SelectedRow > len(t.Rows)-1 { 214 | t.SelectedRow = len(t.Rows) - 1 215 | } 216 | if t.SelectedRow > t.TopRow+(t.Inner.Dy()-2) { 217 | t.TopRow = t.SelectedRow - (t.Inner.Dy() - 2) 218 | } 219 | } 220 | 221 | // ScrollUp moves the cursor one position upwards 222 | func (t *Table) ScrollUp() { 223 | t.SelectedRow-- 224 | t.calcPos() 225 | } 226 | 227 | // ScrollDown moves the cursor one position downwards 228 | func (t *Table) ScrollDown() { 229 | t.SelectedRow++ 230 | t.calcPos() 231 | } 232 | 233 | // ScrollTop moves the cursor to the top 234 | func (t *Table) ScrollTop() { 235 | t.SelectedRow = 0 236 | t.calcPos() 237 | } 238 | 239 | // ScrollBottom moves the cursor to the bottom 240 | func (t *Table) ScrollBottom() { 241 | t.SelectedRow = len(t.Rows) - 1 242 | t.calcPos() 243 | } 244 | 245 | // ScrollHalfPageUp moves the cursor half a page up 246 | func (t *Table) ScrollHalfPageUp() { 247 | t.SelectedRow = t.SelectedRow - (t.Inner.Dy()-2)/2 248 | t.calcPos() 249 | } 250 | 251 | // ScrollHalfPageDown moves the cursor half a page down 252 | func (t *Table) ScrollHalfPageDown() { 253 | t.SelectedRow = t.SelectedRow + (t.Inner.Dy()-2)/2 254 | t.calcPos() 255 | } 256 | 257 | // ScrollPageUp moves the cursor a page up 258 | func (t *Table) ScrollPageUp() { 259 | t.SelectedRow -= (t.Inner.Dy() - 2) 260 | t.calcPos() 261 | } 262 | 263 | // ScrollPageDown moves the cursor a page down 264 | func (t *Table) ScrollPageDown() { 265 | t.SelectedRow += (t.Inner.Dy() - 2) 266 | t.calcPos() 267 | } 268 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cryptgo 2 | ======= 3 | 4 | [![Go Build](https://github.com/Gituser143/cryptgo/actions/workflows/go.yml/badge.svg)](https://github.com/Gituser143/cryptgo/actions/workflows/go.yml) [![Go License Check](https://github.com/Gituser143/cryptgo/actions/workflows/license_check.yml/badge.svg)](https://github.com/Gituser143/cryptgo/actions/workflows/license_check.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/gituser143/cryptgo)](https://goreportcard.com/report/github.com/gituser143/cryptgo) [![Go Reference](https://pkg.go.dev/badge/github.com/Gituser143/cryptgo.svg)](https://pkg.go.dev/github.com/Gituser143/cryptgo) ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/bhargavsnv/cryptgo?label=Docker%20Version&logo=Docker&sort=semver) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 5 | 6 | ### Latest Stable Version: ![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/gituser143/cryptgo?label=Version&sort=semver) 7 | 8 | Crytpgo is a TUI based application written purely in Go to monitor and observe cryptocurrency prices in real time! 9 | 10 | ![logo](images/logo.png) 11 | 12 | Installation 13 | ============ 14 | 15 | Using Go 16 | -------- 17 | 18 | ```bash 19 | go get -u github.com/Gituser143/cryptgo 20 | ``` 21 | 22 | Using Docker 23 | ------------ 24 | 25 | ### Running Image 26 | 27 | Set `VERSION` to a specific version for stable builds. Omitting `VERSION` uses the latest stable version or setting `main` as version provides the latest (in development) version. 28 | 29 | ```bash 30 | # Pull Image 31 | make docker-pull 32 | 33 | # Pull specific version of image 34 | VERSION=v1.1.1 make docker-pull 35 | 36 | # Run Image 37 | make docker-run 38 | 39 | # Run specific version of image 40 | VERSION=v1.1.1 make docker-run 41 | 42 | # Run image with portfolio command 43 | ARG=portfolio make docker-run 44 | 45 | # Run image with help command 46 | ARG=help make docker-run 47 | 48 | ``` 49 | 50 | Optionally, an alias can be created for ease of use: 51 | 52 | ```bash 53 | alias cryptgo='docker run -u $(id -u):$(id -g) -v "$HOME:/home/appuser/" --rm -it bhargavsnv/cryptgo' 54 | ``` 55 | 56 | And run using: 57 | 58 | ```bash 59 | cryptgo [flags] 60 | # or 61 | cryptgo [command] 62 | ``` 63 | 64 | ### Building Image locally 65 | 66 | ```bash 67 | # Clone the repository 68 | git clone https://github.com/Gituser143/cryptgo 69 | 70 | # Navigate into repository 71 | cd cryptgo 72 | 73 | # Build image 74 | make docker-build 75 | 76 | # Run image 77 | make docker-run 78 | ``` 79 | 80 | From Source: 81 | ------------ 82 | 83 | Building requires [Go](https://golang.org) to be installed. 84 | 85 | ```bash 86 | # Clone the repository 87 | git clone https://github.com/Gituser143/cryptgo 88 | 89 | # Navigate into repository 90 | cd cryptgo 91 | 92 | # Build executable 93 | make build 94 | ``` 95 | 96 | Make 97 | -------- 98 | ```bash 99 | # See all make targets 100 | make help 101 | ``` 102 | --- 103 | 104 | What does `cryptgo` do? 105 | ======================= 106 | 107 | Cryptgo helps you look at cryptocurrency values, details and track your crypto portfolio straight from your terminal! 108 | 109 | It makes use of the API provided by [CoinCap.io](https://coincap.io/) and [CoinGecko](https://www.coingecko.com/en) to provide the required details! 110 | 111 | Main Page 112 | --------- 113 | 114 | ![main-page](images/main-page.png) 115 | 116 | - Here, the top 3 currencies (as ranked by Market Cap) are displayed with their graphs on top. 117 | 118 | - A table is provided with relevant information about other currencies. 119 | 120 | - `cryptgo` allows you to keep track of your favourite currencies by adding them to the favourites table. 121 | 122 | - A selected coin (from either the coin table or favourites) can be further inspected in detail. 123 | 124 | ### Key-Bindings 125 | 126 | Key-bindings can be found by pressing `?`. This displays the help prompt. 127 | 128 | - **Quit**: `q` or `` 129 | - **Table Navigation** 130 | - `k` and ``: up 131 | - `j` and ``: down 132 | - ``: half page up 133 | - ``: half page down 134 | - ``: full page up 135 | - ``: full page down 136 | - `gg` and ``: jump to top 137 | - `G` and ``: jump to bottom 138 | - `f`: focus favourites table 139 | - `F`: focus interval table 140 | - **Searching/Filtering** 141 | - `/`: Open search box 142 | - `Esc`: Clear filter text 143 | - **Sorting** 144 | - Use column number to sort ascending. 145 | - Use to sort descending. 146 | - Eg: 1 to sort ascending on 1st Col and F1 for descending 147 | - **Actions (Interval Table)** 148 | - ``: Select Currency (from popular list) 149 | - ``: Select Currency (from full list) 150 | - `e`: Add/Edit coin to Portfolio 151 | - `P`: View portfolio 152 | - ``: Star, save to favourites 153 | - ``: UnStar,remove from favourites 154 | - ``: View Coin Information 155 | - `%`: Select Duration for Percentage Change 156 | 157 | Coin Page 158 | --------- 159 | 160 | ![coin-page](images/coin-page.png) 161 | 162 | - The coin page give more in depth details of a particular coin. 163 | 164 | - It can be navigated to from either the favourites or coin table. 165 | 166 | - The price history is displayed on top and can be viewed through different intervals, as provided by the Graph Interval table on the bottom left. 167 | 168 | - A live price is streamed in the price box and additional details are described in the details table. 169 | 170 | ### Key-Bindings 171 | 172 | Key-bindings can be found by pressing `?`. This displays the help prompt. 173 | 174 | - **Quit**: `q` or `` 175 | - **Table Navigation** 176 | - `k` and ``: up 177 | - `j` and ``: down 178 | - ``: half page up 179 | - ``: half page down 180 | - ``: full page up 181 | - ``: full page down 182 | - `gg` and ``: jump to top 183 | - `G` and ``: jump to bottom 184 | - `f`: focus favourites table 185 | - `F`: focus interval table 186 | - **Sorting** 187 | - Use column number to sort ascending. 188 | - Use `` to sort descending. 189 | - Eg: `1` to sort ascending on 1st Col and `F1` for descending 190 | - **Actions (Interval Table)** 191 | - ``: Set Interval 192 | - ``: Select Currency (from popular list) 193 | - ``: Select Currency (from full list) 194 | 195 | Portfolio Page 196 | -------------- 197 | 198 | ![portfolio-page](images/portfolio-page.png) 199 | 200 | - Cryptgo allows you to track your crypto portfolio through a separately defined page. 201 | 202 | - This page can be accessed with the command `cryptgo portfolio`. 203 | 204 | ### Key-Bindings 205 | 206 | - **Quit: `q` or ``** 207 | - **Table Navigation** 208 | - `k` and ``: up 209 | - `j` and ``: down 210 | - ``: half page up 211 | - ``: half page down 212 | - ``: full page up 213 | - ``: full page down 214 | - `gg` and ``: jump to top 215 | - `G` and ``: jump to bottom 216 | - **Sorting** 217 | 218 | - Use column number to sort ascending. 219 | - Use `` to sort descending. 220 | - Eg: `1` to sort ascending on 1st Col and `F1` for descending 221 | 222 | - **Actions** 223 | 224 | - `c`: Select Currency (from popular list) 225 | - `C`: Select Currency (from full list) 226 | - `e`: Add/Edit coin to Portfolio 227 | - ``: View Coin Information 228 | 229 | ### Mini Portfolio 230 | 231 | ![portfolio](images/portfolio.png) 232 | 233 | - Cryptgo also allows you to view your holdings through a mini portfolio from other pages. 234 | 235 | - Coins can be added/modified/removed by pressing `e` on a coin in the main page. (Set Holding Amount as 0 to remove) 236 | 237 | - Holdings can be modified either through the main page or through the portfolio itself. The below image shows the edit box when modifying holdings. 238 | 239 | ![edit-box](images/portfolio-edit.png) 240 | 241 | Utilities 242 | --------- 243 | 244 | ### Currency 245 | 246 | Currency need not be fixed to USD $, other currencies can be selected from either the popular currency table (press `c`) or full currency table (press `C`). 247 | 248 | #### Popular Currency Table 249 | 250 | ![currency](images/currency.png) 251 | 252 | #### Full Currency Table 253 | 254 | ![currency-full](images/currency-full.png) 255 | 256 | ### Search/Filtering 257 | 258 | Coins can be filtered/searched based on an input substring. The search/filter box can be brought up on the main page (press `/`). 259 | 260 | ![search](images/search.png) 261 | 262 | ### Change Percentage Interval 263 | 264 | The Change Percentage on the main page can be modified too. A list of durations can be viewed and selected by pressing `%`, which brings up the below table. 265 | 266 | ![change-duration](images/change-duration.png) 267 | 268 | ### Change Graph Duration 269 | 270 | The duration of the history graph on the coin page is modifiable. To list the possible durations and select one, press `d` in the coin page. that will bring up the following table. 271 | 272 | ![history-duration](images/history-duration.png) 273 | 274 | --- 275 | 276 | Contributing 277 | ============ 278 | 279 | **Pull Requests and Issues are welcome!** 280 | 281 | Please check the issues page to see available issues to work on, or to even create some! 282 | 283 | --- 284 | 285 | Credits 286 | ======= 287 | 288 | Multiple Design ideas, utilities, functions and practices have been adopted from: 289 | 290 | - [Grofer](https://github.com/pesos/grofer) 291 | 292 | - [Gotop](https://github.com/cjbassi/gotop) 293 | 294 | - [Termui](https://github.com/gizak/termui) 295 | 296 | Do check them out! 297 | -------------------------------------------------------------------------------- /pkg/api/getCoinData.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strings" 23 | "time" 24 | 25 | "github.com/Gituser143/cryptgo/pkg/utils" 26 | "github.com/gorilla/websocket" 27 | gecko "github.com/superoo7/go-gecko/v3" 28 | geckoTypes "github.com/superoo7/go-gecko/v3/types" 29 | ) 30 | 31 | // GetFavouritePrices gets coin prices for coins specified by favourites. 32 | // This data is returned on the dataChannel. 33 | func GetFavouritePrices(ctx context.Context, favourites map[string]bool, dataChannel chan CoinData) error { 34 | 35 | // Init Client 36 | geckoClient := gecko.NewClient(nil) 37 | 38 | // Set Parameters 39 | vsCurrency := "usd" 40 | order := geckoTypes.OrderTypeObject.MarketCapDesc 41 | page := 1 42 | sparkline := true 43 | priceChangePercentage := []string{} 44 | 45 | return utils.LoopTick(ctx, time.Duration(10)*time.Second, func(errChan chan error) { 46 | 47 | var finalErr error 48 | 49 | favouriteData := make(map[string]float64) 50 | 51 | defer func() { 52 | if finalErr != nil { 53 | errChan <- finalErr 54 | } 55 | }() 56 | 57 | // Get Coin IDs 58 | IDs := []string{} 59 | for id := range favourites { 60 | IDs = append(IDs, id) 61 | } 62 | 63 | perPage := len(IDs) 64 | 65 | // Fetch Data 66 | coinDataPointer, err := geckoClient.CoinsMarket(vsCurrency, IDs, order, perPage, page, sparkline, priceChangePercentage) 67 | if err != nil { 68 | finalErr = err 69 | return 70 | } 71 | 72 | // Set Prices 73 | for _, val := range *coinDataPointer { 74 | symbol := strings.ToUpper(val.Symbol) 75 | favouriteData[symbol] = val.CurrentPrice 76 | } 77 | 78 | // Aggregate data 79 | coinData := CoinData{ 80 | Type: "FAVOURITES", 81 | Favourites: favouriteData, 82 | } 83 | 84 | // Send data 85 | select { 86 | case <-ctx.Done(): 87 | finalErr = ctx.Err() 88 | return 89 | case dataChannel <- coinData: 90 | } 91 | 92 | }) 93 | } 94 | 95 | // GetCoinHistory gets price history of a coin specified by id, for an interval 96 | // received through the interval channel. 97 | // The default interval is set as 24 Hours 98 | func GetCoinHistory(ctx context.Context, id string, intervalChannel chan string, dataChannel chan CoinData) error { 99 | 100 | intervalToDuration := map[string]string{ 101 | "24hr": "1", 102 | "7d": "7", 103 | "14d": "14", 104 | "30d": "30", 105 | "90d": "90", 106 | "180d": "180", 107 | "1yr": "365", 108 | "5yr": "1825", 109 | } 110 | 111 | // Set Default Interval to 1 day 112 | i := "24hr" 113 | 114 | // Init Client 115 | geckoClient := gecko.NewClient(nil) 116 | 117 | return utils.LoopTick(ctx, time.Duration(3)*time.Second, func(errChan chan error) { 118 | var finalErr error 119 | 120 | defer func() { 121 | if finalErr != nil { 122 | errChan <- finalErr 123 | } 124 | }() 125 | 126 | select { 127 | case <-ctx.Done(): 128 | finalErr = ctx.Err() 129 | return 130 | case interval := <-intervalChannel: 131 | // Update interval 132 | i = interval 133 | default: 134 | break 135 | } 136 | 137 | // Get interval duration and fetch data 138 | intervalDuration := intervalToDuration[i] 139 | data, err := geckoClient.CoinsIDMarketChart(id, "usd", intervalDuration) 140 | if err != nil { 141 | finalErr = err 142 | return 143 | } 144 | 145 | // Aggregate price history 146 | price := []float64{} 147 | for _, v := range *data.Prices { 148 | price = append(price, float64(v[1])) 149 | } 150 | 151 | // Set max and min 152 | min := utils.MinFloat64(price...) 153 | max := utils.MaxFloat64(price...) 154 | 155 | // Clean price for graphs 156 | for i, val := range price { 157 | price[i] = val - min 158 | } 159 | 160 | // Aggregate data 161 | coinData := CoinData{ 162 | Type: "HISTORY", 163 | PriceHistory: price, 164 | MinPrice: min, 165 | MaxPrice: max, 166 | } 167 | 168 | // Send Data 169 | select { 170 | case <-ctx.Done(): 171 | finalErr = ctx.Err() 172 | return 173 | case dataChannel <- coinData: 174 | } 175 | }) 176 | } 177 | 178 | // GetCoinDetails fetches details for a coin specified by id 179 | // and sends the data on dataChannel 180 | func GetCoinDetails(ctx context.Context, id string, dataChannel chan CoinData) error { 181 | // Init client 182 | geckoClient := gecko.NewClient(nil) 183 | 184 | // Set Parameters 185 | localization := false 186 | tickers := false 187 | marketData := true 188 | communityData := false 189 | developerData := false 190 | sparkline := false 191 | 192 | return utils.LoopTick(ctx, time.Duration(10)*time.Second, func(errChan chan error) { 193 | var finalErr error 194 | 195 | defer func() { 196 | if finalErr != nil { 197 | errChan <- finalErr 198 | } 199 | }() 200 | 201 | // Fetch Data 202 | coinData, err := geckoClient.CoinsID(id, localization, tickers, marketData, communityData, developerData, sparkline) 203 | if err != nil { 204 | finalErr = err 205 | return 206 | } 207 | 208 | // Get Explorer links 209 | explorerLinks := [][]string{} 210 | for key, val := range *coinData.Links { 211 | if key == "blockchain_site" { 212 | sites := val.([]interface{}) 213 | for _, site := range sites { 214 | siteStr := site.(string) 215 | if siteStr != "" { 216 | explorerLinks = append(explorerLinks, []string{siteStr}) 217 | } 218 | } 219 | } 220 | } 221 | 222 | // Get Total Supply if coin has it 223 | totalSupply := 0.0 224 | if coinData.MarketData.TotalSupply != nil { 225 | totalSupply = *coinData.MarketData.TotalSupply 226 | } 227 | 228 | // Get Change Percents 229 | changePercents := [][]string{ 230 | {"24H", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage24h)}, 231 | {"7D", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage7d)}, 232 | {"14D", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage14d)}, 233 | {"30D", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage30d)}, 234 | {"60D", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage60d)}, 235 | {"200D", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage200d)}, 236 | {"1Y", fmt.Sprintf("%.2f", coinData.MarketData.PriceChangePercentage1y)}, 237 | } 238 | 239 | for i, row := range changePercents { 240 | change := row[1] 241 | if string(change[0]) == "-" { 242 | change = utils.DownArrow + " " + change[1:] 243 | } else { 244 | change = utils.UpArrow + " " + change 245 | } 246 | changePercents[i][1] = change 247 | } 248 | 249 | // Get ATH, ATL and Last update times 250 | timeLayout := "2006-01-02T15:04:05.000Z" 251 | tATHDate, err := time.Parse(timeLayout, coinData.MarketData.ATHDate["usd"]) 252 | if err != nil { 253 | finalErr = err 254 | return 255 | } 256 | 257 | tATLDate, err := time.Parse(timeLayout, coinData.MarketData.ATLDate["usd"]) 258 | if err != nil { 259 | finalErr = err 260 | return 261 | } 262 | 263 | tUpdate, err := time.Parse(timeLayout, coinData.LastUpdated) 264 | if err != nil { 265 | finalErr = err 266 | return 267 | } 268 | 269 | data := CoinDetails{ 270 | Name: coinData.Name, 271 | Symbol: strings.ToUpper(coinData.Symbol), 272 | Rank: fmt.Sprintf("%d", coinData.MarketCapRank), 273 | BlockTime: fmt.Sprintf("%d", coinData.BlockTimeInMin), 274 | MarketCap: coinData.MarketData.MarketCap["usd"], 275 | Website: "", 276 | Explorers: explorerLinks, 277 | ATH: coinData.MarketData.ATH["usd"], 278 | ATHDate: tATHDate.Format(time.RFC822), 279 | ATL: coinData.MarketData.ATL["usd"], 280 | ATLDate: tATLDate.Format(time.RFC822), 281 | High24: coinData.MarketData.High24["usd"], 282 | Low24: coinData.MarketData.Low24["usd"], 283 | TotalVolume: coinData.MarketData.TotalVolume["usd"], 284 | ChangePercents: changePercents, 285 | TotalSupply: totalSupply, 286 | CurrentSupply: coinData.MarketData.CirculatingSupply, 287 | LastUpdate: tUpdate.Format(time.RFC822), 288 | } 289 | 290 | // Aggregate data 291 | CoinDetails := CoinData{ 292 | Type: "DETAILS", 293 | Details: data, 294 | } 295 | 296 | // Send data 297 | select { 298 | case <-ctx.Done(): 299 | finalErr = ctx.Err() 300 | return 301 | case dataChannel <- CoinDetails: 302 | } 303 | }) 304 | } 305 | 306 | // GetLivePrice uses a websocket to stream realtime prices of a coin specified 307 | // by id. The prices are sent on the dataChannel 308 | func GetLivePrice(ctx context.Context, id string, dataChannel chan string) error { 309 | url := fmt.Sprintf("wss://ws.coincap.io/prices?assets=%s", id) 310 | c, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 311 | if err != nil { 312 | return err 313 | } 314 | defer c.Close() 315 | 316 | msg := make(map[string]string) 317 | 318 | return utils.LoopTick(ctx, time.Duration(100*time.Millisecond), func(errChan chan error) { 319 | var finalErr error 320 | 321 | // Defer panic recovery for closed websocket 322 | defer func() { 323 | if e := recover(); e != nil { 324 | finalErr = fmt.Errorf("socket read error") 325 | } 326 | }() 327 | 328 | defer func() { 329 | if finalErr != nil { 330 | errChan <- finalErr 331 | } 332 | }() 333 | 334 | err = c.ReadJSON(&msg) 335 | if err != nil { 336 | finalErr = err 337 | return 338 | } 339 | 340 | select { 341 | case <-ctx.Done(): 342 | finalErr = ctx.Err() 343 | return 344 | case dataChannel <- msg[id]: 345 | } 346 | }) 347 | } 348 | -------------------------------------------------------------------------------- /pkg/widgets/editBox.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Widget borrowed from termbox-go demo 18 | // https://github.com/nsf/termbox-go/blob/master/_demos/editbox.go 19 | 20 | package widgets 21 | 22 | import ( 23 | "fmt" 24 | "unicode/utf8" 25 | 26 | ui "github.com/gizak/termui/v3" 27 | "github.com/mattn/go-runewidth" 28 | "github.com/nsf/termbox-go" 29 | ) 30 | 31 | func tbprint(x, y int, fg, bg termbox.Attribute, msg string) { 32 | for _, c := range msg { 33 | termbox.SetCell(x, y, c, fg, bg) 34 | x += runewidth.RuneWidth(c) 35 | } 36 | } 37 | 38 | func fill(x, y, w, h int, cell termbox.Cell) { 39 | for ly := 0; ly < h; ly++ { 40 | for lx := 0; lx < w; lx++ { 41 | termbox.SetCell(x+lx, y+ly, cell.Ch, cell.Fg, cell.Bg) 42 | } 43 | } 44 | } 45 | 46 | func runeAdvanceLen(r rune, pos int) int { 47 | if r == '\t' { 48 | return tabstopLength - pos%tabstopLength 49 | } 50 | return runewidth.RuneWidth(r) 51 | } 52 | 53 | func voffsetCoffset(text []byte, boffset int) (voffset, coffset int) { 54 | text = text[:boffset] 55 | for len(text) > 0 { 56 | r, size := utf8.DecodeRune(text) 57 | text = text[size:] 58 | coffset++ 59 | voffset += runeAdvanceLen(r, voffset) 60 | } 61 | return 62 | } 63 | 64 | func byteSliceGrow(s []byte, desiredCap int) []byte { 65 | if cap(s) < desiredCap { 66 | ns := make([]byte, len(s), desiredCap) 67 | copy(ns, s) 68 | return ns 69 | } 70 | return s 71 | } 72 | 73 | func byteSliceRemove(text []byte, from, to int) []byte { 74 | size := to - from 75 | copy(text[from:], text[to:]) 76 | text = text[:len(text)-size] 77 | return text 78 | } 79 | 80 | func byteSliceInsert(text []byte, offset int, what []byte) []byte { 81 | n := len(text) + len(what) 82 | text = byteSliceGrow(text, n) 83 | text = text[:n] 84 | copy(text[offset+len(what):], text[offset:]) 85 | copy(text[offset:], what) 86 | return text 87 | } 88 | 89 | const preferredHorizontalThreshold = 5 90 | const tabstopLength = 8 91 | 92 | // EditBox helps user provide input through a text box like widget 93 | type EditBox struct { 94 | text []byte 95 | lineVoffset int 96 | cursorBoffset int // cursor offset in bytes 97 | cursorVoffset int // visual cursor offset in termbox cells 98 | cursorCoffset int // cursor offset in unicode code points 99 | } 100 | 101 | // Draw helps draw the EditBox in the given location, 'h' is not used at the moment 102 | func (eb *EditBox) Draw(x, y, w, h int) { 103 | eb.AdjustVOffset(w) 104 | 105 | const coldef = termbox.ColorDefault 106 | const colred = termbox.ColorRed 107 | 108 | fill(x, y, w, h, termbox.Cell{Ch: ' '}) 109 | 110 | t := eb.text 111 | lx := 0 112 | tabstop := 0 113 | for { 114 | rx := lx - eb.lineVoffset 115 | if len(t) == 0 { 116 | break 117 | } 118 | 119 | if lx == tabstop { 120 | tabstop += tabstopLength 121 | } 122 | 123 | if rx >= w { 124 | termbox.SetCell(x+w-1, y, arrowRight, 125 | colred, coldef) 126 | break 127 | } 128 | 129 | r, size := utf8.DecodeRune(t) 130 | if r == '\t' { 131 | for ; lx < tabstop; lx++ { 132 | rx = lx - eb.lineVoffset 133 | if rx >= w { 134 | goto next 135 | } 136 | 137 | if rx >= 0 { 138 | termbox.SetCell(x+rx, y, ' ', coldef, coldef) 139 | } 140 | } 141 | } else { 142 | if rx >= 0 { 143 | termbox.SetCell(x+rx, y, r, coldef, coldef) 144 | } 145 | lx += runewidth.RuneWidth(r) 146 | } 147 | next: 148 | t = t[size:] 149 | } 150 | 151 | if eb.lineVoffset != 0 { 152 | termbox.SetCell(x, y, arrowLeft, colred, coldef) 153 | } 154 | } 155 | 156 | // AdjustVOffset adjusts line visual offset to a proper value depending on width 157 | func (eb *EditBox) AdjustVOffset(width int) { 158 | ht := preferredHorizontalThreshold 159 | maxHorizontalThreshold := (width - 1) / 2 160 | if ht > maxHorizontalThreshold { 161 | ht = maxHorizontalThreshold 162 | } 163 | 164 | threshold := width - 1 165 | if eb.lineVoffset != 0 { 166 | threshold = width - ht 167 | } 168 | if eb.cursorVoffset-eb.lineVoffset >= threshold { 169 | eb.lineVoffset = eb.cursorVoffset + (ht - width + 1) 170 | } 171 | 172 | if eb.lineVoffset != 0 && eb.cursorVoffset-eb.lineVoffset < ht { 173 | eb.lineVoffset = eb.cursorVoffset - ht 174 | if eb.lineVoffset < 0 { 175 | eb.lineVoffset = 0 176 | } 177 | } 178 | } 179 | 180 | func (eb *EditBox) moveCursorTo(boffset int) { 181 | eb.cursorBoffset = boffset 182 | eb.cursorVoffset, eb.cursorCoffset = voffsetCoffset(eb.text, boffset) 183 | } 184 | 185 | func (eb *EditBox) runeUnderCursor() (rune, int) { 186 | return utf8.DecodeRune(eb.text[eb.cursorBoffset:]) 187 | } 188 | 189 | func (eb *EditBox) runeBeforeCursor() (rune, int) { 190 | return utf8.DecodeLastRune(eb.text[:eb.cursorBoffset]) 191 | } 192 | 193 | func (eb *EditBox) moveCursorOneRuneBackward() { 194 | if eb.cursorBoffset == 0 { 195 | return 196 | } 197 | _, size := eb.runeBeforeCursor() 198 | eb.moveCursorTo(eb.cursorBoffset - size) 199 | } 200 | 201 | func (eb *EditBox) moveCursorOneRuneForward() { 202 | if eb.cursorBoffset == len(eb.text) { 203 | return 204 | } 205 | _, size := eb.runeUnderCursor() 206 | eb.moveCursorTo(eb.cursorBoffset + size) 207 | } 208 | 209 | func (eb *EditBox) moveCursorToBeginningOfTheLine() { 210 | eb.moveCursorTo(0) 211 | } 212 | 213 | func (eb *EditBox) moveCursorToEndOfTheLine() { 214 | eb.moveCursorTo(len(eb.text)) 215 | } 216 | 217 | func (eb *EditBox) deleteRuneBackward() { 218 | if eb.cursorBoffset == 0 { 219 | return 220 | } 221 | 222 | eb.moveCursorOneRuneBackward() 223 | _, size := eb.runeUnderCursor() 224 | eb.text = byteSliceRemove(eb.text, eb.cursorBoffset, eb.cursorBoffset+size) 225 | } 226 | 227 | func (eb *EditBox) deleteRuneForward() { 228 | if eb.cursorBoffset == len(eb.text) { 229 | return 230 | } 231 | _, size := eb.runeUnderCursor() 232 | eb.text = byteSliceRemove(eb.text, eb.cursorBoffset, eb.cursorBoffset+size) 233 | } 234 | 235 | func (eb *EditBox) deleteTheRestOfTheLine() { 236 | eb.text = eb.text[:eb.cursorBoffset] 237 | } 238 | 239 | func (eb *EditBox) insertRune(r rune) { 240 | var buf [utf8.UTFMax]byte 241 | n := utf8.EncodeRune(buf[:], r) 242 | eb.text = byteSliceInsert(eb.text, eb.cursorBoffset, buf[:n]) 243 | eb.moveCursorOneRuneForward() 244 | } 245 | 246 | // Please, keep in mind that cursor depends on the value of lineVoffset, which 247 | // is being set on Draw() call, so.. call this method after Draw() one. 248 | func (eb *EditBox) cursorX() int { 249 | return eb.cursorVoffset - eb.lineVoffset 250 | } 251 | 252 | var editBox EditBox 253 | 254 | const editBoxWidth = 30 255 | 256 | func redrawAll(symbol string) { 257 | const coldef = termbox.ColorDefault 258 | termbox.Clear(coldef, coldef) 259 | w, h := termbox.Size() 260 | 261 | midy := h / 2 262 | midx := (w - editBoxWidth) / 2 263 | 264 | // unicode box drawing chars around the edit box 265 | if runewidth.EastAsianWidth { 266 | termbox.SetCell(midx-1, midy, '|', coldef, coldef) 267 | termbox.SetCell(midx+editBoxWidth, midy, '|', coldef, coldef) 268 | termbox.SetCell(midx-1, midy-1, '+', coldef, coldef) 269 | termbox.SetCell(midx-1, midy+1, '+', coldef, coldef) 270 | termbox.SetCell(midx+editBoxWidth, midy-1, '+', coldef, coldef) 271 | termbox.SetCell(midx+editBoxWidth, midy+1, '+', coldef, coldef) 272 | fill(midx, midy-1, editBoxWidth, 1, termbox.Cell{Ch: '-'}) 273 | fill(midx, midy+1, editBoxWidth, 1, termbox.Cell{Ch: '-'}) 274 | } else { 275 | termbox.SetCell(midx-1, midy, '│', coldef, coldef) 276 | termbox.SetCell(midx+editBoxWidth, midy, '│', coldef, coldef) 277 | termbox.SetCell(midx-1, midy-1, '┌', coldef, coldef) 278 | termbox.SetCell(midx-1, midy+1, '└', coldef, coldef) 279 | termbox.SetCell(midx+editBoxWidth, midy-1, '┐', coldef, coldef) 280 | termbox.SetCell(midx+editBoxWidth, midy+1, '┘', coldef, coldef) 281 | fill(midx, midy-1, editBoxWidth, 1, termbox.Cell{Ch: '─'}) 282 | fill(midx, midy+1, editBoxWidth, 1, termbox.Cell{Ch: '─'}) 283 | } 284 | 285 | editBox.Draw(midx, midy, editBoxWidth, 1) 286 | termbox.SetCursor(midx+editBox.cursorX(), midy) 287 | 288 | title := " Enter Symbol/Name of coin " 289 | if symbol != "" { 290 | title = fmt.Sprintf(" Enter Amount in %s ", symbol) 291 | } 292 | tbprint(midx, midy-1, coldef, coldef, title) 293 | tbprint(midx, midy+2, coldef, coldef, "ESC to Close") 294 | tbprint(midx, midy+3, coldef, coldef, "Enter to Save") 295 | 296 | termbox.Flush() 297 | } 298 | 299 | var arrowLeft = '←' 300 | var arrowRight = '→' 301 | 302 | func init() { 303 | if runewidth.EastAsianWidth { 304 | arrowLeft = '<' 305 | arrowRight = '>' 306 | } 307 | } 308 | 309 | // DrawEdit draws an editbox and returns input passed to the box 310 | func DrawEdit(ev <-chan ui.Event, symbol string) string { 311 | termbox.SetInputMode(termbox.InputEsc) 312 | 313 | redrawAll(symbol) 314 | defer termbox.HideCursor() 315 | for { 316 | for e := range ev { 317 | switch e.ID { 318 | case "": 319 | return "" 320 | case "": 321 | return string(editBox.text) 322 | case "": 323 | editBox.moveCursorOneRuneBackward() 324 | case "": 325 | editBox.moveCursorOneRuneForward() 326 | case ">", "": 327 | editBox.deleteRuneBackward() 328 | case "", "": 329 | editBox.deleteRuneForward() 330 | case "": 331 | editBox.insertRune('\t') 332 | case "": 333 | editBox.insertRune(' ') 334 | case "": 335 | editBox.deleteTheRestOfTheLine() 336 | case "": 337 | editBox.moveCursorToBeginningOfTheLine() 338 | case "": 339 | editBox.moveCursorToEndOfTheLine() 340 | default: 341 | if len(e.ID) == 1 && []rune(e.ID)[0] != 0 { 342 | editBox.insertRune([]rune(e.ID)[0]) 343 | } 344 | } 345 | redrawAll(symbol) 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2021 Bhargav SNV 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /pkg/display/coin/coin.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package coin 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strconv" 23 | "time" 24 | 25 | "github.com/Gituser143/cryptgo/pkg/api" 26 | uw "github.com/Gituser143/cryptgo/pkg/display/utilitywidgets" 27 | "github.com/Gituser143/cryptgo/pkg/utils" 28 | "github.com/Gituser143/cryptgo/pkg/widgets" 29 | ui "github.com/gizak/termui/v3" 30 | ) 31 | 32 | // DisplayCoin displays the per coin values and details along with a favourites table. It uses the same uiEvents channel as the root page 33 | func DisplayCoin( 34 | ctx context.Context, 35 | id string, 36 | coinIDs api.CoinIDMap, 37 | intervalChannel chan string, 38 | dataChannel chan api.CoinData, 39 | priceChannel chan string, 40 | uiEvents <-chan ui.Event) error { 41 | 42 | defer ui.Clear() 43 | 44 | // Init Coin page 45 | page := newCoinPage() 46 | 47 | // Currency table 48 | currencyWidget := uw.NewCurrencyPage() 49 | 50 | currencyID := utils.GetCurrencyID() 51 | currencyID, currency, currencyVal := currencyWidget.Get(currencyID) 52 | 53 | // variables for graph interval 54 | changeInterval := "24 Hours" 55 | changeIntervalWidget := uw.NewChangeIntervalPage() 56 | 57 | // Selection of default table 58 | selectedTable := page.ExplorerTable 59 | selectedTable.ShowCursor = true 60 | utilitySelected := uw.None 61 | 62 | // variables to sort favourites table 63 | favSortIdx := -1 64 | favSortAsc := false 65 | favHeader := []string{ 66 | "Symbol", 67 | fmt.Sprintf("Price (%s)", currency), 68 | } 69 | 70 | // Initialise portfolio 71 | favourites := utils.GetFavourites() 72 | portfolioMap := utils.GetPortfolio() 73 | defer func() { 74 | utils.SaveMetadata(favourites, currencyID, portfolioMap) 75 | }() 76 | 77 | // Initiliase Portfolio Table 78 | portfolioTable := uw.NewPortfolioPage() 79 | 80 | // Initialise help menu 81 | help := widgets.NewHelpMenu() 82 | help.SelectHelpMenu("COIN") 83 | 84 | // UpdateUI to refresh UI 85 | updateUI := func() { 86 | // Get Terminal Dimensions 87 | w, h := ui.TerminalDimensions() 88 | 89 | // Adjust Suuply chart Bar graph values 90 | page.SupplyChart.BarGap = ((w / 3) - (2 * page.SupplyChart.BarWidth)) / 2 91 | 92 | page.Grid.SetRect(0, 0, w, h) 93 | 94 | // Clear UI 95 | ui.Clear() 96 | 97 | // Render required widgets 98 | switch utilitySelected { 99 | case uw.Help: 100 | help.Resize(w, h) 101 | ui.Render(help) 102 | case uw.Portfolio: 103 | portfolioTable.Resize(w, h) 104 | ui.Render(portfolioTable) 105 | case uw.Currency: 106 | currencyWidget.Resize(w, h) 107 | ui.Render(currencyWidget) 108 | case uw.Change: 109 | changeIntervalWidget.Resize(w, h) 110 | ui.Render(changeIntervalWidget) 111 | default: 112 | ui.Render(page.Grid) 113 | } 114 | } 115 | 116 | // Render empty UI 117 | updateUI() 118 | 119 | // Create ticker to periodically refresh UI 120 | t := time.NewTicker(time.Duration(1) * time.Second) 121 | tick := t.C 122 | 123 | previousKey := "" 124 | 125 | for { 126 | select { 127 | case <-ctx.Done(): // Context cancelled, exit 128 | return ctx.Err() 129 | 130 | case e := <-uiEvents: // keyboard events 131 | switch e.ID { 132 | case "", "q", "": 133 | if utilitySelected != uw.None { 134 | utilitySelected = uw.None 135 | selectedTable = page.ExplorerTable 136 | selectedTable.ShowCursor = true 137 | updateUI() 138 | } else { 139 | return fmt.Errorf("UI Closed") 140 | } 141 | 142 | case "": 143 | updateUI() 144 | 145 | case "?": 146 | selectedTable.ShowCursor = false 147 | selectedTable = help.Table 148 | selectedTable.ShowCursor = true 149 | utilitySelected = uw.Help 150 | updateUI() 151 | 152 | case "c": 153 | if utilitySelected == uw.None { 154 | selectedTable.ShowCursor = false 155 | selectedTable = currencyWidget.Table 156 | selectedTable.ShowCursor = true 157 | currencyWidget.UpdateRows(false) 158 | utilitySelected = uw.Currency 159 | } 160 | 161 | case "C": 162 | if utilitySelected == uw.None { 163 | selectedTable.ShowCursor = false 164 | selectedTable = currencyWidget.Table 165 | selectedTable.ShowCursor = true 166 | currencyWidget.UpdateRows(true) 167 | utilitySelected = uw.Currency 168 | } 169 | 170 | case "d": 171 | if utilitySelected == uw.None { 172 | selectedTable.ShowCursor = false 173 | selectedTable = changeIntervalWidget.Table 174 | selectedTable.ShowCursor = true 175 | utilitySelected = uw.Change 176 | } 177 | 178 | case "f": 179 | if utilitySelected == uw.None { 180 | selectedTable.ShowCursor = false 181 | selectedTable = page.FavouritesTable 182 | selectedTable.ShowCursor = true 183 | } 184 | 185 | case "F": 186 | if utilitySelected == uw.None { 187 | selectedTable.ShowCursor = false 188 | selectedTable = page.ExplorerTable 189 | selectedTable.ShowCursor = true 190 | } 191 | 192 | case "P": 193 | if utilitySelected == uw.None { 194 | selectedTable.ShowCursor = false 195 | selectedTable = portfolioTable.Table 196 | selectedTable.ShowCursor = true 197 | portfolioTable.UpdateRows(portfolioMap, currency, currencyVal) 198 | utilitySelected = uw.Portfolio 199 | } 200 | 201 | // Navigations 202 | case "j", "": 203 | selectedTable.ScrollDown() 204 | 205 | case "k", "": 206 | selectedTable.ScrollUp() 207 | 208 | case "": 209 | selectedTable.ScrollHalfPageDown() 210 | 211 | case "": 212 | selectedTable.ScrollHalfPageUp() 213 | 214 | case "": 215 | selectedTable.ScrollPageDown() 216 | 217 | case "": 218 | selectedTable.ScrollPageUp() 219 | 220 | case "g": 221 | if previousKey == "g" { 222 | selectedTable.ScrollTop() 223 | } 224 | 225 | case "": 226 | selectedTable.ScrollTop() 227 | 228 | case "G", "": 229 | selectedTable.ScrollBottom() 230 | 231 | // Actions 232 | case "": 233 | switch utilitySelected { 234 | case uw.Change: 235 | // Update Graph Durations 236 | if changeIntervalWidget.SelectedRow < len(changeIntervalWidget.Rows) { 237 | row := changeIntervalWidget.Rows[changeIntervalWidget.SelectedRow] 238 | 239 | // Get newer selected duration 240 | changeInterval = row[0] 241 | newChangeInterval := uw.IntervalMap[changeInterval] 242 | 243 | // Empty current graph 244 | page.ValueGraph.Data["Value"] = []float64{} 245 | 246 | // Send Updated Interval 247 | intervalChannel <- newChangeInterval 248 | } 249 | utilitySelected = uw.None 250 | 251 | case uw.Currency: 252 | 253 | // Update Currency 254 | if currencyWidget.SelectedRow < len(currencyWidget.Rows) { 255 | row := currencyWidget.Rows[currencyWidget.SelectedRow] 256 | 257 | // Get currency and rate 258 | currencyID = row[0] 259 | currencyID, currency, currencyVal = currencyWidget.Get(currencyID) 260 | 261 | // Update currency fields 262 | favHeader[1] = fmt.Sprintf("Price (%s)", currency) 263 | } 264 | utilitySelected = uw.None 265 | } 266 | 267 | if utilitySelected == uw.None { 268 | selectedTable.ShowCursor = false 269 | selectedTable = page.ExplorerTable 270 | selectedTable.ShowCursor = true 271 | } 272 | 273 | case "e": 274 | switch utilitySelected { 275 | case uw.Portfolio: 276 | id := "" 277 | symbol := "" 278 | 279 | // Get symbol 280 | if portfolioTable.SelectedRow < len(portfolioTable.Rows) { 281 | row := portfolioTable.Rows[portfolioTable.SelectedRow] 282 | symbol = row[1] 283 | } 284 | 285 | // Get ID from symbol 286 | id = coinIDs[symbol].CoinGeckoID 287 | 288 | if id != "" { 289 | // Draw Edit Box and get new amount 290 | inputStr := widgets.DrawEdit(uiEvents, symbol) 291 | amt, err := strconv.ParseFloat(inputStr, 64) 292 | 293 | // Update amount 294 | if err == nil { 295 | if amt > 0 { 296 | portfolioMap[id] = amt 297 | } else { 298 | delete(portfolioMap, id) 299 | } 300 | } 301 | } 302 | 303 | portfolioTable.UpdateRows(portfolioMap, currency, currencyVal) 304 | } 305 | } 306 | 307 | if utilitySelected == uw.None { 308 | switch selectedTable { 309 | case page.FavouritesTable: 310 | switch e.ID { 311 | // Sort Ascending 312 | case "1", "2": 313 | idx, _ := strconv.Atoi(e.ID) 314 | favSortIdx = idx - 1 315 | page.FavouritesTable.Header = append([]string{}, favHeader...) 316 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.UpArrow 317 | favSortAsc = true 318 | utils.SortData(page.FavouritesTable.Rows, favSortIdx, favSortAsc, "FAVOURITES") 319 | 320 | // Sort Descending 321 | case "", "": 322 | page.FavouritesTable.Header = append([]string{}, favHeader...) 323 | idx, _ := strconv.Atoi(e.ID[2:3]) 324 | favSortIdx = idx - 1 325 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.DownArrow 326 | favSortAsc = false 327 | utils.SortData(page.FavouritesTable.Rows, favSortIdx, favSortAsc, "FAVOURITES") 328 | } 329 | } 330 | } 331 | 332 | updateUI() 333 | if previousKey == "g" { 334 | previousKey = "" 335 | } else { 336 | previousKey = e.ID 337 | } 338 | 339 | case data := <-priceChannel: 340 | // Update live price 341 | if data == "NA" { 342 | if utilitySelected == uw.None { 343 | page.PriceBox.Rows[0][0] = data 344 | } 345 | } else { 346 | p, _ := strconv.ParseFloat(data, 64) 347 | if utilitySelected == uw.None { 348 | page.PriceBox.Rows[0][0] = fmt.Sprintf("%.2f", p/currencyVal) 349 | ui.Render(page.PriceBox) 350 | } 351 | } 352 | 353 | case data := <-dataChannel: 354 | switch data.Type { 355 | 356 | case "FAVOURITES": 357 | // Update favorites table 358 | rows := [][]string{} 359 | for symbol, price := range data.Favourites { 360 | p := fmt.Sprintf("%.2f", price/currencyVal) 361 | rows = append(rows, []string{symbol, p}) 362 | } 363 | page.FavouritesTable.Header[1] = fmt.Sprintf("Price (%s)", currency) 364 | page.FavouritesTable.Rows = rows 365 | 366 | case "HISTORY": 367 | // Update History graph 368 | price := data.PriceHistory 369 | 370 | // Set value, min & max price 371 | page.ValueGraph.Data["Value"] = price 372 | value := (price[len(price)-1] + data.MinPrice) / currencyVal 373 | 374 | page.ValueGraph.Labels["Value"] = fmt.Sprintf("%.2f %s", value, currency) 375 | page.ValueGraph.Labels["Max"] = fmt.Sprintf("%.2f %s", data.MaxPrice/currencyVal, currency) 376 | page.ValueGraph.Labels["Min"] = fmt.Sprintf("%.2f %s", data.MinPrice/currencyVal, currency) 377 | 378 | // Update Graph title 379 | page.ValueGraph.Title = fmt.Sprintf(" Value History (%s) ", changeInterval) 380 | 381 | case "DETAILS": 382 | // Update Details table 383 | page.DetailsTable.Header = []string{"Name", data.Details.Name} 384 | 385 | marketCapVals, units := utils.RoundValues(data.Details.MarketCap, 0) 386 | marketCap := fmt.Sprintf("%.2f %s %s", marketCapVals[0]/currencyVal, units, currency) 387 | 388 | ATHVals, units := utils.RoundValues(data.Details.ATH, 0) 389 | ATH := fmt.Sprintf("%.2f %s %s", ATHVals[0]/currencyVal, units, currency) 390 | 391 | ATLVals, units := utils.RoundValues(data.Details.ATL, 0) 392 | ATL := fmt.Sprintf("%.2f %s %s", ATLVals[0]/currencyVal, units, currency) 393 | 394 | TotalVolVals, units := utils.RoundValues(data.Details.TotalVolume, 0) 395 | TotalVolume := fmt.Sprintf("%.2f %s %s", TotalVolVals[0]/currencyVal, units, currency) 396 | 397 | rows := [][]string{ 398 | {"Symbol", data.Details.Symbol}, 399 | {"Rank", data.Details.Rank}, 400 | {"BlockTime (min)", data.Details.BlockTime}, 401 | {"MarketCap", marketCap}, 402 | {"ATH", ATH}, 403 | {"ATHDate", data.Details.ATHDate}, 404 | {"ATL", ATL}, 405 | {"ATLDate", data.Details.ATLDate}, 406 | {"TotalVolume", TotalVolume}, 407 | {"LastUpdate", data.Details.LastUpdate}, 408 | } 409 | 410 | page.DetailsTable.Rows = rows 411 | 412 | // Update 24 High/Low 413 | page.PriceBox.Rows[0][1] = fmt.Sprintf("%.2f", data.Details.High24/currencyVal) 414 | page.PriceBox.Rows[0][2] = fmt.Sprintf("%.2f", data.Details.Low24/currencyVal) 415 | page.PriceBox.Title = fmt.Sprintf(" Live Price (%s) ", currency) 416 | 417 | // Get Change Percents 418 | page.ChangesTable.Rows = data.Details.ChangePercents 419 | 420 | // Get supply and Max supply 421 | supply := data.Details.CurrentSupply 422 | maxSupply := data.Details.TotalSupply 423 | 424 | supplyVals, units := utils.RoundValues(supply, maxSupply) 425 | page.SupplyChart.Data = supplyVals 426 | page.SupplyChart.Title = fmt.Sprintf(" Supply (%s) ", units) 427 | 428 | // Get Explorers 429 | page.ExplorerTable.Rows = data.Details.Explorers 430 | 431 | } 432 | 433 | // Sort favourites table 434 | if favSortIdx != -1 { 435 | utils.SortData(page.FavouritesTable.Rows, favSortIdx, favSortAsc, "FAVOURITES") 436 | 437 | if favSortAsc { 438 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.UpArrow 439 | } else { 440 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.DownArrow 441 | } 442 | } else { 443 | utils.SortData(page.FavouritesTable.Rows, 0, true, "FAVOURITES") 444 | } 445 | 446 | case <-tick: // Refresh UI 447 | updateUI() 448 | } 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /pkg/display/portfolio/portfolio.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package portfolio 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strconv" 23 | "strings" 24 | "time" 25 | 26 | "github.com/Gituser143/cryptgo/pkg/api" 27 | "github.com/Gituser143/cryptgo/pkg/display/coin" 28 | uw "github.com/Gituser143/cryptgo/pkg/display/utilitywidgets" 29 | "github.com/Gituser143/cryptgo/pkg/utils" 30 | "github.com/Gituser143/cryptgo/pkg/widgets" 31 | ui "github.com/gizak/termui/v3" 32 | "golang.org/x/sync/errgroup" 33 | ) 34 | 35 | // DisplayPortfolio serves the prtfolio page 36 | func DisplayPortfolio(ctx context.Context, dataChannel chan api.AssetData, sendData *bool) error { 37 | 38 | // Initialise UI 39 | if err := ui.Init(); err != nil { 40 | return fmt.Errorf("failed to initialise termui: %v", err) 41 | } 42 | defer ui.Close() 43 | 44 | // Initialise page 45 | page := newPortfolioPage() 46 | selectedTable := page.CoinTable 47 | utilitySelected := uw.None 48 | 49 | // Variables for CoinIDs 50 | coinIDMap := api.NewCoinIDMap() 51 | coinIDMap.Populate() 52 | 53 | // currency variables 54 | currencyWidget := uw.NewCurrencyPage() 55 | currencyID := utils.GetCurrencyID() 56 | currencyID, currency, currencyVal := currencyWidget.Get(currencyID) 57 | 58 | // get portfolio details 59 | portfolioMap := utils.GetPortfolio() 60 | 61 | // get performers map 62 | performersMap := getEmptyPerformers() 63 | 64 | // get favourites 65 | favourites := utils.GetFavourites() 66 | 67 | // Save metadata back to disk 68 | defer func() { 69 | utils.SaveMetadata(favourites, currencyID, portfolioMap) 70 | }() 71 | 72 | // Initialise help menu 73 | help := widgets.NewHelpMenu() 74 | help.SelectHelpMenu("PORTFOLIO") 75 | 76 | // Variables for sorting CoinTable 77 | coinSortIdx := -1 78 | coinSortAsc := false 79 | coinHeader := []string{ 80 | "Rank", 81 | "Symbol", 82 | fmt.Sprintf("Price (%s)", currency), 83 | "Change % (1d)", 84 | "Holding", 85 | fmt.Sprintf("Balance (%s)", currency), 86 | "Holding %", 87 | } 88 | 89 | previousKey := "" 90 | 91 | // Pause function to pause sending and receiving of data 92 | pause := func() { 93 | *sendData = !(*sendData) 94 | } 95 | 96 | // UpdateUI to refresh UI 97 | updateUI := func() { 98 | // Get Terminal Dimensions 99 | w, h := ui.TerminalDimensions() 100 | page.Grid.SetRect(0, 0, w, h) 101 | 102 | // Clear UI 103 | ui.Clear() 104 | 105 | // Render required widgets 106 | switch utilitySelected { 107 | case uw.Help: 108 | help.Resize(w, h) 109 | ui.Render(help) 110 | case uw.Currency: 111 | currencyWidget.Resize(w, h) 112 | ui.Render(currencyWidget) 113 | default: 114 | ui.Render(page.Grid) 115 | } 116 | } 117 | 118 | // Render Empty UI 119 | updateUI() 120 | 121 | // Create Channel to get keyboard events 122 | uiEvents := ui.PollEvents() 123 | 124 | // Create ticker to periodically refresh UI 125 | t := time.NewTicker(time.Duration(1) * time.Second) 126 | tick := t.C 127 | 128 | for { 129 | select { 130 | case <-ctx.Done(): // Context cancelled, exit 131 | return ctx.Err() 132 | 133 | case e := <-uiEvents: 134 | switch e.ID { 135 | 136 | // handle button events 137 | case "q", "": 138 | return fmt.Errorf("UI Closed") 139 | 140 | case "": 141 | updateUI() 142 | 143 | case "p": 144 | pause() 145 | 146 | case "?": 147 | selectedTable.ShowCursor = false 148 | selectedTable = help.Table 149 | selectedTable.ShowCursor = true 150 | utilitySelected = uw.Help 151 | updateUI() 152 | 153 | case "c": 154 | if utilitySelected == uw.None { 155 | selectedTable.ShowCursor = false 156 | selectedTable = currencyWidget.Table 157 | selectedTable.ShowCursor = true 158 | currencyWidget.UpdateRows(false) 159 | utilitySelected = uw.Currency 160 | } 161 | 162 | case "C": 163 | if utilitySelected == uw.None { 164 | selectedTable.ShowCursor = false 165 | selectedTable = currencyWidget.Table 166 | selectedTable.ShowCursor = true 167 | currencyWidget.UpdateRows(true) 168 | utilitySelected = uw.Currency 169 | } 170 | 171 | case "e": 172 | switch utilitySelected { 173 | case uw.None: 174 | id := "" 175 | symbol := "" 176 | 177 | // Get ID and symbol 178 | if selectedTable == page.CoinTable { 179 | if page.CoinTable.SelectedRow < len(page.CoinTable.Rows) { 180 | row := page.CoinTable.Rows[page.CoinTable.SelectedRow] 181 | symbol = row[1] 182 | } 183 | } 184 | 185 | coinIDs := coinIDMap[symbol] 186 | 187 | id = coinIDs.CoinGeckoID 188 | 189 | if id != "" { 190 | 191 | inputStr := widgets.DrawEdit(uiEvents, symbol) 192 | 193 | amt, err := strconv.ParseFloat(inputStr, 64) 194 | if err == nil { 195 | if amt > 0 { 196 | portfolioMap[id] = amt 197 | } else { 198 | delete(portfolioMap, id) 199 | } 200 | } 201 | } 202 | } 203 | 204 | case "": 205 | switch utilitySelected { 206 | case uw.Currency: 207 | // Update Currency 208 | if currencyWidget.SelectedRow < len(currencyWidget.Rows) { 209 | row := currencyWidget.Rows[currencyWidget.SelectedRow] 210 | 211 | // Get currency and rate 212 | currencyID = row[0] 213 | currencyID, currency, currencyVal = currencyWidget.Get(currencyID) 214 | 215 | // Update currency fields 216 | coinHeader[2] = fmt.Sprintf("Price (%s)", currency) 217 | coinHeader[5] = fmt.Sprintf("Balance (%s)", currency) 218 | } 219 | utilitySelected = uw.None 220 | 221 | case uw.None: 222 | 223 | // pause UI and data send 224 | pause() 225 | 226 | symbol := "" 227 | 228 | // Get ID and symbol 229 | if selectedTable == page.CoinTable { 230 | if page.CoinTable.SelectedRow < len(page.CoinTable.Rows) { 231 | row := page.CoinTable.Rows[page.CoinTable.SelectedRow] 232 | symbol = row[1] 233 | } 234 | } 235 | 236 | coinIDs := coinIDMap[symbol] 237 | 238 | coinCapID := coinIDs.CoinCapID 239 | coinGeckoID := coinIDs.CoinGeckoID 240 | 241 | if coinGeckoID != "" { 242 | // Create new errorgroup for coin page 243 | eg, coinCtx := errgroup.WithContext(ctx) 244 | coinDataChannel := make(chan api.CoinData) 245 | coinPriceChannel := make(chan string) 246 | intervalChannel := make(chan string) 247 | 248 | // Clear UI 249 | ui.Clear() 250 | 251 | // Serve Coin Price History 252 | eg.Go(func() error { 253 | err := api.GetCoinHistory( 254 | coinCtx, 255 | coinGeckoID, 256 | intervalChannel, 257 | coinDataChannel, 258 | ) 259 | return err 260 | }) 261 | 262 | // Serve Coin Asset data 263 | eg.Go(func() error { 264 | err := api.GetCoinDetails(coinCtx, coinGeckoID, coinDataChannel) 265 | return err 266 | }) 267 | 268 | // Serve favourite coin prices 269 | eg.Go(func() error { 270 | err := api.GetFavouritePrices(coinCtx, 271 | favourites, 272 | coinDataChannel, 273 | ) 274 | return err 275 | }) 276 | 277 | // Serve Live price of coin 278 | if coinCapID != "" { 279 | eg.Go(func() error { 280 | api.GetLivePrice(coinCtx, coinCapID, coinPriceChannel) 281 | // Send NA to indicate price is not being updated 282 | go func() { 283 | coinPriceChannel <- "NA" 284 | }() 285 | return nil 286 | }) 287 | } 288 | 289 | utils.SaveMetadata(favourites, currencyID, portfolioMap) 290 | 291 | // Serve Visuals for coin 292 | eg.Go(func() error { 293 | err := coin.DisplayCoin( 294 | coinCtx, 295 | coinGeckoID, 296 | coinIDMap, 297 | intervalChannel, 298 | coinDataChannel, 299 | coinPriceChannel, 300 | uiEvents, 301 | ) 302 | return err 303 | }) 304 | 305 | if err := eg.Wait(); err != nil { 306 | if err.Error() != "UI Closed" { 307 | // Unpause 308 | pause() 309 | return err 310 | } 311 | } 312 | 313 | currencyID = utils.GetCurrencyID() 314 | currencyID, currency, currencyVal = currencyWidget.Get(currencyID) 315 | 316 | } 317 | 318 | // unpause data send and receive 319 | pause() 320 | updateUI() 321 | utilitySelected = uw.None 322 | } 323 | 324 | if utilitySelected == uw.None { 325 | selectedTable.ShowCursor = false 326 | selectedTable = page.CoinTable 327 | selectedTable.ShowCursor = true 328 | } 329 | 330 | // Handle Navigations 331 | case "": 332 | utilitySelected = uw.None 333 | selectedTable = page.CoinTable 334 | selectedTable.ShowCursor = true 335 | updateUI() 336 | 337 | case "j", "": 338 | selectedTable.ScrollDown() 339 | 340 | case "k", "": 341 | selectedTable.ScrollUp() 342 | 343 | case "": 344 | selectedTable.ScrollHalfPageDown() 345 | 346 | case "": 347 | selectedTable.ScrollHalfPageUp() 348 | 349 | case "": 350 | selectedTable.ScrollPageDown() 351 | 352 | case "": 353 | selectedTable.ScrollPageUp() 354 | 355 | case "g": 356 | if previousKey == "g" { 357 | selectedTable.ScrollTop() 358 | } 359 | 360 | case "": 361 | selectedTable.ScrollTop() 362 | 363 | case "G", "": 364 | selectedTable.ScrollBottom() 365 | 366 | // handle sorting 367 | case "1", "2", "3", "4", "5", "6", "7": 368 | // Sort Ascending 369 | if utilitySelected == uw.None { 370 | idx, _ := strconv.Atoi(e.ID) 371 | coinSortIdx = idx - 1 372 | page.CoinTable.Header = append([]string{}, coinHeader...) 373 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.UpArrow 374 | coinSortAsc = true 375 | utils.SortData(page.CoinTable.Rows, coinSortIdx, coinSortAsc, "PORTFOLIO") 376 | } 377 | 378 | case "", "", "", "", "", "", "": 379 | // Sort Descending 380 | if utilitySelected == uw.None { 381 | page.CoinTable.Header = append([]string{}, coinHeader...) 382 | idx, _ := strconv.Atoi(e.ID[2:3]) 383 | coinSortIdx = idx - 1 384 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.DownArrow 385 | coinSortAsc = false 386 | utils.SortData(page.CoinTable.Rows, coinSortIdx, coinSortAsc, "PORTFOLIO") 387 | } 388 | } 389 | 390 | updateUI() 391 | if previousKey == "g" { 392 | previousKey = "" 393 | } else { 394 | previousKey = e.ID 395 | } 396 | 397 | case data := <-dataChannel: 398 | rows := [][]string{} 399 | 400 | // Update currency headers 401 | page.CoinTable.Header[2] = fmt.Sprintf("Price (%s)", currency) 402 | page.CoinTable.Header[5] = fmt.Sprintf("Balance (%s)", currency) 403 | 404 | // variables to calculate holding % 405 | balanceMap := map[string]float64{} 406 | portfolioTotal := 0.0 407 | durations := []string{"1h", "24h", "7d", "30d", "1y"} 408 | 409 | // Iterate over coin assets 410 | for _, val := range data.AllCoinData { 411 | // Get coins in portfolio 412 | if portfolioHolding, ok := portfolioMap[val.ID]; ok { 413 | // Get coin details 414 | price := fmt.Sprintf("%.2f", val.CurrentPrice/currencyVal) 415 | 416 | var change string 417 | percentageChange := api.GetPercentageChangeForDuration(val, "24h") 418 | if percentageChange < 0 { 419 | change = fmt.Sprintf("%s %.2f", utils.DownArrow, -percentageChange) 420 | } else { 421 | change = fmt.Sprintf("%s %.2f", utils.UpArrow, percentageChange) 422 | } 423 | 424 | rank := fmt.Sprintf("%d", val.MarketCapRank) 425 | symbol := strings.ToUpper(val.Symbol) 426 | holding := fmt.Sprintf("%.5f", portfolioHolding) 427 | balanceFloat := val.CurrentPrice / currencyVal * portfolioHolding 428 | balance := fmt.Sprintf("%.2f", balanceFloat) 429 | 430 | // Aggregate data 431 | rows = append(rows, []string{ 432 | rank, 433 | symbol, 434 | price, 435 | change, 436 | holding, 437 | balance, 438 | "holdingPercent", // calculated after total balance is calculated 439 | }) 440 | 441 | // Calculate portfolio total 442 | portfolioTotal += balanceFloat 443 | 444 | // Keep track of a coin's balance 445 | balanceMap[symbol] = balanceFloat 446 | 447 | // Calculate best and worst performers 448 | for _, duration := range durations { 449 | val := api.GetPercentageChangeForDuration(val, duration) 450 | 451 | if val > performersMap[duration].BestVal { 452 | performersMap[duration] = performer{ 453 | BestVal: val, 454 | BestCoin: symbol, 455 | WorstVal: performersMap[duration].WorstVal, 456 | WorstCoin: performersMap[duration].WorstCoin, 457 | } 458 | } 459 | 460 | if val < performersMap[duration].WorstVal { 461 | performersMap[duration] = performer{ 462 | BestVal: performersMap[duration].BestVal, 463 | BestCoin: performersMap[duration].BestCoin, 464 | WorstVal: val, 465 | WorstCoin: symbol, 466 | } 467 | } 468 | } 469 | } 470 | } 471 | 472 | // Update portfolio holding % values 473 | for i, row := range rows { 474 | symbol := row[1] 475 | rows[i][6] = fmt.Sprintf("%.2f", (balanceMap[symbol]/portfolioTotal)*100) 476 | } 477 | 478 | // Update coin table 479 | page.CoinTable.Rows = rows 480 | 481 | // Update details table 482 | page.DetailsTable.Header = []string{ 483 | "Balance", 484 | fmt.Sprintf("%.2f", portfolioTotal), 485 | } 486 | page.DetailsTable.Rows = [][]string{ 487 | {"Currency", currency}, 488 | {"Coins", fmt.Sprintf("%d", len(portfolioMap))}, 489 | } 490 | 491 | // Update Best Performers Table 492 | BestPerformerRows := [][]string{} 493 | WorstPerformerRows := [][]string{} 494 | 495 | // Format best and worst performer data 496 | for _, duration := range durations { 497 | change := "" 498 | if performersMap[duration].BestVal < 0 { 499 | change = fmt.Sprintf("%s %.2f", utils.DownArrow, -performersMap[duration].BestVal) 500 | } else { 501 | change = fmt.Sprintf("%s %.2f", utils.UpArrow, performersMap[duration].BestVal) 502 | } 503 | 504 | BestPerformerRows = append(BestPerformerRows, []string{ 505 | duration, 506 | performersMap[duration].BestCoin, 507 | change, 508 | }) 509 | 510 | if performersMap[duration].WorstVal < 0 { 511 | change = fmt.Sprintf("%s %.2f", utils.DownArrow, -performersMap[duration].WorstVal) 512 | } else { 513 | change = fmt.Sprintf("%s %.2f", utils.UpArrow, performersMap[duration].WorstVal) 514 | } 515 | 516 | WorstPerformerRows = append(WorstPerformerRows, []string{ 517 | duration, 518 | performersMap[duration].WorstCoin, 519 | change, 520 | }) 521 | 522 | } 523 | 524 | page.BestPerformerTable.Rows = BestPerformerRows 525 | page.WorstPerformerTable.Rows = WorstPerformerRows 526 | 527 | // Sort CoinTable data 528 | if coinSortIdx != -1 { 529 | utils.SortData(page.CoinTable.Rows, coinSortIdx, coinSortAsc, "PORTFOLIO") 530 | 531 | if coinSortAsc { 532 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.UpArrow 533 | } else { 534 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.DownArrow 535 | } 536 | } 537 | 538 | case <-tick: // Refresh UI 539 | updateUI() 540 | } 541 | } 542 | 543 | } 544 | -------------------------------------------------------------------------------- /pkg/display/allcoin/allCoin.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Bhargav SNV bhargavsnv100@gmail.com 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package allcoin 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strconv" 23 | "strings" 24 | "sync" 25 | "time" 26 | 27 | "github.com/Gituser143/cryptgo/pkg/api" 28 | "github.com/Gituser143/cryptgo/pkg/display/coin" 29 | uw "github.com/Gituser143/cryptgo/pkg/display/utilitywidgets" 30 | "github.com/Gituser143/cryptgo/pkg/utils" 31 | "github.com/Gituser143/cryptgo/pkg/widgets" 32 | ui "github.com/gizak/termui/v3" 33 | "golang.org/x/sync/errgroup" 34 | ) 35 | 36 | func filterRows(allRows [][]string, filter string, m *sync.Mutex) [][]string { 37 | filteredRows := [][]string{} 38 | 39 | m.Lock() 40 | for _, row := range allRows { 41 | symbol, name := row[1], row[5] 42 | if strings.Contains(symbol, filter) || strings.Contains(name, filter) { 43 | filteredRows = append(filteredRows, row) 44 | } 45 | } 46 | m.Unlock() 47 | 48 | return filteredRows 49 | } 50 | 51 | // DisplayAllCoins displays the main page with top coin prices, favourites and 52 | // general coin asset data 53 | func DisplayAllCoins(ctx context.Context, dataChannel chan api.AssetData, sendData *bool) error { 54 | 55 | // Initialise UI 56 | if err := ui.Init(); err != nil { 57 | return fmt.Errorf("failed to initialise termui: %v", err) 58 | } 59 | defer ui.Close() 60 | 61 | // Variables for filter/search 62 | filterStr := "" 63 | 64 | allRows := [][]string{} 65 | var rowsMutex sync.Mutex 66 | 67 | // Variables for CoinIDs 68 | coinIDMap := api.NewCoinIDMap() 69 | coinIDMap.Populate() 70 | 71 | currencyWidget := uw.NewCurrencyPage() 72 | currencyID := utils.GetCurrencyID() 73 | currencyID, currency, currencyVal := currencyWidget.Get(currencyID) 74 | 75 | // Variables for percentage change 76 | changePercent := "24h" 77 | changePercentWidget := uw.NewChangePercentPage() 78 | 79 | // Initialise page and set selected table 80 | page := newAllCoinPage() 81 | selectedTable := page.CoinTable 82 | utilitySelected := uw.None 83 | 84 | // Initialise favourites and portfolio 85 | portfolioMap := utils.GetPortfolio() 86 | favourites := utils.GetFavourites() 87 | 88 | defer func() { 89 | utils.SaveMetadata(favourites, currencyID, portfolioMap) 90 | }() 91 | 92 | // Initialise Help Menu 93 | help := widgets.NewHelpMenu() 94 | help.SelectHelpMenu("ALL") 95 | 96 | // Initiliase Portfolio Table 97 | portfolioTable := uw.NewPortfolioPage() 98 | 99 | // Variables for sorting CoinTable 100 | coinSortIdx := -1 101 | coinSortAsc := false 102 | coinHeader := []string{ 103 | "Rank", 104 | "Symbol", 105 | fmt.Sprintf("Price (%s)", currency), 106 | fmt.Sprintf("Change %%(%s)", changePercent), 107 | "Supply / MaxSupply", 108 | } 109 | 110 | // Variables for sorting FavouritesTable 111 | favSortIdx := -1 112 | favSortAsc := false 113 | favHeader := []string{ 114 | "Symbol", 115 | fmt.Sprintf("Price (%s)", currency), 116 | } 117 | 118 | previousKey := "" 119 | 120 | // Pause function to pause sending and receiving of data 121 | pause := func() { 122 | *sendData = !(*sendData) 123 | } 124 | 125 | // UpdateUI to refresh UI 126 | updateUI := func() { 127 | // Get Terminal Dimensions 128 | w, h := ui.TerminalDimensions() 129 | page.Grid.SetRect(0, 0, w, h) 130 | 131 | // Clear UI 132 | ui.Clear() 133 | 134 | // Render required widgets 135 | switch utilitySelected { 136 | case uw.Help: 137 | help.Resize(w, h) 138 | ui.Render(help) 139 | case uw.Portfolio: 140 | portfolioTable.Resize(w, h) 141 | ui.Render(portfolioTable) 142 | case uw.Currency: 143 | currencyWidget.Resize(w, h) 144 | ui.Render(currencyWidget) 145 | case uw.Change: 146 | changePercentWidget.Resize(w, h) 147 | ui.Render(changePercentWidget) 148 | default: 149 | ui.Render(page.Grid) 150 | } 151 | } 152 | 153 | // Render Empty UI 154 | updateUI() 155 | 156 | // Create Channel to get keyboard events 157 | uiEvents := ui.PollEvents() 158 | 159 | // Create ticker to periodically refresh UI 160 | t := time.NewTicker(time.Duration(1) * time.Second) 161 | tick := t.C 162 | 163 | for { 164 | select { 165 | case <-ctx.Done(): // Context cancelled, exit 166 | return ctx.Err() 167 | 168 | case e := <-uiEvents: // keyboard events 169 | // Handle Utility Selection, resize and Quit 170 | switch e.ID { 171 | case "q", "": 172 | return fmt.Errorf("UI Closed") 173 | 174 | case "": 175 | updateUI() 176 | 177 | case "p": 178 | pause() 179 | 180 | case "?": 181 | selectedTable.ShowCursor = false 182 | selectedTable = help.Table 183 | selectedTable.ShowCursor = true 184 | utilitySelected = uw.Help 185 | updateUI() 186 | 187 | case "f": 188 | if utilitySelected == uw.None { 189 | selectedTable.ShowCursor = false 190 | selectedTable = page.FavouritesTable 191 | selectedTable.ShowCursor = true 192 | } 193 | 194 | case "F": 195 | if utilitySelected == uw.None { 196 | selectedTable.ShowCursor = false 197 | selectedTable = page.CoinTable 198 | selectedTable.ShowCursor = true 199 | } 200 | 201 | case "c": 202 | if utilitySelected == uw.None { 203 | selectedTable.ShowCursor = false 204 | selectedTable = currencyWidget.Table 205 | selectedTable.ShowCursor = true 206 | currencyWidget.UpdateRows(false) 207 | utilitySelected = uw.Currency 208 | } 209 | 210 | case "C": 211 | if utilitySelected == uw.None { 212 | selectedTable.ShowCursor = false 213 | selectedTable = currencyWidget.Table 214 | selectedTable.ShowCursor = true 215 | currencyWidget.UpdateRows(true) 216 | utilitySelected = uw.Currency 217 | } 218 | 219 | case "%": 220 | if utilitySelected == uw.None { 221 | selectedTable.ShowCursor = false 222 | selectedTable = changePercentWidget.Table 223 | selectedTable.ShowCursor = true 224 | utilitySelected = uw.Change 225 | } 226 | 227 | case "P": 228 | if utilitySelected == uw.None { 229 | selectedTable.ShowCursor = false 230 | selectedTable = portfolioTable.Table 231 | selectedTable.ShowCursor = true 232 | portfolioTable.UpdateRows(portfolioMap, currency, currencyVal) 233 | utilitySelected = uw.Portfolio 234 | } 235 | 236 | // Handle Navigations 237 | case "": 238 | if utilitySelected == uw.None { 239 | filterStr = "" 240 | page.CoinTable.Title = " Coins " 241 | } 242 | utilitySelected = uw.None 243 | selectedTable = page.CoinTable 244 | selectedTable.ShowCursor = true 245 | updateUI() 246 | 247 | case "j", "": 248 | selectedTable.ScrollDown() 249 | 250 | case "k", "": 251 | selectedTable.ScrollUp() 252 | 253 | case "": 254 | selectedTable.ScrollHalfPageDown() 255 | 256 | case "": 257 | selectedTable.ScrollHalfPageUp() 258 | 259 | case "": 260 | selectedTable.ScrollPageDown() 261 | 262 | case "": 263 | selectedTable.ScrollPageUp() 264 | 265 | case "g": 266 | if previousKey == "g" { 267 | selectedTable.ScrollTop() 268 | } 269 | 270 | case "": 271 | selectedTable.ScrollTop() 272 | 273 | case "G", "": 274 | selectedTable.ScrollBottom() 275 | 276 | // Handle Actions 277 | case "e": 278 | switch utilitySelected { 279 | case uw.Portfolio: 280 | id := "" 281 | symbol := "" 282 | 283 | // Get ID and symbol 284 | if portfolioTable.SelectedRow < len(portfolioTable.Rows) { 285 | row := portfolioTable.Rows[portfolioTable.SelectedRow] 286 | symbol = row[1] 287 | } 288 | 289 | coinIDs := coinIDMap[symbol] 290 | 291 | id = coinIDs.CoinGeckoID 292 | 293 | if id != "" { 294 | inputStr := widgets.DrawEdit(uiEvents, symbol) 295 | amt, err := strconv.ParseFloat(inputStr, 64) 296 | 297 | if err == nil { 298 | if amt > 0 { 299 | portfolioMap[id] = amt 300 | } else { 301 | delete(portfolioMap, id) 302 | } 303 | } 304 | } 305 | 306 | portfolioTable.UpdateRows(portfolioMap, currency, currencyVal) 307 | 308 | case uw.None: 309 | id := "" 310 | symbol := "" 311 | 312 | // Get ID and symbol 313 | if selectedTable == page.CoinTable { 314 | if page.CoinTable.SelectedRow < len(page.CoinTable.Rows) { 315 | row := page.CoinTable.Rows[page.CoinTable.SelectedRow] 316 | symbol = row[1] 317 | } 318 | } else { 319 | if page.FavouritesTable.SelectedRow < len(page.FavouritesTable.Rows) { 320 | row := page.FavouritesTable.Rows[page.FavouritesTable.SelectedRow] 321 | symbol = row[0] 322 | } 323 | } 324 | 325 | coinIDs := coinIDMap[symbol] 326 | 327 | id = coinIDs.CoinGeckoID 328 | 329 | if id != "" { 330 | 331 | inputStr := widgets.DrawEdit(uiEvents, symbol) 332 | 333 | amt, err := strconv.ParseFloat(inputStr, 64) 334 | if err == nil { 335 | if amt > 0 { 336 | portfolioMap[id] = amt 337 | } else { 338 | delete(portfolioMap, id) 339 | } 340 | } 341 | } 342 | } 343 | 344 | case "/": 345 | switch utilitySelected { 346 | case uw.None: 347 | inputStr := widgets.DrawEdit(uiEvents, "") 348 | filterStr = strings.ToUpper(strings.Trim(inputStr, " \t\n")) 349 | page.CoinTable.Title = fmt.Sprintf(" Coins. Filter: '%s' ", filterStr) 350 | } 351 | 352 | case "": 353 | switch utilitySelected { 354 | case uw.Currency: 355 | 356 | // Update Currency 357 | if currencyWidget.SelectedRow < len(currencyWidget.Rows) { 358 | row := currencyWidget.Rows[currencyWidget.SelectedRow] 359 | 360 | // Get currency and rate 361 | currencyID = row[0] 362 | currencyID, currency, currencyVal = currencyWidget.Get(currencyID) 363 | 364 | // Update currency fields 365 | coinHeader[2] = fmt.Sprintf("Price (%s)", currency) 366 | favHeader[1] = fmt.Sprintf("Price (%s)", currency) 367 | } 368 | utilitySelected = uw.None 369 | 370 | case uw.Change: 371 | if changePercentWidget.SelectedRow < len(changePercentWidget.Rows) { 372 | row := changePercentWidget.Rows[changePercentWidget.SelectedRow] 373 | 374 | changePercent = uw.DurationMap[row[0]] 375 | 376 | coinHeader[3] = fmt.Sprintf("Change %%(%s)", changePercent) 377 | } 378 | utilitySelected = uw.None 379 | 380 | case uw.None: 381 | // pause UI and data send 382 | pause() 383 | 384 | symbol := "" 385 | 386 | // Get ID and symbol 387 | if selectedTable == page.CoinTable { 388 | if page.CoinTable.SelectedRow < len(page.CoinTable.Rows) { 389 | row := page.CoinTable.Rows[page.CoinTable.SelectedRow] 390 | symbol = row[1] 391 | } 392 | } else { 393 | if page.FavouritesTable.SelectedRow < len(page.FavouritesTable.Rows) { 394 | row := page.FavouritesTable.Rows[page.FavouritesTable.SelectedRow] 395 | symbol = row[0] 396 | } 397 | } 398 | coinIDs := coinIDMap[symbol] 399 | 400 | coinCapID := coinIDs.CoinCapID 401 | coinGeckoID := coinIDs.CoinGeckoID 402 | 403 | if coinGeckoID != "" { 404 | // Create new errorgroup for coin page 405 | eg, coinCtx := errgroup.WithContext(ctx) 406 | coinDataChannel := make(chan api.CoinData) 407 | coinPriceChannel := make(chan string) 408 | intervalChannel := make(chan string) 409 | 410 | // Clear UI 411 | ui.Clear() 412 | 413 | // Serve Coin Price History 414 | eg.Go(func() error { 415 | err := api.GetCoinHistory( 416 | coinCtx, 417 | coinGeckoID, 418 | intervalChannel, 419 | coinDataChannel, 420 | ) 421 | return err 422 | }) 423 | 424 | // Serve Coin Asset data 425 | eg.Go(func() error { 426 | err := api.GetCoinDetails(coinCtx, coinGeckoID, coinDataChannel) 427 | return err 428 | }) 429 | 430 | // Serve favourite coin prices 431 | eg.Go(func() error { 432 | err := api.GetFavouritePrices(coinCtx, 433 | favourites, 434 | coinDataChannel, 435 | ) 436 | return err 437 | }) 438 | 439 | // Serve Live price of coin 440 | if coinCapID != "" { 441 | eg.Go(func() error { 442 | api.GetLivePrice(coinCtx, coinCapID, coinPriceChannel) 443 | // Send NA to indicate price is not being updated 444 | go func() { 445 | coinPriceChannel <- "NA" 446 | }() 447 | return nil 448 | }) 449 | } 450 | 451 | utils.SaveMetadata(favourites, currencyID, portfolioMap) 452 | 453 | // Serve Visuals for coin 454 | eg.Go(func() error { 455 | err := coin.DisplayCoin( 456 | coinCtx, 457 | coinGeckoID, 458 | coinIDMap, 459 | intervalChannel, 460 | coinDataChannel, 461 | coinPriceChannel, 462 | uiEvents, 463 | ) 464 | return err 465 | }) 466 | 467 | if err := eg.Wait(); err != nil { 468 | if err.Error() != "UI Closed" { 469 | // Unpause 470 | pause() 471 | return err 472 | } 473 | } 474 | 475 | currencyID = utils.GetCurrencyID() 476 | currencyID, currency, currencyVal = currencyWidget.Get(currencyID) 477 | 478 | } 479 | 480 | // unpause data send and receive 481 | pause() 482 | updateUI() 483 | utilitySelected = uw.None 484 | } 485 | 486 | if utilitySelected == uw.None { 487 | selectedTable.ShowCursor = false 488 | selectedTable = page.CoinTable 489 | selectedTable.ShowCursor = true 490 | } 491 | 492 | case "s": 493 | if utilitySelected == uw.None { 494 | id := "" 495 | symbol := "" 496 | 497 | // Get ID and symbol 498 | if selectedTable == page.CoinTable { 499 | if page.CoinTable.SelectedRow < len(page.CoinTable.Rows) { 500 | row := page.CoinTable.Rows[page.CoinTable.SelectedRow] 501 | symbol = row[1] 502 | } 503 | } else { 504 | if page.FavouritesTable.SelectedRow < len(page.FavouritesTable.Rows) { 505 | row := page.FavouritesTable.Rows[page.FavouritesTable.SelectedRow] 506 | symbol = row[0] 507 | } 508 | } 509 | coinIDs := coinIDMap[symbol] 510 | id = coinIDs.CoinGeckoID 511 | favourites[id] = true 512 | } 513 | 514 | case "S": 515 | if utilitySelected == uw.None { 516 | id := "" 517 | symbol := "" 518 | 519 | // Get ID and symbol 520 | if selectedTable == page.CoinTable { 521 | if page.CoinTable.SelectedRow < len(page.CoinTable.Rows) { 522 | row := page.CoinTable.Rows[page.CoinTable.SelectedRow] 523 | symbol = row[1] 524 | } 525 | } else { 526 | if page.FavouritesTable.SelectedRow < len(page.FavouritesTable.Rows) { 527 | row := page.FavouritesTable.Rows[page.FavouritesTable.SelectedRow] 528 | symbol = row[0] 529 | } 530 | } 531 | 532 | coinIDs := coinIDMap[symbol] 533 | 534 | id = coinIDs.CoinGeckoID 535 | 536 | delete(favourites, id) 537 | } 538 | } 539 | 540 | if utilitySelected == uw.None { 541 | // Handle Sorting of tables 542 | switch selectedTable { 543 | case page.CoinTable: 544 | switch e.ID { 545 | // Sort Ascending 546 | case "1", "2", "3", "4": 547 | idx, _ := strconv.Atoi(e.ID) 548 | coinSortIdx = idx - 1 549 | page.CoinTable.Header = append([]string{}, coinHeader...) 550 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.UpArrow 551 | coinSortAsc = true 552 | utils.SortData(allRows, coinSortIdx, coinSortAsc, "COINS") 553 | 554 | // Sort Descending 555 | case "", "", "", "": 556 | page.CoinTable.Header = append([]string{}, coinHeader...) 557 | idx, _ := strconv.Atoi(e.ID[2:3]) 558 | coinSortIdx = idx - 1 559 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.DownArrow 560 | coinSortAsc = false 561 | utils.SortData(allRows, coinSortIdx, coinSortAsc, "COINS") 562 | } 563 | 564 | case page.FavouritesTable: 565 | switch e.ID { 566 | // Sort Ascending 567 | case "1", "2": 568 | idx, _ := strconv.Atoi(e.ID) 569 | favSortIdx = idx - 1 570 | page.FavouritesTable.Header = append([]string{}, favHeader...) 571 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.UpArrow 572 | favSortAsc = true 573 | utils.SortData(page.FavouritesTable.Rows, favSortIdx, favSortAsc, "FAVOURITES") 574 | 575 | // Sort Descending 576 | case "", "": 577 | page.FavouritesTable.Header = append([]string{}, favHeader...) 578 | idx, _ := strconv.Atoi(e.ID[2:3]) 579 | favSortIdx = idx - 1 580 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.DownArrow 581 | favSortAsc = false 582 | utils.SortData(page.FavouritesTable.Rows, favSortIdx, favSortAsc, "FAVOURITES") 583 | } 584 | } 585 | } 586 | 587 | updateUI() 588 | if previousKey == "g" { 589 | previousKey = "" 590 | } else { 591 | previousKey = e.ID 592 | } 593 | 594 | case data := <-dataChannel: 595 | 596 | // Update Top Coin data 597 | for i, v := range data.TopCoinData { 598 | // Set title to coin name 599 | page.TopCoinGraphs[i].Title = fmt.Sprintf(" %s (7D) ", data.TopCoins[i]) 600 | 601 | // Update value graphs 602 | page.TopCoinGraphs[i].Data["Value"] = v 603 | 604 | // Set value, max & min values 605 | maxValue := data.MaxPrices[i] / currencyVal 606 | minValue := data.MinPrices[i] / currencyVal 607 | // Current value is last point (cleaned) in graph + minimum value 608 | value := (v[len(v)-1] + data.MinPrices[i]) / currencyVal 609 | 610 | page.TopCoinGraphs[i].Labels["Value"] = fmt.Sprintf("%.2f %s", value, currency) 611 | page.TopCoinGraphs[i].Labels["Max"] = fmt.Sprintf("%.2f %s", maxValue, currency) 612 | page.TopCoinGraphs[i].Labels["Min"] = fmt.Sprintf("%.2f %s", minValue, currency) 613 | } 614 | 615 | favouritesData := [][]string{} 616 | 617 | // Update currency headers 618 | page.CoinTable.Header[2] = fmt.Sprintf("Price (%s)", currency) 619 | page.CoinTable.Header[3] = fmt.Sprintf("Change %%(%s)", changePercent) 620 | page.FavouritesTable.Header[1] = fmt.Sprintf("Price (%s)", currency) 621 | 622 | rowsMutex.Lock() 623 | allRows = [][]string{} 624 | 625 | // Iterate over coin assets 626 | for _, val := range data.AllCoinData { 627 | // Get coin price 628 | price := fmt.Sprintf("%.2f", val.CurrentPrice/currencyVal) 629 | 630 | // Get change % 631 | var change string 632 | percentageChange := api.GetPercentageChangeForDuration(val, changePercent) 633 | if percentageChange < 0 { 634 | change = fmt.Sprintf("%s %.2f", utils.DownArrow, -percentageChange) 635 | } else { 636 | change = fmt.Sprintf("%s %.2f", utils.UpArrow, percentageChange) 637 | } 638 | 639 | units := "" 640 | var supplyVals []float64 641 | supplyData := "" 642 | 643 | if val.CirculatingSupply != 0.00 && val.TotalSupply != 0.00 { 644 | supplyVals, units = utils.RoundValues(val.CirculatingSupply, val.TotalSupply) 645 | supplyData = fmt.Sprintf("%.2f%s / %.2f%s", supplyVals[0], units, supplyVals[1], units) 646 | } else { 647 | if val.CirculatingSupply == 0.00 { 648 | supplyVals, units = utils.RoundValues(val.CirculatingSupply, val.TotalSupply) 649 | supplyData = fmt.Sprintf("NA / %.2f%s", supplyVals[1], units) 650 | } else { 651 | supplyVals, units = utils.RoundValues(val.CirculatingSupply, val.TotalSupply) 652 | supplyData = fmt.Sprintf("%.2f%s / NA", supplyVals[0], units) 653 | } 654 | } 655 | 656 | rank := fmt.Sprintf("%d", val.MarketCapRank) 657 | 658 | // Aggregate data 659 | allRows = append(allRows, []string{ 660 | rank, 661 | strings.ToUpper(val.Symbol), 662 | price, 663 | change, 664 | supplyData, 665 | strings.ToUpper(val.Name), // not displayed, used for filter purpose 666 | }) 667 | 668 | // Aggregate favourite data 669 | if _, ok := favourites[val.ID]; ok { 670 | favouritesData = append(favouritesData, []string{ 671 | strings.ToUpper(val.Symbol), 672 | price, 673 | }) 674 | } 675 | } 676 | rowsMutex.Unlock() 677 | 678 | page.CoinTable.Rows = filterRows(allRows, filterStr, &rowsMutex) 679 | page.FavouritesTable.Rows = favouritesData 680 | 681 | // Sort CoinTable data 682 | if coinSortIdx != -1 { 683 | utils.SortData(allRows, coinSortIdx, coinSortAsc, "COINS") 684 | 685 | if coinSortAsc { 686 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.UpArrow 687 | } else { 688 | page.CoinTable.Header[coinSortIdx] = coinHeader[coinSortIdx] + " " + utils.DownArrow 689 | } 690 | } 691 | 692 | // Sort FavouritesTable Data 693 | if favSortIdx != -1 { 694 | utils.SortData(page.FavouritesTable.Rows, favSortIdx, favSortAsc, "FAVOURITES") 695 | 696 | if favSortAsc { 697 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.UpArrow 698 | } else { 699 | page.FavouritesTable.Header[favSortIdx] = favHeader[favSortIdx] + " " + utils.DownArrow 700 | } 701 | } 702 | 703 | case <-tick: // Refresh UI 704 | // Filter Data 705 | page.CoinTable.Rows = filterRows(allRows, filterStr, &rowsMutex) 706 | 707 | if *sendData { 708 | updateUI() 709 | } 710 | } 711 | } 712 | } 713 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 9 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 10 | cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= 11 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 12 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 13 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 14 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 15 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 16 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 17 | github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= 18 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 19 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 20 | github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= 21 | github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= 22 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 23 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 24 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 25 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 26 | github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= 27 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 28 | github.com/cjbassi/drawille-go v0.0.0-20190126131713-27dc511fe6fd/go.mod h1:vjcQJUZJYD3MeVGhtZXSMnCHfUNZxsyYzJt90eCYxK4= 29 | github.com/cjbassi/gotop v0.0.0-20200829004927-65d76af83079 h1:DEDTR1YNmPDyLzjwoiMngZgZahe3fyOgxtVsslLMxo4= 30 | github.com/cjbassi/gotop v0.0.0-20200829004927-65d76af83079/go.mod h1:SV2S8TDHWa4zojVB7SWMvG22/YZKW7itvFbWJGpZGNI= 31 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 32 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 33 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 34 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 35 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 36 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 37 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 38 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 40 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 42 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 43 | github.com/distatus/battery v0.9.0/go.mod h1:gGO7GxHTi1zlRT+cAj8uGG0/8HFiqAeH0TJvoipnuPs= 44 | github.com/docopt/docopt.go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:l7JNRynTRuqe45tpIyItHNqZWTxywYjp87MWTOnU5cg= 45 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 46 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 47 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 48 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 49 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 50 | github.com/gizak/termui/v3 v3.0.0/go.mod h1:uinu2dMdtMI+FTIdEFUJQT5y+KShnhQRshvPblXq3lY= 51 | github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= 52 | github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= 53 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 54 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 55 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 56 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 57 | github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= 58 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 59 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 60 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 61 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 62 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 63 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 64 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 65 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 66 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 67 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 68 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 69 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 70 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 71 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 72 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 73 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 74 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 75 | github.com/google/pprof v0.0.0-20190404155422-f8f10df84213/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 76 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 77 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 78 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 79 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 80 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 81 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 82 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 83 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 84 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 85 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 86 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 87 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 88 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 89 | github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= 90 | github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= 91 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 92 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 93 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 94 | github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 95 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 96 | github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= 97 | github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= 98 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= 99 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 100 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 101 | github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= 102 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 103 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 104 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 105 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 106 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 107 | github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= 108 | github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= 109 | github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= 110 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 111 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 112 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 113 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 114 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 115 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 116 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 117 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 118 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 119 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 120 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 121 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 122 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 123 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 124 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 125 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 126 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 127 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 128 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 129 | github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= 130 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 131 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 132 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 133 | github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 134 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 135 | github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= 136 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 137 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 138 | github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 139 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 140 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 141 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 142 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 143 | github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= 144 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 145 | github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= 146 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 147 | github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= 148 | github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= 149 | github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 150 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 151 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 152 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 153 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 154 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 155 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 156 | github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= 157 | github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 158 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 159 | github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 160 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 161 | github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= 162 | github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= 163 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 164 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 165 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 166 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 167 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 168 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 169 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 170 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 171 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 172 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 173 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 174 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 175 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 176 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 177 | github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 178 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 179 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 180 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 181 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 182 | github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 183 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 184 | github.com/shirou/gopsutil v2.18.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= 185 | github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc= 186 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 187 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 188 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 189 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 190 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 191 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 192 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 193 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 194 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 195 | github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= 196 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 197 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 198 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 199 | github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= 200 | github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= 201 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 202 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 203 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 204 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 205 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 206 | github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 207 | github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= 208 | github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 209 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 210 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 211 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 212 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 213 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 214 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 215 | github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 216 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 217 | github.com/superoo7/go-gecko v1.0.0 h1:Xa1hZu2AYSA20eVMEd4etY0fcJoEI5deja1mdRmqlpI= 218 | github.com/superoo7/go-gecko v1.0.0/go.mod h1:6AMYHL2wP2EN8AB9msPM76Lbo8L/MQOknYjvak5coaY= 219 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 220 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 221 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 222 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 223 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 224 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 225 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 226 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 227 | golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8= 228 | golang.org/x/arch v0.0.0-20190312162104-788fe5ffcd8c/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4= 229 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 230 | golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 231 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 232 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 233 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 234 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 235 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 236 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 237 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 238 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 239 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 240 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 241 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 242 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 243 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 244 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 245 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 246 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 247 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 248 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 249 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 250 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 251 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 252 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 253 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 254 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 255 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 256 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 257 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 258 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 259 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 260 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 261 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 262 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 263 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 264 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 265 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 266 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 267 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 268 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 269 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 270 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 271 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 272 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 273 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 274 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 275 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 276 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 277 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 278 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 279 | golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 280 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 281 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 282 | golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 283 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 284 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 285 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 286 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 287 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 288 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 289 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 290 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 291 | golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c= 292 | golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 293 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 294 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 295 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 296 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 297 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 298 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 299 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 300 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 301 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 302 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 303 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 304 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 305 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 306 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 307 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 308 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 309 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 310 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 311 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 312 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 313 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 314 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 315 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 316 | golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 317 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 318 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 319 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 320 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 321 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 322 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 323 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 324 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 325 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 326 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 327 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 328 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 329 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 330 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 331 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 332 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 333 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 334 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 335 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 336 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 337 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 338 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 339 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 340 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 341 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 342 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 343 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 344 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 345 | gopkg.in/h2non/gock.v1 v1.0.14 h1:fTeu9fcUvSnLNacYvYI54h+1/XEteDyHvrVCZEEEYNM= 346 | gopkg.in/h2non/gock.v1 v1.0.14/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= 347 | gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= 348 | gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 349 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 350 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 351 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 352 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 353 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 354 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 355 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 356 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 357 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 358 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 359 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 360 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 361 | howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= 362 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 363 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 364 | --------------------------------------------------------------------------------