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

615 lines
15 KiB
Go

package cointop
import (
"crypto/sha256"
"fmt"
"io/ioutil"
"os"
"strings"
"sync"
"time"
"github.com/cointop-sh/cointop/pkg/api"
"github.com/cointop-sh/cointop/pkg/api/types"
"github.com/cointop-sh/cointop/pkg/cache"
"github.com/cointop-sh/cointop/pkg/filecache"
"github.com/cointop-sh/cointop/pkg/pathutil"
"github.com/cointop-sh/cointop/pkg/table"
"github.com/cointop-sh/cointop/pkg/ui"
"github.com/miguelmota/gocui"
log "github.com/sirupsen/logrus"
)
// TODO: clean up and optimize codebase
5 years ago
// 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
5 years ago
}
// State is the state preferences of cointop
type State struct {
allCoins []*Coin
allCoinsSlugMap sync.Map
cacheDir string
5 years ago
coins []*Coin
chartPoints [][]rune
5 years ago
currencyConversion string
coinsTableColumns []string
5 years ago
convertMenuVisible bool
defaultView string
defaultChartRange string
maxChartWidth int
5 years ago
favorites map[string]bool
favoritesTableColumns []string
favoriteChar string
5 years ago
helpVisible bool
hideMarketbar bool
hideChart bool
hideTable bool
5 years ago
hideStatusbar bool
hidePortfolioBalances bool
keepRowFocusOnSort bool
lastSelectedRowIndex int
marketBarHeight int
maxPages int
5 years ago
page int
perPage int
portfolio *Portfolio
portfolioUpdateMenuVisible bool
portfolioTableColumns []string
5 years ago
refreshRate time.Duration
running bool
5 years ago
searchFieldVisible bool
lastSearchQuery string
5 years ago
selectedCoin *Coin
selectedChartRange string
selectedView string
lastSelectedView string
5 years ago
shortcutKeys map[string]string
sortDesc bool
sortBy string
tableOffsetX int
5 years ago
onlyTable bool
onlyChart bool
tableColumnWidths sync.Map
tableColumnAlignLeft sync.Map
chartHeight int
lastChartHeight int
priceAlerts *PriceAlerts
priceAlertEditID string
priceAlertNewID string
compactNotation bool
tableCompactNotation bool
favoritesCompactNotation bool
portfolioCompactNotation bool
enableMouse bool
5 years ago
}
// Cointop cointop
type Cointop struct {
g *gocui.Gui
ui *ui.UI
ActionsMap map[string]bool
apiKeys *APIKeys
cache *cache.Cache
colorsDir string
config ConfigFileConfig
configFilepath string
api api.Interface
apiChoice string
chartRanges []string
chartRangesMap map[string]time.Duration
colorschemeName string
colorscheme *Colorscheme
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
5 years ago
}
// PortfolioEntry is portfolio entry
5 years ago
type PortfolioEntry struct {
5 years ago
Coin string
Holdings float64
}
// Portfolio is portfolio structure
5 years ago
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
HidePortfolioBalances bool
NoCache bool
OnlyTable bool
OnlyChart bool
RefreshRate *uint
PerPage uint
MaxPages uint
}
5 years ago
// APIKeys is api keys structure
type APIKeys struct {
5 years ago
cmc string
}
// DefaultCurrency ...
var DefaultCurrency = "USD"
// DefaultChartRange ...
var DefaultChartRange = "1Y"
// DefaultCompactNotation ...
var DefaultCompactNotation = false
// DefaultEnableMouse ...
var DefaultEnableMouse = true
// DefaultMaxChartWidth ...
var DefaultMaxChartWidth = 175
// DefaultChartHeight ...
var DefaultChartHeight = 10
// DefaultSortBy ...
var DefaultSortBy = "rank"
// DefaultPerPage ...
var DefaultPerPage = uint(100)
// DefaultMaxPages ...
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)
// DefaultFavoriteChar ...
var DefaultFavoriteChar = "*"
// NewCointop initializes cointop
func NewCointop(config *Config) (*Cointop, error) {
if os.Getenv("DEBUG") != "" {
log.SetLevel(log.DebugLevel)
}
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
}
6 years ago
ct := &Cointop{
// defaults
5 years ago
apiChoice: CoinGecko,
apiKeys: new(APIKeys),
forceRefresh: make(chan bool),
maxTableWidth: 175,
ActionsMap: ActionsMap(),
5 years ago
cache: cache.New(1*time.Minute, 2*time.Minute),
colorsDir: config.ColorsDir,
5 years ago
configFilepath: configFilepath,
4 years ago
chartRanges: ChartRanges(),
chartRangesMap: ChartRangesMap(),
limiter: time.NewTicker(2 * time.Second).C,
filecache: nil,
5 years ago
State: &State{
allCoins: []*Coin{},
cacheDir: DefaultCacheDir,
coinsTableColumns: DefaultCoinTableHeaders,
currencyConversion: DefaultCurrency,
defaultChartRange: DefaultChartRange,
maxChartWidth: DefaultMaxChartWidth,
favorites: make(map[string]bool),
favoritesTableColumns: DefaultCoinTableHeaders,
favoriteChar: DefaultFavoriteChar,
hideMarketbar: config.HideMarketbar,
hideChart: config.HideChart,
hideTable: config.HideTable,
hideStatusbar: config.HideStatusbar,
hidePortfolioBalances: config.HidePortfolioBalances,
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),
5 years ago
portfolio: &Portfolio{
3 years ago
Entries: make(map[string]*PortfolioEntry),
5 years ago
},
portfolioTableColumns: DefaultPortfolioTableHeaders,
chartHeight: DefaultChartHeight,
lastChartHeight: DefaultChartHeight,
tableOffsetX: 0,
tableColumnWidths: sync.Map{},
tableColumnAlignLeft: sync.Map{},
priceAlerts: &PriceAlerts{
Entries: make([]*PriceAlert, 0),
SoundEnabled: true,
},
compactNotation: DefaultCompactNotation,
enableMouse: DefaultEnableMouse,
tableCompactNotation: DefaultCompactNotation,
favoritesCompactNotation: DefaultCompactNotation,
portfolioCompactNotation: DefaultCompactNotation,
5 years ago
},
Views: &Views{
Chart: NewChartView(),
Table: NewTableView(),
TableHeader: NewTableHeaderView(),
Marketbar: NewMarketbarView(),
SearchField: NewSearchFieldView(),
Statusbar: NewStatusbarView(),
Menu: NewMenuView(),
Input: NewInputView(),
5 years ago
},
}
ct.initlog()
5 years ago
4 years ago
err := ct.SetupConfig()
if err != nil {
return nil, err
}
5 years ago
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)
5 years ago
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)
5 years ago
ct.cache.Set("hideStatusbar", ct.State.hideStatusbar, cache.NoExpiration)
if config.RefreshRate != nil {
5 years ago
ct.State.refreshRate = time.Duration(*config.RefreshRate) * time.Second
}
5 years ago
if ct.State.refreshRate == 0 {
ct.refreshTicker = time.NewTicker(time.Duration(1))
ct.refreshTicker.Stop()
} else {
5 years ago
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
4 years ago
if err := ct.SaveConfig(); err != nil {
return nil, err
}
}
if config.Colorscheme != "" {
5 years ago
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
4 years ago
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
}
4 years ago
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
}
5 years ago
allCoinsSlugMap := make(map[string]*Coin)
coinscachekey := ct.CacheKey("allCoinsSlugMap")
if ct.filecache != nil {
ct.filecache.Get(coinscachekey, &allCoinsSlugMap)
}
5 years ago
// fix for https://github.com/cointop-sh/cointop/issues/59
// can remove this after everyone has cleared their cache
for _, v := range allCoinsSlugMap {
// Some APIs returns rank 0 for new coins
4 years ago
// 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)
5 years ago
}
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
})
5 years ago
if len(ct.State.allCoins) > 1 {
max := len(ct.State.allCoins)
5 years ago
if max > 100 {
max = 100
}
4 years ago
ct.Sort(ct.State.sortBy, ct.State.sortDesc, ct.State.allCoins, false)
5 years ago
ct.State.coins = ct.State.allCoins[0:max]
5 years ago
}
var globaldata []float64
chartcachekey := ct.CompositeCacheKey("globaldata", "", "", ct.State.selectedChartRange)
if ct.filecache != nil {
ct.filecache.Get(chartcachekey, &globaldata)
}
ct.cache.Set(chartcachekey, globaldata, 10*time.Second)
5 years ago
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
6 years ago
}
6 years ago
// Run runs cointop
func (ct *Cointop) Run() error {
log.Debug("Run()")
ui, err := ui.NewUI()
if err != nil {
return err
}
5 years ago
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(ct.State.enableMouse)
ui.SetHighlight(true)
ui.SetManagerFunc(ct.layout)
if err := ct.SetKeybindings(); 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
}
4 years ago
// CleanConfig is the config for the clean function
type CleanConfig struct {
Log bool
CacheDir string
}
4 years ago
// Clean removes cache files
func (ct *Cointop) Clean(config *CleanConfig) error {
if config == nil {
config = &CleanConfig{}
}
cacheDir := DefaultCacheDir
if config.CacheDir != "" {
cacheDir = pathutil.NormalizePath(config.CacheDir)
} else if ct.State.cacheDir != "" {
cacheDir = ct.State.cacheDir
}
cacheCleaned := false
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
}
4 years ago
// ResetConfig is the config for the reset function
type ResetConfig struct {
Log bool
CacheDir string
}
4 years ago
// Reset removes configuration and cache files
func (ct *Cointop) Reset(config *ResetConfig) error {
if config == nil {
config = &ResetConfig{}
}
if err := ct.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"))
}