diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..e00974a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.2] - 2018-12-30 +### Fixed +- Paginate CoinMarketCap V1 API responses due to their backward-incompatible update + +### Added +- `-clean` flag to clean cache +- `-reset` flag to clean cache and delete config +- `-config` flag to use a different specified config file + +## [1.1.1] - 2018-12-26 +### Changed +- Use go modules instead of dep + +## [1.1.0] - 2018-12-25 +### Added +- Basic portfolio functionality +- `P` keyboard shortcut to toggle portfolio view +- `e` keyboard shortcut to edit portfolio holdings +- `[portfolio]` TOML config holdings list diff --git a/Makefile b/Makefile index 385d80d..63c16fc 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,15 @@ test: cointop/test: go run main.go -test +cointop/version: + go run main.go -version + +cointop/clean: + go run main.go -clean + +cointop/reset: + go run main.go -reset + snap/clean: snapcraft clean rm -f cointop_*.snap diff --git a/README.md b/README.md index 098530e..f2241f7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ Now you can run cointop: cointop ``` +### Binary (all platforms) + +You can download the binary from the [releases](https://github.com/miguelmota/cointop/releases) page + +```bash +# replace x.x.x with the latest version +wget https://github.com/miguelmota/cointop/releases/download/x.x.x/cointop_x.x.x_linux_amd64.tar.gz +tar -xvzf cointop_x.x.x_linux_amd64.tar.gz cointop +./cointop +``` + ### Homebrew (macOS) cointop is available via [Homebrew](https://formulae.brew.sh/formula/cointop) for macOS: @@ -302,7 +313,7 @@ Key|Action m|Sort table by *[m]arket cap* M (Shift+m)|Go to middle of visible table window (vim inspired) n|Sort table by *[n]ame* -o|[o]pen link to highlighted coin on [CoinMarketCap](https://coinmarketcap.com/) +o|[o]pen link to highlighted coin (visits the API's coin page) p|Sort table by *[p]rice* P (Shift+p)|Toggle show portfolio r|Sort table by *[r]ank* @@ -468,16 +479,24 @@ Frequently asked questions: - Q: Where is the data from? - - A: The data is from [Coin Market Cap](https://coinmarketcap.com/). + - A: Currently, the data is from [CoinMarketCap](https://coinmarketcap.com/). - Q: What coins does this support? - - A: This supports any coin listed on [Coin Market Cap](https://coinmarketcap.com/). + - A: This supports any coin supported by the API being used to fetch coin information. + +- Q: Will you be supporting more coin API's in the future? + + - A: Yes supporting more coin API's is planned. - Q: How often is the data polled? - A: Data gets polled once every minute by default. You can press Ctrl+r to force refresh. +- Q: I ran cointop for the first time and don't see any data? + + - A: Running cointop for the first time will fetch the data and populate the cache which may take a few seconds. + - Q: I installed cointop without errors but the command is not found. - A: Make sure your `GOPATH` and `PATH` is set correctly. @@ -619,7 +638,7 @@ Frequently asked questions: - Q: The data isn't refreshing! - - A: The CoinMarketCap API has rate limits, so make sure to keep manual refreshes to a minimum. If you've hit the rate limit then wait about half an hour to be able to fetch the data again. Keep in mind that CoinMarketCap updates prices every 5 minutes so constant refreshes aren't necessary. + - A: The coin APIs have rate limits, so make sure to keep manual refreshes to a minimum. If you've hit the rate limit then wait about half an hour to be able to fetch the data again. Keep in mind that some coin APIs, such as CoinMarketCap, update prices every 5 minutes so constant refreshes aren't necessary. - Q: How do I quit the application? @@ -641,6 +660,10 @@ Frequently asked questions: - A: In `~/.cointop/config`, set `defaultView = "default"` +- Q: How can use a different config file other than the default? + + - A: Run `cointop -config="/path/to/config/file"` to use the specified file as the config. + - Q: I'm getting the error `open /dev/tty: no such device or address`. -A: Usually this error occurs when cointop is running as a daemon or slave which means that there is no terminal allocated, so `/dev/tty` doesn't exist for that process. Try running it with the following environment variables: @@ -649,9 +672,21 @@ Frequently asked questions: DEV_IN=/dev/stdout DEV_OUT=/dev/stdout cointop ``` +- Q: I can only view the first page, why isn't the pagination is working? + + - A: Sometimes the coin APIs will make updates and break things. If you see this problem please [submit an issue](https://github.com/miguelmota/cointop/issues/new). + +- Q: How can I delete the cache? + + - A: Run `cointop -clean` to delete the cache files. Cointop will generate new cache files after fetching data. + +- Q: How can I reset cointop? + + - A: Run `cointop -reset` to delete the config files and cache. Cointop will generate a new config when starting up. + - Q: What is the size of the binary? - - A: The executable is only ~2MB in size. + - A: The executable binary is ~6MB in size. Packed with [UPX](https://upx.github.io/) it's ~2.5MB ## Development diff --git a/cmd/cointop.go b/cmd/cointop.go new file mode 100644 index 0000000..5a6bfe2 --- /dev/null +++ b/cmd/cointop.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "flag" + "fmt" + + "github.com/miguelmota/cointop/cointop" +) + +// Run ... +func Run() { + var v, ver, test, clean, reset bool + var config string + flag.BoolVar(&v, "v", false, "Version") + flag.BoolVar(&ver, "version", false, "Version") + flag.BoolVar(&test, "test", false, "Run test") + flag.BoolVar(&clean, "clean", false, "Clean cache") + flag.BoolVar(&reset, "reset", false, "Reset config") + flag.StringVar(&config, "config", "", "Config filepath") + flag.Parse() + if v || ver { + fmt.Printf("cointop v%s", cointop.Version()) + } else if test { + doTest() + } else if clean { + cointop.Clean() + } else if reset { + cointop.Reset() + } else { + cointop.NewCointop(&cointop.Config{ + ConfigFilepath: config, + }).Run() + } +} + +func doTest() { + cointop.NewCointop(nil).Exit() +} diff --git a/cointop/cointop.go b/cointop/cointop.go index 72c2bad..f1fbee1 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -2,6 +2,7 @@ package cointop import ( "fmt" + "io/ioutil" "log" "os" "strings" @@ -56,6 +57,7 @@ type Cointop struct { actionsmap map[string]bool shortcutkeys map[string]string config config // toml config + configFilepath string searchfield *gocui.View searchfieldviewname string searchfieldvisible bool @@ -93,12 +95,25 @@ type portfolio struct { Entries map[string]*portfolioEntry } -// New initializes cointop -func New() *Cointop { +// Config config options +type Config struct { + ConfigFilepath string +} + +// NewCointop initializes cointop +func NewCointop(config *Config) *Cointop { var debug bool if os.Getenv("DEBUG") != "" { debug = true } + + configFilepath := "~/.cointop/config" + if config != nil { + if config.ConfigFilepath != "" { + configFilepath = config.ConfigFilepath + } + } + ct := &Cointop{ api: api.NewCMC(), refreshticker: time.NewTicker(1 * time.Minute), @@ -114,6 +129,7 @@ func New() *Cointop { favorites: map[string]bool{}, cache: cache.New(1*time.Minute, 2*time.Minute), debug: debug, + configFilepath: configFilepath, marketbarviewname: "market", chartviewname: "chart", chartranges: []string{ @@ -231,3 +247,43 @@ func (ct *Cointop) Run() { log.Fatalf("main loop: %v", err) } } + +// Clean ... +func Clean() { + tmpPath := "/tmp" + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + files, err := ioutil.ReadDir(tmpPath) + if err != nil { + log.Fatal(err) + } + + for _, f := range files { + if strings.HasPrefix(f.Name(), "fcache.") { + file := fmt.Sprintf("%s/%s", tmpPath, f.Name()) + fmt.Printf("removing %s\n", file) + if err := os.Remove(file); err != nil { + log.Fatal(err) + } + } + } + } + + fmt.Println("cointop cache has been cleaned") +} + +// Reset ... +func Reset() { + Clean() + + homedir := userHomeDir() + // default config path + configPath := fmt.Sprintf("%s%s", homedir, "/.cointop") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + fmt.Printf("removing %s\n", configPath) + if err := os.RemoveAll(configPath); err != nil { + log.Fatal(err) + } + } + + fmt.Println("cointop has been reset") +} diff --git a/cointop/config.go b/cointop/config.go index e37db7e..c3c71f1 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -2,7 +2,6 @@ package cointop import ( "bytes" - "fmt" "io/ioutil" "os" "strings" @@ -21,11 +20,7 @@ type config struct { } func (ct *Cointop) setupConfig() error { - err := ct.makeConfigDir() - if err != nil { - return err - } - err = ct.makeConfigFile() + err := ct.createConfigIfNotExists() if err != nil { return err } @@ -56,13 +51,26 @@ func (ct *Cointop) setupConfig() error { return nil } +func (ct *Cointop) createConfigIfNotExists() error { + err := ct.makeConfigDir() + if err != nil { + return err + } + err = ct.makeConfigFile() + if err != nil { + return err + } + return nil +} + func (ct *Cointop) configDirPath() string { - homedir := userHomeDir() - return fmt.Sprintf("%s%s", homedir, "/.cointop") + path := normalizePath(ct.configFilepath) + parts := strings.Split(path, "/") + return strings.Join(parts[0:len(parts)-1], "/") } func (ct *Cointop) configPath() string { - return fmt.Sprintf("%v%v", ct.configDirPath(), "/config") + return normalizePath(ct.configFilepath) } func (ct *Cointop) makeConfigDir() error { diff --git a/cointop/util.go b/cointop/util.go index 73a6f9f..9748e60 100644 --- a/cointop/util.go +++ b/cointop/util.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/gob" "os" + "path/filepath" "runtime" "strings" @@ -41,6 +42,15 @@ func userHomeDir() string { return os.Getenv("HOME") } +func normalizePath(path string) string { + // expand tilde + if strings.HasPrefix(path, "~/") { + path = filepath.Join(userHomeDir(), path[2:]) + } + + return path +} + func (ct *Cointop) slugify(s string) string { s = strings.ToLower(s) return s diff --git a/cointop/version.go b/cointop/version.go index aa60412..727116d 100644 --- a/cointop/version.go +++ b/cointop/version.go @@ -1,7 +1,7 @@ package cointop // TODO: make dynamic based on git tag -const version = "1.1.1" +const version = "1.1.2" func (ct *Cointop) version() string { return version diff --git a/main.go b/main.go index aa4de0a..a7da060 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,9 @@ package main import ( - "flag" - "fmt" - - "github.com/miguelmota/cointop/cointop" + "github.com/miguelmota/cointop/cmd" ) func main() { - var v, ver, test bool - flag.BoolVar(&v, "v", false, "Version") - flag.BoolVar(&ver, "version", false, "Version") - flag.BoolVar(&test, "test", false, "Run test") - flag.Parse() - if v || ver { - fmt.Printf("cointop v%s", cointop.Version()) - } else if test { - doTest() - } else { - cointop.New().Run() - } -} - -func doTest() { - cointop.New().Exit() + cmd.Run() } diff --git a/pkg/api/api.go b/pkg/api/api.go index 7fe56a0..8dc032e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,3 +13,8 @@ func NewCMC() Interface { func NewCC() { // TODO } + +// NewCG new CoinGecko API +func NewCG() { + // TODO +} diff --git a/pkg/api/impl/coingecko/coingecko.go b/pkg/api/impl/coingecko/coingecko.go new file mode 100644 index 0000000..4d1dfc3 --- /dev/null +++ b/pkg/api/impl/coingecko/coingecko.go @@ -0,0 +1,3 @@ +package coingecko + +// TODO diff --git a/pkg/api/impl/coinmarketcap/coinmarketcap.go b/pkg/api/impl/coinmarketcap/coinmarketcap.go index e29e6c8..935803c 100644 --- a/pkg/api/impl/coinmarketcap/coinmarketcap.go +++ b/pkg/api/impl/coinmarketcap/coinmarketcap.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "sync" + "time" apitypes "github.com/miguelmota/cointop/pkg/api/types" cmc "github.com/miguelmota/cointop/pkg/cmc" @@ -34,7 +35,7 @@ func (s *Service) Ping() error { return nil } -func getLimitedCoinData(convert string, offset int) (map[string]apitypes.Coin, error) { +func getLimitedCoinDataV2(convert string, offset int) (map[string]apitypes.Coin, error) { ret := make(map[string]apitypes.Coin) max := 100 coins, err := cmc.Tickers(&cmc.TickersOptions{ @@ -58,27 +59,54 @@ func getLimitedCoinData(convert string, offset int) (map[string]apitypes.Coin, e PercentChange1H: v.Quotes[convert].PercentChange1H, PercentChange24H: v.Quotes[convert].PercentChange24H, PercentChange7D: v.Quotes[convert].PercentChange7D, - Volume24H: v.Quotes[convert].Volume24H, + Volume24H: formatVolume(v.Quotes[convert].Volume24H), LastUpdated: strconv.Itoa(v.LastUpdated), } } return ret, nil } -// GetAllCoinData get all coin data -func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, error) { +func getLimitedCoinData(convert string, offset int) (map[string]apitypes.Coin, error) { ret := make(map[string]apitypes.Coin) - coins, err := cmc.V1Tickers(0, convert) + max := 100 + coins, err := cmc.Tickers(&cmc.TickersOptions{ + Convert: convert, + Start: max * offset, + Limit: max, + }) if err != nil { return ret, err } for _, v := range coins { - price := v.Quotes[convert].Price - pricestr := fmt.Sprintf("%.2f", price) - if convert == "ETH" || convert == "BTC" || price < 1 { - pricestr = fmt.Sprintf("%.5f", price) + price := formatPrice(v.Quotes[convert].Price, convert) + ret[v.Name] = apitypes.Coin{ + ID: strings.ToLower(v.Name), + Name: v.Name, + Symbol: v.Symbol, + Rank: v.Rank, + AvailableSupply: v.CirculatingSupply, + TotalSupply: v.TotalSupply, + MarketCap: v.Quotes[convert].MarketCap, + Price: price, + PercentChange1H: v.Quotes[convert].PercentChange1H, + PercentChange24H: v.Quotes[convert].PercentChange24H, + PercentChange7D: v.Quotes[convert].PercentChange7D, + Volume24H: formatVolume(v.Quotes[convert].Volume24H), + LastUpdated: strconv.Itoa(v.LastUpdated), } - price, _ = strconv.ParseFloat(pricestr, 64) + } + return ret, nil +} + +// GetAllCoinData1 get all coin data +func (s *Service) GetAllCoinData1(convert string) (map[string]apitypes.Coin, error) { + ret := make(map[string]apitypes.Coin) + coins, err := cmc.V1Tickers(0, convert) + if err != nil { + return ret, err + } + for _, v := range coins { + price := formatPrice(v.Quotes[convert].Price, convert) ret[v.Name] = apitypes.Coin{ ID: strings.ToLower(v.Name), Name: v.Name, @@ -91,7 +119,7 @@ func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, erro PercentChange1H: v.PercentChange1H, PercentChange24H: v.PercentChange24H, PercentChange7D: v.PercentChange7D, - Volume24H: v.Quotes[convert].Volume24H, + Volume24H: formatVolume(v.Quotes[convert].Volume24H), LastUpdated: strconv.Itoa(v.LastUpdated), } } @@ -105,6 +133,31 @@ func (s *Service) GetAllCoinDataV2(convert string) (map[string]apitypes.Coin, er ret := make(map[string]apitypes.Coin) var mutex sync.Mutex for i := 0; i < 5; i++ { + wg.Add(1) + go func(j int) { + defer wg.Done() + coins, err := getLimitedCoinDataV2(convert, j) + if err != nil { + return + } + mutex.Lock() + for k, v := range coins { + ret[k] = v + } + mutex.Unlock() + }(i) + } + wg.Wait() + return ret, nil +} + +// GetAllCoinData gets all coin data. Need to paginate through all pages +func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, error) { + var wg sync.WaitGroup + ret := make(map[string]apitypes.Coin) + var mutex sync.Mutex + for i := 0; i < 17; i++ { + time.Sleep(500 * time.Millisecond) wg.Add(1) go func(j int) { defer wg.Done() @@ -177,3 +230,16 @@ func (s *Service) GetGlobalMarketData(convert string) (apitypes.GlobalMarketData } return ret, nil } + +func formatPrice(price float64, convert string) float64 { + pricestr := fmt.Sprintf("%.2f", price) + if convert == "ETH" || convert == "BTC" || price < 1 { + pricestr = fmt.Sprintf("%.5f", price) + } + price, _ = strconv.ParseFloat(pricestr, 64) + return price +} + +func formatVolume(volume float64) float64 { + return float64(int64(volume)) +}