You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cointop/cointop/cointop.go

539 lines
13 KiB
Go

package cointop
import (
"errors"
"fmt"
"io/ioutil"
"os"
"strings"
"sync"
"time"
"github.com/miguelmota/cointop/pkg/api"
"github.com/miguelmota/cointop/pkg/api/types"
"github.com/miguelmota/cointop/pkg/filecache"
"github.com/miguelmota/cointop/pkg/pathutil"
"github.com/miguelmota/cointop/pkg/table"
"github.com/miguelmota/cointop/pkg/ui"
"github.com/miguelmota/gocui"
"github.com/patrickmn/go-cache"
)
// TODO: clean up and optimize codebase
// ErrInvalidAPIChoice is error for invalid API choice
var ErrInvalidAPIChoice = errors.New("Invalid API choice")
// Views are all views in cointop
type Views struct {
Chart *ChartView
Table *TableView
TableHeader *TableHeaderView
Marketbar *MarketbarView
SearchField *SearchFieldView
Statusbar *StatusbarView
Help *HelpView
ConvertMenu *ConvertMenuView
Input *InputView
PortfolioUpdateMenu *PortfolioUpdateMenuView
}
// State is the state preferences of cointop
type State struct {
allCoins []*Coin
allCoinsSlugMap sync.Map
cacheDir string
coins []*Coin
chartPoints [][]rune
currencyConversion string
convertMenuVisible bool
defaultView string
// DEPRECATED: favorites by 'symbol' is deprecated because of collisions.
favoritesBySymbol map[string]bool
favorites map[string]bool
filterByFavorites bool
helpVisible bool
hideMarketbar bool
hideChart bool
hideStatusbar bool
lastSelectedRowIndex int
page int
perPage int
portfolio *Portfolio
portfolioVisible bool
portfolioUpdateMenuVisible bool
refreshRate time.Duration
running bool
searchFieldVisible bool
selectedCoin *Coin
selectedChartRange string
shortcutKeys map[string]string
sortDesc bool
sortBy string
onlyTable bool
chartHeight int
}
// Cointop cointop
type Cointop struct {
g *gocui.Gui
ui *ui.UI
ActionsMap map[string]bool
apiKeys *APIKeys
cache *cache.Cache
colorsDir string
config config // toml config
configFilepath string
api api.Interface
apiChoice string
chartRanges []string
chartRangesMap map[string]time.Duration
colorschemeName string
colorscheme *Colorscheme
debug bool
filecache *filecache.FileCache
forceRefresh chan bool
limiter <-chan time.Time
maxTableWidth int
refreshMux sync.Mutex
refreshTicker *time.Ticker
saveMux sync.Mutex
State *State
table *table.Table
TableColumnOrder []string
Views *Views
}
// CoinMarketCap is API choice
var CoinMarketCap = "coinmarketcap"
// CoinGecko is API choice
var CoinGecko = "coingecko"
// PortfolioEntry is portfolio entry
type PortfolioEntry struct {
Coin string
Holdings float64
}
// Portfolio is portfolio structure
type Portfolio struct {
Entries map[string]*PortfolioEntry
}
// Config config options
type Config struct {
APIChoice string
CacheDir string
ColorsDir string
Colorscheme string
ConfigFilepath string
CoinMarketCapAPIKey string
NoPrompts bool
HideMarketbar bool
HideChart bool
HideStatusbar bool
NoCache bool
OnlyTable bool
RefreshRate *uint
PerPage uint
}
// APIKeys is api keys structure
type APIKeys struct {
cmc string
}
// DefaultPerPage ...
var DefaultPerPage uint = 100
// DefaultColorscheme ...
var DefaultColorscheme = "cointop"
// DefaultConfigFilepath ...
var DefaultConfigFilepath = pathutil.NormalizePath(":PREFERRED_CONFIG_HOME:/cointop/config.toml")
// DefaultCacheDir ...
var DefaultCacheDir = filecache.DefaultCacheDir
// DefaultColorsDir ...
var DefaultColorsDir = fmt.Sprintf("%s/colors", DefaultConfigFilepath)
// NewCointop initializes cointop
func NewCointop(config *Config) (*Cointop, error) {
var debug bool
if os.Getenv("DEBUG") != "" {
debug = true
}
if config == nil {
config = &Config{}
}
configFilepath := DefaultConfigFilepath
if config.ConfigFilepath != "" {
configFilepath = config.ConfigFilepath
}
perPage := DefaultPerPage
if config.PerPage != 0 {
perPage = config.PerPage
}
ct := &Cointop{
apiChoice: CoinGecko,
apiKeys: new(APIKeys),
forceRefresh: make(chan bool),
maxTableWidth: 175,
ActionsMap: ActionsMap(),
cache: cache.New(1*time.Minute, 2*time.Minute),
colorsDir: config.ColorsDir,
configFilepath: configFilepath,
chartRanges: ChartRanges(),
debug: debug,
chartRangesMap: ChartRangesMap(),
limiter: time.Tick(2 * time.Second),
filecache: nil,
State: &State{
allCoins: []*Coin{},
cacheDir: DefaultCacheDir,
currencyConversion: "USD",
// DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility.
favoritesBySymbol: make(map[string]bool),
favorites: make(map[string]bool),
hideMarketbar: config.HideMarketbar,
hideChart: config.HideChart,
hideStatusbar: config.HideStatusbar,
onlyTable: config.OnlyTable,
refreshRate: 60 * time.Second,
selectedChartRange: "7D",
shortcutKeys: DefaultShortcuts(),
sortBy: "rank",
page: 0,
perPage: int(perPage),
portfolio: &Portfolio{
Entries: make(map[string]*PortfolioEntry, 0),
},
chartHeight: 10,
},
TableColumnOrder: TableColumnOrder(),
Views: &Views{
Chart: NewChartView(),
Table: NewTableView(),
TableHeader: NewTableHeaderView(),
Marketbar: NewMarketbarView(),
SearchField: NewSearchFieldView(),
Statusbar: NewStatusbarView(),
Help: NewHelpView(),
ConvertMenu: NewConvertMenuView(),
Input: NewInputView(),
PortfolioUpdateMenu: NewPortfolioUpdateMenuView(),
},
}
err := ct.SetupConfig()
if err != nil {
return nil, err
}
ct.cache.Set("onlyTable", ct.State.onlyTable, cache.NoExpiration)
ct.cache.Set("hideMarketbar", ct.State.hideMarketbar, cache.NoExpiration)
ct.cache.Set("hideChart", ct.State.hideChart, cache.NoExpiration)
ct.cache.Set("hideStatusbar", ct.State.hideStatusbar, cache.NoExpiration)
if config.RefreshRate != nil {
ct.State.refreshRate = time.Duration(*config.RefreshRate) * time.Second
}
if ct.State.refreshRate == 0 {
ct.refreshTicker = time.NewTicker(time.Duration(1))
ct.refreshTicker.Stop()
} else {
ct.refreshTicker = time.NewTicker(ct.State.refreshRate)
}
if config.CacheDir != "" {
ct.State.cacheDir = pathutil.NormalizePath(config.CacheDir)
if err := ct.SaveConfig(); err != nil {
return nil, err
}
}
if !config.NoCache {
fcache, err := filecache.NewFileCache(&filecache.Config{
CacheDir: ct.State.cacheDir,
})
if err != nil {
fmt.Printf("error: %s\nyou may change the cache directory with --cache-dir flag.\nproceeding without filecache.\n", err)
}
ct.filecache = fcache
}
// prompt for CoinMarketCap api key if not found
if config.CoinMarketCapAPIKey != "" {
ct.apiKeys.cmc = config.CoinMarketCapAPIKey
if err := ct.SaveConfig(); err != nil {
return nil, err
}
}
if config.Colorscheme != "" {
ct.colorschemeName = config.Colorscheme
}
colors, err := ct.getColorschemeColors()
if err != nil {
return nil, err
}
ct.colorscheme = NewColorscheme(colors)
if config.APIChoice != "" {
ct.apiChoice = config.APIChoice
if err := ct.SaveConfig(); err != nil {
return nil, err
}
}
if ct.apiChoice == CoinMarketCap && ct.apiKeys.cmc == "" {
apiKey := os.Getenv("CMC_PRO_API_KEY")
if apiKey == "" {
if !config.NoPrompts {
apiKey, err = ct.ReadAPIKeyFromStdin("CoinMarketCap Pro")
if err != nil {
return nil, err
}
ct.apiKeys.cmc = apiKey
}
} else {
ct.apiKeys.cmc = apiKey
}
if err := ct.SaveConfig(); err != nil {
return nil, err
}
}
if ct.apiChoice == CoinGecko {
ct.State.selectedChartRange = "1Y"
}
if ct.apiChoice == CoinMarketCap {
ct.api = api.NewCMC(ct.apiKeys.cmc)
} else if ct.apiChoice == CoinGecko {
ct.api = api.NewCG()
} else {
return nil, ErrInvalidAPIChoice
}
allCoinsSlugMap := make(map[string]*Coin)
coinscachekey := ct.CacheKey("allCoinsSlugMap")
if ct.filecache != nil {
ct.filecache.Get(coinscachekey, &allCoinsSlugMap)
}
// fix for https://github.com/miguelmota/cointop/issues/59
// can remove this after everyone has cleared their cache
for _, v := range allCoinsSlugMap {
// Some APIs returns rank 0 for new coins
// or coins with low market cap data so we need to put them
// at the end of the list.
if v.Rank == 0 {
v.Rank = 10000
}
}
for k, v := range allCoinsSlugMap {
ct.State.allCoinsSlugMap.Store(k, v)
}
ct.State.allCoinsSlugMap.Range(func(key, value interface{}) bool {
if coin, ok := value.(*Coin); ok {
ct.State.allCoins = append(ct.State.allCoins, coin)
}
return true
})
if len(ct.State.allCoins) > 1 {
max := len(ct.State.allCoins)
if max > 100 {
max = 100
}
ct.Sort(ct.State.sortBy, ct.State.sortDesc, ct.State.allCoins, false)
ct.State.coins = ct.State.allCoins[0:max]
}
// DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility.
// Here we're doing a lookup based on symbol and setting the favorite to the coin name instead of coin symbol.
ct.State.allCoinsSlugMap.Range(func(key, value interface{}) bool {
if coin, ok := value.(*Coin); ok {
for k := range ct.State.favoritesBySymbol {
if coin.Symbol == k {
ct.State.favorites[coin.Name] = true
delete(ct.State.favoritesBySymbol, k)
}
}
}
return true
})
var globaldata []float64
chartcachekey := ct.CacheKey(fmt.Sprintf("%s_%s", "globaldata", strings.Replace(ct.State.selectedChartRange, " ", "", -1)))
if ct.filecache != nil {
ct.filecache.Get(chartcachekey, &globaldata)
}
ct.cache.Set(chartcachekey, globaldata, 10*time.Second)
var market types.GlobalMarketData
marketcachekey := ct.CacheKey("market")
if ct.filecache != nil {
ct.filecache.Get(marketcachekey, &market)
}
ct.cache.Set(marketcachekey, market, 10*time.Second)
// TODO: notify offline status in status bar
/*
if err := ct.api.Ping(); err != nil {
return nil, err
}
*/
return ct, nil
}
// Run runs cointop
func (ct *Cointop) Run() error {
ct.debuglog("run()")
ui, err := ui.NewUI()
if err != nil {
return err
}
ui.SetFgColor(ct.colorscheme.BaseFg())
ui.SetBgColor(ct.colorscheme.BaseBg())
ct.ui = ui
ct.g = ui.GetGocui()
defer ui.Close()
ui.SetInputEsc(true)
ui.SetMouse(true)
ui.SetHighlight(true)
ui.SetManagerFunc(ct.layout)
if err := ct.Keybindings(ct.g); err != nil {
return fmt.Errorf("keybindings: %v", err)
}
ct.State.running = true
if err := ui.MainLoop(); err != nil && err != gocui.ErrQuit {
return fmt.Errorf("main loop: %v", err)
}
return nil
}
// IsRunning returns true if cointop is running
func (ct *Cointop) IsRunning() bool {
return ct.State.running
}
// CleanConfig is the config for the clean function
type CleanConfig struct {
Log bool
CacheDir string
}
// Clean removes cache files
func Clean(config *CleanConfig) error {
if config == nil {
config = &CleanConfig{}
}
cacheCleaned := false
cacheDir := DefaultCacheDir
if config.CacheDir != "" {
cacheDir = pathutil.NormalizePath(config.CacheDir)
}
if _, err := os.Stat(cacheDir); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(cacheDir)
if err != nil {
return err
}
for _, f := range files {
if strings.HasPrefix(f.Name(), "fcache.") {
file := fmt.Sprintf("%s/%s", cacheDir, f.Name())
if config.Log {
fmt.Printf("removing %s\n", file)
}
if err := os.Remove(file); err != nil {
return err
}
cacheCleaned = true
}
}
}
if config.Log {
if cacheCleaned {
fmt.Println("cointop cache has been cleaned")
}
}
return nil
}
// ResetConfig is the config for the reset function
type ResetConfig struct {
Log bool
CacheDir string
}
// Reset removes configuration and cache files
func Reset(config *ResetConfig) error {
if config == nil {
config = &ResetConfig{}
}
if err := Clean(&CleanConfig{
CacheDir: config.CacheDir,
Log: config.Log,
}); err != nil {
return err
}
configDeleted := false
for _, configPath := range possibleConfigPaths {
normalizedPath := pathutil.NormalizePath(configPath)
if _, err := os.Stat(normalizedPath); !os.IsNotExist(err) {
if config.Log {
fmt.Printf("removing %s\n", normalizedPath)
}
if err := os.RemoveAll(normalizedPath); err != nil {
return err
}
configDeleted = true
}
}
if config.Log {
if configDeleted {
fmt.Println("cointop has been reset")
}
}
return nil
}
// ColorschemeHelpString ...
func ColorschemeHelpString() string {
return fmt.Sprintf("To install standard themes, do:\n\ngit clone git@github.com:cointop-sh/colors.git %s\n\nSee git.io/cointop#colorschemes for more info.", pathutil.NormalizePath(":PREFERRED_CONFIG_HOME:/cointop/colors"))
}