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" ) )
}