From e8035959f292d208911bbc09794fac249d331dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radu=20Lucu=C8=9B?= Date: Sat, 17 Aug 2024 12:42:15 +0300 Subject: [PATCH] feat: add config command Configuration flags: --styling disable or enable styling (0: default, 1: enable, 2: disable) --map-colors allows to map colors to other colors, e.g. 0:230,1:213 --color-range display color range. Useful for finding colors to map --- README.md | 28 +++++ cmd/cleed/config.go | 59 +++++++++ cmd/cleed/config_test.go | 244 ++++++++++++++++++++++++++++++++++++ cmd/cleed/root.go | 9 ++ internal/feed.go | 121 ++++++++++++++++-- internal/printer.go | 8 ++ internal/storage/config.go | 9 +- internal/storage/storage.go | 3 +- 8 files changed, 465 insertions(+), 16 deletions(-) create mode 100644 cmd/cleed/config.go create mode 100644 cmd/cleed/config_test.go diff --git a/README.md b/README.md index 38912a5..ce07a1c 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,31 @@ cleed list # Show all feeds in a list cleed list mylist ``` + +#### Configuration + +```bash +# Display configuration +cleed config + +# Disable styling +cleed config --styling=false + +# Map color 0 to 230 and color 1 to 213 +cleed config --map-colors=0:230,1:213 + +# Remove color mapping for color 0 +cleed config --map-colors=0: + +# Clear all color mappings +cleed config --map-colors= + +# Display color range. Useful for finding colors to map +cleed config --color-range +``` + +> **Color mapping** +> +> You can map the colors used in the feed reader to any color you want. This is useful if certain colors are not visible in your terminal based on the color scheme that you are using. +> +> Run `cleed config --color-range` to see the color range and map the colors that you want using the `cleed config --map-colors` command. diff --git a/cmd/cleed/config.go b/cmd/cleed/config.go new file mode 100644 index 0000000..28ddbb0 --- /dev/null +++ b/cmd/cleed/config.go @@ -0,0 +1,59 @@ +package cleed + +import ( + "github.com/spf13/cobra" +) + +func (r *Root) initConfig() { + cmd := &cobra.Command{ + Use: "config", + Short: "Display or change configuration", + Long: `Display or change configuration + +Examples: + # Display configuration + cleed config + + # Disable styling + cleed config --styling=false + + # Map color 0 to 230 and color 1 to 213 + cleed config --map-colors=0:230,1:213 + + # Remove color mapping for color 0 + cleed config --map-colors=0: + + # Clear all color mappings + cleed config --map-colors= + + # Display color range. Useful for finding colors to map + cleed config --color-range +`, + RunE: r.RunConfig, + } + + flags := cmd.Flags() + flags.Uint8("styling", 0, "disable or enable styling (0: default, 1: enable, 2: disable)") + flags.String("map-colors", "", "map colors to other colors, e.g. 0:230,1:213. Use --color-range to check available colors") + flags.Bool("color-range", false, "display color range. Useful for finding colors to map") + + r.Cmd.AddCommand(cmd) +} + +func (r *Root) RunConfig(cmd *cobra.Command, args []string) error { + if cmd.Flag("styling").Changed { + styling, err := cmd.Flags().GetUint8("styling") + if err != nil { + return err + } + return r.feed.SetStyling(styling) + } + if cmd.Flag("map-colors").Changed { + return r.feed.UpdateColorMap(cmd.Flag("map-colors").Value.String()) + } + if cmd.Flag("color-range").Changed { + r.feed.DisplayColorRange() + return nil + } + return r.feed.DisplayConfig() +} diff --git a/cmd/cleed/config_test.go b/cmd/cleed/config_test.go new file mode 100644 index 0000000..4f91dad --- /dev/null +++ b/cmd/cleed/config_test.go @@ -0,0 +1,244 @@ +package cleed + +import ( + "bytes" + "fmt" + "os" + "testing" + "time" + + "github.com/radulucut/cleed/internal" + _storage "github.com/radulucut/cleed/internal/storage" + "github.com/radulucut/cleed/mocks" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func Test_Config(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + out := new(bytes.Buffer) + printer := internal.NewPrinter(nil, out, out) + storage := _storage.NewLocalStorage("cleed_test", timeMock) + defer localStorageCleanup(t, storage) + + feed := internal.NewTerminalFeed(timeMock, printer, storage) + feed.SetAgent("cleed/test") + + root, err := NewRoot("0.1.0", timeMock, printer, storage, feed) + assert.NoError(t, err) + + os.Args = []string{"cleed", "config"} + + err = root.Cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, `Styling: enabled +Color map: +`, out.String()) + + config, err := storage.LoadConfig() + assert.NoError(t, err) + expectedConfig := &_storage.Config{ + Version: "0.1.0", + LastRun: time.Time{}, + Styling: 0, + ColorMap: make(map[uint8]uint8), + } + assert.Equal(t, expectedConfig, config) +} + +func Test_Config_Styling(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + out := new(bytes.Buffer) + printer := internal.NewPrinter(nil, out, out) + storage := _storage.NewLocalStorage("cleed_test", timeMock) + defer localStorageCleanup(t, storage) + + feed := internal.NewTerminalFeed(timeMock, printer, storage) + feed.SetAgent("cleed/test") + + root, err := NewRoot("0.1.0", timeMock, printer, storage, feed) + assert.NoError(t, err) + + os.Args = []string{"cleed", "config", "--styling", "2"} + + err = root.Cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "styling was updated\n", out.String()) + + config, err := storage.LoadConfig() + assert.NoError(t, err) + expectedConfig := &_storage.Config{ + Version: "0.1.0", + LastRun: time.Time{}, + Styling: 2, + ColorMap: make(map[uint8]uint8), + } + assert.Equal(t, expectedConfig, config) +} + +func Test_Config_MapColors(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + out := new(bytes.Buffer) + printer := internal.NewPrinter(nil, out, out) + storage := _storage.NewLocalStorage("cleed_test", timeMock) + defer localStorageCleanup(t, storage) + + feed := internal.NewTerminalFeed(timeMock, printer, storage) + feed.SetAgent("cleed/test") + + root, err := NewRoot("0.1.0", timeMock, printer, storage, feed) + assert.NoError(t, err) + + os.Args = []string{"cleed", "config", "--map-colors", "1:2,3:4"} + + err = root.Cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "color map updated\n", out.String()) + + config, err := storage.LoadConfig() + assert.NoError(t, err) + expectedConfig := &_storage.Config{ + Version: "0.1.0", + LastRun: time.Time{}, + Styling: 0, + ColorMap: map[uint8]uint8{1: 2, 3: 4}, + } + assert.Equal(t, expectedConfig, config) +} + +func Test_Config_MapColors_RemoveColorMapping(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + out := new(bytes.Buffer) + printer := internal.NewPrinter(nil, out, out) + storage := _storage.NewLocalStorage("cleed_test", timeMock) + defer localStorageCleanup(t, storage) + storage.Init("0.1.0") + + config, err := storage.LoadConfig() + assert.NoError(t, err) + config.ColorMap = map[uint8]uint8{1: 2, 3: 4} + err = storage.SaveConfig() + assert.NoError(t, err) + + feed := internal.NewTerminalFeed(timeMock, printer, storage) + feed.SetAgent("cleed/test") + + root, err := NewRoot("0.1.0", timeMock, printer, storage, feed) + assert.NoError(t, err) + + os.Args = []string{"cleed", "config", "--map-colors", "1:"} + + err = root.Cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "color map updated\n", out.String()) + + config, err = storage.LoadConfig() + assert.NoError(t, err) + expectedConfig := &_storage.Config{ + Version: "0.1.0", + LastRun: time.Time{}, + Styling: 0, + ColorMap: map[uint8]uint8{3: 4}, + } + assert.Equal(t, expectedConfig, config) +} + +func Test_Config_MapColors_ClearColorMapping(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + out := new(bytes.Buffer) + printer := internal.NewPrinter(nil, out, out) + storage := _storage.NewLocalStorage("cleed_test", timeMock) + defer localStorageCleanup(t, storage) + storage.Init("0.1.0") + + config, err := storage.LoadConfig() + assert.NoError(t, err) + config.ColorMap = map[uint8]uint8{1: 2, 3: 4} + err = storage.SaveConfig() + assert.NoError(t, err) + + feed := internal.NewTerminalFeed(timeMock, printer, storage) + feed.SetAgent("cleed/test") + + root, err := NewRoot("0.1.0", timeMock, printer, storage, feed) + assert.NoError(t, err) + + os.Args = []string{"cleed", "config", "--map-colors="} + + err = root.Cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "color map updated\n", out.String()) + + config, err = storage.LoadConfig() + assert.NoError(t, err) + expectedConfig := &_storage.Config{ + Version: "0.1.0", + LastRun: time.Time{}, + Styling: 0, + ColorMap: map[uint8]uint8{}, + } + assert.Equal(t, expectedConfig, config) +} + +func Test_Config_ColorRange(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + timeMock := mocks.NewMockTime(ctrl) + timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + out := new(bytes.Buffer) + printer := internal.NewPrinter(nil, out, out) + storage := _storage.NewLocalStorage("cleed_test", timeMock) + defer localStorageCleanup(t, storage) + storage.Init("0.1.0") + + config, err := storage.LoadConfig() + assert.NoError(t, err) + config.ColorMap = map[uint8]uint8{1: 2, 3: 4} + err = storage.SaveConfig() + assert.NoError(t, err) + + feed := internal.NewTerminalFeed(timeMock, printer, storage) + feed.SetAgent("cleed/test") + + root, err := NewRoot("0.1.0", timeMock, printer, storage, feed) + assert.NoError(t, err) + + os.Args = []string{"cleed", "config", "--color-range"} + + err = root.Cmd.Execute() + assert.NoError(t, err) + + expectedOutput := "" + for i := 0; i < 256; i++ { + expectedOutput += fmt.Sprintf("\033[38;5;%dm%d \033[0m", i, i) + } + expectedOutput += "\n" + assert.Equal(t, expectedOutput, out.String()) +} diff --git a/cmd/cleed/root.go b/cmd/cleed/root.go index 77bd51f..1c701c5 100644 --- a/cmd/cleed/root.go +++ b/cmd/cleed/root.go @@ -61,6 +61,14 @@ func NewRoot( return nil, fmt.Errorf("failed to initialize storage: %v", err) } + config, err := root.storage.LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load config: %v", err) + } + if config.Styling != 0 { + root.printer.SetStyling(config.Styling == 1) + } + root.Cmd = &cobra.Command{ Use: "cleed", Short: "A command line feed reader", @@ -101,6 +109,7 @@ Examples: root.initFollow() root.initUnfollow() root.initList() + root.initConfig() return root, nil } diff --git a/internal/feed.go b/internal/feed.go index 65c1ffd..b0f6ae5 100644 --- a/internal/feed.go +++ b/internal/feed.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "slices" + "strconv" + "strings" "sync" "time" @@ -47,6 +49,90 @@ func (f *TerminalFeed) SetAgent(agent string) { f.agent = agent } +func (f *TerminalFeed) DisplayConfig() error { + config, err := f.storage.LoadConfig() + if err != nil { + return utils.NewInternalError("failed to load config: " + err.Error()) + } + styling := "default" + if config.Styling == 0 { + styling = "enabled" + } else if config.Styling == 1 { + styling = "disabled" + } + f.printer.Println("Styling:", styling) + f.printer.Print("Color map:") + for k, v := range config.ColorMap { + f.printer.Printf(" %d:%d", k, v) + } + f.printer.Println() + return nil +} + +func (f *TerminalFeed) SetStyling(v uint8) error { + config, err := f.storage.LoadConfig() + if err != nil { + return utils.NewInternalError("failed to load config: " + err.Error()) + } + if v > 2 { + return utils.NewInternalError("invalid value for styling") + } + config.Styling = v + err = f.storage.SaveConfig() + if err != nil { + return utils.NewInternalError("failed to save config: " + err.Error()) + } + f.printer.Println("styling was updated") + return nil +} + +func (f *TerminalFeed) UpdateColorMap(mappings string) error { + config, err := f.storage.LoadConfig() + if err != nil { + return utils.NewInternalError("failed to load config: " + err.Error()) + } + if mappings == "" { + config.ColorMap = make(map[uint8]uint8) + } else { + colors := strings.Split(mappings, ",") + for i := range colors { + parts := strings.Split(colors[i], ":") + if len(parts) == 0 { + return utils.NewInternalError("failed to parse color mapping: " + colors[i]) + } + left, err := strconv.Atoi(parts[0]) + if err != nil { + return utils.NewInternalError("failed to parse color mapping: " + parts[0]) + } + if len(parts) == 1 || parts[1] == "" { + delete(config.ColorMap, uint8(left)) + } else { + right, err := strconv.Atoi(parts[1]) + if err != nil { + return utils.NewInternalError("failed to parse color mapping: " + parts[1]) + } + config.ColorMap[uint8(left)] = uint8(right) + } + } + } + err = f.storage.SaveConfig() + if err != nil { + return utils.NewInternalError("failed to save config: " + err.Error()) + } + f.printer.Println("color map updated") + return nil +} + +func (f *TerminalFeed) DisplayColorRange() { + styling := f.printer.GetStyling() + f.printer.SetStyling(true) + for i := 0; i < 256; i++ { + f.printer.Print(f.printer.ColorForeground(fmt.Sprintf("%d ", i), uint8(i))) + } + f.printer.Println() + f.printer.SetStyling(styling) +} + func (f *TerminalFeed) Follow(urls []string, list string) error { if len(urls) == 0 { return utils.NewInternalError("please provide at least one URL") @@ -124,7 +210,11 @@ type FeedOptions struct { } func (f *TerminalFeed) Feed(opts *FeedOptions) error { - items, err := f.processFeeds(opts) + config, err := f.storage.LoadConfig() + if err != nil { + return utils.NewInternalError("failed to load config: " + err.Error()) + } + items, err := f.processFeeds(opts, config) if err != nil { return err } @@ -148,40 +238,38 @@ func (f *TerminalFeed) Feed(opts *FeedOptions) error { if opts.Limit > 0 { l = min(len(items), opts.Limit) } - cellMax := [2]int{} + cellMax := [1]int{} for i := l - 1; i >= 0; i-- { fi := items[i] fi.PublishedRelative = utils.Relative(f.time.Now().Unix() - fi.Item.PublishedParsed.Unix()) cellMax[0] = max(cellMax[0], runewidth.StringWidth(fi.Feed.Title), len(fi.PublishedRelative)) - cellMax[1] = max(cellMax[1], runewidth.StringWidth(fi.Item.Title), runewidth.StringWidth(fi.Item.Link)) } cellMax[0] = min(cellMax[0], 30) + secondaryTextColor := mapColor(7, config) + highlightColor := mapColor(10, config) for i := l - 1; i >= 0; i-- { fi := items[i] newMark := "" if fi.IsNew { - newMark = f.printer.ColorForeground("• ", 10) + newMark = f.printer.ColorForeground("• ", highlightColor) } f.printer.Print( f.printer.ColorForeground(runewidth.FillRight(runewidth.Truncate(fi.Feed.Title, cellMax[0], "..."), cellMax[0]), fi.FeedColor), " ", newMark+fi.Item.Title, "\n", - f.printer.ColorForeground(runewidth.FillRight(fi.PublishedRelative, cellMax[0]), 7), + f.printer.ColorForeground(runewidth.FillRight(fi.PublishedRelative, cellMax[0]), secondaryTextColor), " ", - f.printer.ColorForeground(fi.Item.Link, 7), + f.printer.ColorForeground(fi.Item.Link, secondaryTextColor), "\n\n", ) } - config, _ := f.storage.LoadConfig() - if config != nil { - config.LastRun = f.time.Now() - f.storage.SaveConfig() - } + config.LastRun = f.time.Now() + f.storage.SaveConfig() return nil } -func (f *TerminalFeed) processFeeds(opts *FeedOptions) ([]*FeedItem, error) { +func (f *TerminalFeed) processFeeds(opts *FeedOptions, config *storage.Config) ([]*FeedItem, error) { var err error lists := make([]string, 0) if opts.List != "" { @@ -233,7 +321,7 @@ func (f *TerminalFeed) processFeeds(opts *FeedOptions) ([]*FeedItem, error) { defer mx.Unlock() color, ok := feedColorMap[feed.Title] if !ok { - color = uint8(len(feedColorMap) % 231) + color = mapColor(uint8(len(feedColorMap)%256), config) feedColorMap[feed.Title] = color } for _, feedItem := range feed.Items { @@ -313,3 +401,10 @@ func (f *TerminalFeed) fetchFeed(feed *storage.CacheInfoItem) (bool, string, err err = f.storage.SaveFeedCache(bodyReader, feed.URL) return true, res.Header.Get("ETag"), err } + +func mapColor(color uint8, config *storage.Config) uint8 { + if c, ok := config.ColorMap[color]; ok { + return c + } + return color +} diff --git a/internal/printer.go b/internal/printer.go index 4a25c9f..9766991 100644 --- a/internal/printer.go +++ b/internal/printer.go @@ -74,6 +74,14 @@ func (p *Printer) ColorBackground(s string, color uint8) string { return fmt.Sprintf("\033[48;5;%dm%s\033[0m", color, s) } +func (p *Printer) GetStyling() bool { + return !p.disableStyling +} + +func (p *Printer) SetStyling(enable bool) { + p.disableStyling = !enable +} + func (p *Printer) GetSize() (width, height int) { f, ok := p.OutWriter.(*os.File) if !ok { diff --git a/internal/storage/config.go b/internal/storage/config.go index 9cb563c..3ff3077 100644 --- a/internal/storage/config.go +++ b/internal/storage/config.go @@ -11,8 +11,10 @@ const ( ) type Config struct { - Version string `json:"version"` - LastRun time.Time `json:"lastRun"` + Version string `json:"version"` + LastRun time.Time `json:"lastRun"` + Styling uint8 `json:"styling"` // 0: default, 1: enabled, 2: disabled + ColorMap map[uint8]uint8 `json:"colorMap"` } func (s *LocalStorage) LoadConfig() (*Config, error) { @@ -32,6 +34,9 @@ func (s *LocalStorage) LoadConfig() (*Config, error) { if err != nil { return nil, err } + if s.config.ColorMap == nil { + s.config.ColorMap = make(map[uint8]uint8) + } return s.config, nil } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index cfe9ab6..7ca226c 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -53,7 +53,8 @@ func (s *LocalStorage) Init(version string) error { if err != nil { if os.IsNotExist(err) { s.config = &Config{ - Version: version, + Version: version, + ColorMap: make(map[uint8]uint8), } err = s.SaveConfig() }