package cointop
import (
"crypto/sha256"
"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/cache"
"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"
)
// TODO: clean up and optimize codebase
// Views are all views in cointop
type Views struct {
Chart * ChartView
Table * TableView
TableHeader * TableHeaderView
Marketbar * MarketbarView
SearchField * SearchFieldView
Statusbar * StatusbarView
Menu * MenuView
Input * InputView
}
// State is the state preferences of cointop
type State struct {
allCoins [ ] * Coin
allCoinsSlugMap sync . Map
cacheDir string
coins [ ] * Coin
chartPoints [ ] [ ] rune
currencyConversion string
coinsTableColumns [ ] string
convertMenuVisible bool
defaultView string
// DEPRECATED: favorites by 'symbol' is deprecated because of collisions.
favoritesBySymbol map [ string ] bool
favorites map [ string ] bool
favoritesTableColumns [ ] string
helpVisible bool
hideMarketbar bool
hideChart bool
hideTable bool
hideStatusbar bool
keepRowFocusOnSort bool
lastSelectedRowIndex int
marketBarHeight int
maxPages int
page int
perPage int
portfolio * Portfolio
portfolioUpdateMenuVisible bool
portfolioTableColumns [ ] string
refreshRate time . Duration
running bool
searchFieldVisible bool
selectedCoin * Coin
selectedChartRange string
selectedView string
lastSelectedView string
shortcutKeys map [ string ] string
sortDesc bool
sortBy string
tableOffsetX int
onlyTable bool
onlyChart bool
tableColumnWidths sync . Map
tableColumnAlignLeft sync . Map
chartHeight int
lastChartHeight int
priceAlerts * PriceAlerts
priceAlertEditID string
priceAlertNewID string
}
// 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
logfile * os . File
forceRefresh chan bool
limiter <- chan time . Time
maxTableWidth int
refreshMux sync . Mutex
refreshTicker * time . Ticker
saveMux sync . Mutex
State * State
table * table . Table
Views * Views
}
// PortfolioEntry is portfolio entry
type PortfolioEntry struct {
Coin string
Holdings float64
}
// Portfolio is portfolio structure
type Portfolio struct {
Entries map [ string ] * PortfolioEntry
}
// PriceAlert is price alert structure
type PriceAlert struct {
ID string
CoinName string
TargetPrice float64
Operator string
Frequency string
CreatedAt string
Expired bool
}
// PriceAlerts is price alerts structure
type PriceAlerts struct {
Entries [ ] * PriceAlert
SoundEnabled bool
}
// Config config options
type Config struct {
APIChoice string
CacheDir string
ColorsDir string
Colorscheme string
ConfigFilepath string
CoinMarketCapAPIKey string
NoPrompts bool
HideMarketbar bool
HideChart bool
HideTable bool
HideStatusbar bool
NoCache bool
OnlyTable bool
OnlyChart bool
RefreshRate * uint
PerPage uint
MaxPages uint
}
// APIKeys is api keys structure
type APIKeys struct {
cmc string
}
// DefaultCurrency ...
var DefaultCurrency = "USD"
// DefaultChartRange ...
var DefaultChartRange = "1Y"
// DefaultSortBy ...
var DefaultSortBy = "rank"
// DefaultPerPage ...
var DefaultPerPage uint = 100
// MaxPages
var DefaultMaxPages uint = 35
// 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
}
maxPages := DefaultMaxPages
if config . MaxPages > 0 {
maxPages = config . MaxPages
}
ct := & Cointop {
// defaults
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 . NewTicker ( 2 * time . Second ) . C ,
filecache : nil ,
State : & State {
allCoins : [ ] * Coin { } ,
cacheDir : DefaultCacheDir ,
coinsTableColumns : DefaultCoinTableHeaders ,
currencyConversion : DefaultCurrency ,
// DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility.
favoritesBySymbol : make ( map [ string ] bool ) ,
favorites : make ( map [ string ] bool ) ,
favoritesTableColumns : DefaultCoinTableHeaders ,
hideMarketbar : config . HideMarketbar ,
hideChart : config . HideChart ,
hideTable : config . HideTable ,
hideStatusbar : config . HideStatusbar ,
keepRowFocusOnSort : false ,
marketBarHeight : 1 ,
maxPages : int ( maxPages ) ,
onlyTable : config . OnlyTable ,
onlyChart : config . OnlyChart ,
refreshRate : 60 * time . Second ,
selectedChartRange : DefaultChartRange ,
shortcutKeys : DefaultShortcuts ( ) ,
sortBy : DefaultSortBy ,
page : 0 ,
perPage : int ( perPage ) ,
portfolio : & Portfolio {
Entries : make ( map [ string ] * PortfolioEntry ) ,
} ,
portfolioTableColumns : DefaultPortfolioTableHeaders ,
chartHeight : 10 ,
lastChartHeight : 10 ,
tableOffsetX : 0 ,
tableColumnWidths : sync . Map { } ,
tableColumnAlignLeft : sync . Map { } ,
priceAlerts : & PriceAlerts {
Entries : make ( [ ] * PriceAlert , 0 ) ,
SoundEnabled : true ,
} ,
} ,
Views : & Views {
Chart : NewChartView ( ) ,
Table : NewTableView ( ) ,
TableHeader : NewTableHeaderView ( ) ,
Marketbar : NewMarketbarView ( ) ,
SearchField : NewSearchFieldView ( ) ,
Statusbar : NewStatusbarView ( ) ,
Menu : NewMenuView ( ) ,
Input : NewInputView ( ) ,
} ,
}
if debug {
ct . initlog ( )
}
err := ct . SetupConfig ( )
if err != nil {
return nil , err
}
ct . cache . Set ( "onlyTable" , ct . State . onlyTable , cache . NoExpiration )
if ct . State . onlyTable && ct . State . onlyChart {
ct . State . onlyChart = false
}
ct . cache . Set ( "onlyChart" , ct . State . onlyChart , cache . NoExpiration )
ct . cache . Set ( "hideMarketbar" , ct . State . hideMarketbar , cache . NoExpiration )
ct . cache . Set ( "hideChart" , ct . State . hideChart , cache . NoExpiration )
ct . cache . Set ( "hideTable" , ct . State . hideTable , 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 {
// each custom config file has it's own file cache
hash := sha256 . Sum256 ( [ ] byte ( ct . ConfigFilePath ( ) ) )
fcache , err := filecache . NewFileCache ( & filecache . Config {
CacheDir : ct . State . cacheDir ,
Prefix : fmt . Sprintf ( "%x" , hash [ 0 : 4 ] ) ,
} )
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 == CoinMarketCap {
ct . api = api . NewCMC ( ct . apiKeys . cmc )
} else if ct . apiChoice == CoinGecko {
ct . api = api . NewCG ( perPage , maxPages )
} 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 )
}
go ct . PriceAlertWatcher ( )
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" ) )
}