diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9408ae8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bookworm diff --git a/Makefile b/Makefile index 712fb30..7d42386 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,8 @@ all: go install + +install: + go install + +build: + go build diff --git a/cmd/completions.go b/cmd/completions.go deleted file mode 100644 index 5d51e0c..0000000 --- a/cmd/completions.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "slices" - - "github.com/spf13/cobra" -) - -func nonCmp(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { - return []string{}, cobra.ShellCompDirectiveNoFileComp -} - -func getNamesThenTagsCmp(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - if len(args) == 0 { - return getNamesCmp(cmd, args, toComplete) - } - return getTagsCmp(cmd, args, toComplete) -} - -func getTagsCmp(_ *cobra.Command, args []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { - out := make([]string, len(Bw.Cfg.BookMarks)) - for _, b := range Bw.Cfg.BookMarks { - for _, tag := range b.Tags { - if !slices.Contains(args, tag) { - out = append(out, tag) - } - } - } - return out, cobra.ShellCompDirectiveNoFileComp -} - -func getNamesCmp(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - if len(args) > 0 { - return nonCmp(cmd, args, toComplete) - } - out := make([]string, len(Bw.Cfg.BookMarks)) - for k := range Bw.Cfg.BookMarks { - out = append(out, k) - } - return out, cobra.ShellCompDirectiveNoFileComp -} diff --git a/cmd/delete.go b/cmd/delete.go index 00f94ea..d41bae4 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -13,6 +13,7 @@ var delCmd = &cobra.Command{ Short: "Delete no good bookmarks.", Args: cobra.ExactArgs(1), Aliases: []string{"rm"}, + PreRunE: prGetCfg, ValidArgsFunction: getNamesCmp, Run: func(cmd *cobra.Command, args []string) { Bw.DeleteBookMark(args[0]) diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..3c2dee0 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(initCmd) +} + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize Bookworm.", + PreRunE: prInitCfg, + Run: func(cmd *cobra.Command, args []string) { + if Bw == nil { + panic("BW object is nil") + } + fmt.Println("Successfully Initialized") + }, +} diff --git a/cmd/list.go b/cmd/list.go index a59b67e..6d67640 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,9 +1,7 @@ package cmd import ( - // "fmt" "github.com/spf13/cobra" - "slices" ) func init() { @@ -13,18 +11,16 @@ func init() { listCmd.RegisterFlagCompletionFunc("tag", getTagsCmp) } -// Inspo `gh repo list` var listCmd = &cobra.Command{ Use: "list", Args: cobra.ExactArgs(0), Short: "List your bookmarks.", Aliases: []string{"ls"}, + PreRunE: prGetCfg, ValidArgsFunction: nonCmp, Run: func(cmd *cobra.Command, args []string) { - for _, b := range Bw.Cfg.BookMarks { - if tagFilter == "" || slices.Contains(b.Tags, tagFilter) { - b.Println() - } + for _, b := range Bw.ListBookMarks(tagFilter) { + b.Println() } }, } diff --git a/cmd/make.go b/cmd/make.go index 9397c62..6725119 100644 --- a/cmd/make.go +++ b/cmd/make.go @@ -15,6 +15,7 @@ var makeCmd = &cobra.Command{ Short: "Make new bookmarks.", Args: cobra.ExactArgs(2), Aliases: []string{"mk", "new"}, + PreRunE: prGetCfg, ValidArgsFunction: nonCmp, Run: func(cmd *cobra.Command, args []string) { if !internal.IsValidUrl(args[1]) { diff --git a/cmd/open.go b/cmd/open.go index 7c25bd3..4d11007 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -14,11 +14,12 @@ var openCmd = &cobra.Command{ Use: "open", Short: "Open a Bookmark.", Aliases: []string{"go"}, + PreRunE: prGetCfg, Args: cobra.ExactArgs(1), ValidArgsFunction: getNamesCmp, Run: func(cmd *cobra.Command, args []string) { - bm, ok := Bw.Cfg.BookMarks[args[0]] - if !ok { + bm := Bw.GetBookMark(args[0]) + if bm == nil { fmt.Println("Couldn't Find BookMark!") return } diff --git a/cmd/root.go b/cmd/root.go index 5eccd99..b4ff2ad 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,33 +2,33 @@ package cmd import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/dandeandean/bookworm/internal" "github.com/spf13/cobra" - "os" ) var ( - Bw = internal.Init() + // Global BookWorm + Bw *internal.BookWorm + tagFilter string ) -var tagFilter string - var rootCmd = &cobra.Command{ - Use: "bookworm", - Short: "Bookworm can manage your bookmarks from the command line.", + Use: "bookworm", + Short: "Bookworm can manage your bookmarks from the command line.", + PreRunE: prGetCfg, Run: func(cmd *cobra.Command, args []string) { m := TeaModel() p := tea.NewProgram(m) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) - os.Exit(1) } }, } func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Println(err) + fmt.Println("Something Horrible Happened!", err) } } diff --git a/cmd/tag.go b/cmd/tag.go index bf1bf0d..eaaf11f 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -13,6 +13,7 @@ var tagCmd = &cobra.Command{ Use: "tag name tags...", Args: cobra.MinimumNArgs(2), Short: "Tag boomarks with tags.", + PreRunE: prGetCfg, ValidArgsFunction: getNamesThenTagsCmp, Run: func(cmd *cobra.Command, args []string) { err := Bw.SetTags(args[0], args[1:]) diff --git a/cmd/util.go b/cmd/util.go new file mode 100644 index 0000000..786e685 --- /dev/null +++ b/cmd/util.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + + "github.com/dandeandean/bookworm/internal" + "github.com/spf13/cobra" +) + +func prGetCfg(_ *cobra.Command, _ []string) error { + if Bw != nil { + return nil + } + var err error + Bw, err = internal.Get() + if err != nil { + fmt.Println("Couldn't get Config, please run bookworm init!") + os.Exit(2) + return err + } + return nil +} + +func prInitCfg(cmd *cobra.Command, args []string) error { + var err error + Bw, err = internal.Init() + if err != nil { + fmt.Println("Failed to create config!") + os.Exit(2) + return err + } + return nil +} + +func nonCmp(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { + return []string{}, cobra.ShellCompDirectiveNoFileComp +} + +func getNamesThenTagsCmp(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) == 0 { + return getNamesCmp(cmd, args, toComplete) + } + return getTagsCmp(cmd, args, toComplete) +} + +func getTagsCmp(cmd *cobra.Command, args []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { + prGetCfg(cmd, args) + bms := Bw.ListBookMarks("") + out := make([]string, len(bms)) + for _, b := range bms { + for _, tag := range b.Tags { + if !slices.Contains(args, tag) { + out = append(out, tag) + } + } + } + return out, cobra.ShellCompDirectiveNoFileComp +} + +func getNamesCmp(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) > 0 { + return nonCmp(cmd, args, toComplete) + } + prGetCfg(cmd, args) + bms := Bw.ListBookMarks("") + out := make([]string, 0) + for _, k := range bms { + out = append(out, k.Name) + } + return out, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/views.go b/cmd/views.go index 971855b..666b101 100644 --- a/cmd/views.go +++ b/cmd/views.go @@ -8,16 +8,14 @@ import ( ) type model struct { - choices []internal.BookMark // items on the to-do list - cursor int // which to-do list item our cursor is pointing at - selected map[int]struct{} // which to-do items are selected + choices []*internal.BookMark // items on the to-do list + cursor int // which to-do list item our cursor is pointing at + selected map[int]struct{} // which to-do items are selected } func TeaModel() model { - choices := make([]internal.BookMark, 0) - for _, bm := range Bw.Cfg.BookMarks { - choices = append(choices, *bm) - } + choices := Bw.ListBookMarks("") + return model{ choices: choices, selected: make(map[int]struct{}), @@ -67,12 +65,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { delete(m.selected, m.cursor) } else { m.selected[m.cursor] = struct{}{} - internal.OpenURL(m.choices[m.cursor].Link) err := internal.OpenURL(m.choices[m.cursor].Link) if err != nil { panic(err) } - Bw.SetLastOpened(m.choices[m.cursor]) + Bw.SetLastOpened(*m.choices[m.cursor]) return m, tea.Quit } } diff --git a/internal/bookworm.go b/internal/bookworm.go index 973dee6..587aa10 100644 --- a/internal/bookworm.go +++ b/internal/bookworm.go @@ -2,6 +2,7 @@ package internal import ( "errors" + "slices" ) type BookWorm struct { @@ -9,34 +10,45 @@ type BookWorm struct { BookMarks map[string]*BookMark } -func Init() *BookWorm { - // get or init config +// get already init'd config +func Get() (*BookWorm, error) { cfg, err := getConfig() - if err != nil || cfg == nil { - panic(errors.New("Something horrible happened")) + if err != nil { + return nil, err + } + bms, err := cfg.enumBookMarks() + if err != nil { + return nil, err } - bms := cfg.BookMarks if bms == nil { - bms = make(map[string]*BookMark) + return nil, errors.New("Bookmarks are nil!") } return &BookWorm{ Cfg: cfg, BookMarks: bms, + }, nil +} + +// Init a Config that is not already there +func Init() (*BookWorm, error) { + cfg, err := initConfig() + if err != nil { + return nil, err + } + if cfg == nil { + return nil, err } + return &BookWorm{ + Cfg: cfg, + BookMarks: make(map[string]*BookMark), + }, nil } // Registister Config writes all of the changes to the Config -// This is a little janky, but oh well -func (w *BookWorm) RegisterConfig() error { - // for now we'll store the bookmarks in the config - // this should be replaced by a proper db - w.Cfg.BookMarks = w.BookMarks - return w.Cfg.writeConfig() -} func (w *BookWorm) SetLastOpened(bm BookMark) error { w.Cfg.LastOpened = bm.Link - return w.RegisterConfig() + return w.Cfg.writeConfig() } func (w *BookWorm) SetTags(name string, tags []string) error { @@ -46,7 +58,7 @@ func (w *BookWorm) SetTags(name string, tags []string) error { } bm.Tags = append(bm.Tags, tags...) // Rewriting all of the bookmarks each time is not great - return w.RegisterConfig() + return w.writeBookMark(name) } func (w *BookWorm) NewBookMark(name string, link string, tags []string) error { @@ -55,11 +67,29 @@ func (w *BookWorm) NewBookMark(name string, link string, tags []string) error { Link: link, Tags: tags, } - return w.RegisterConfig() + return w.writeBookMark(name) } func (w *BookWorm) DeleteBookMark(name string) error { w.BookMarks[name] = &BookMark{} delete(w.BookMarks, name) - return w.RegisterConfig() + return w.deleteBookMark(name) +} + +func (w *BookWorm) GetBookMark(name string) *BookMark { + return w.BookMarks[name] +} + +func (w *BookWorm) ListBookMarks(tagFilter string) []*BookMark { + out := make([]*BookMark, 0) + for _, b := range w.BookMarks { + if slices.Contains(b.Tags, tagFilter) || tagFilter == "" { + out = append(out, b) + } + } + return out +} + +func (w *BookWorm) LenBookMarks() int { + return len(w.BookMarks) } diff --git a/internal/state.go b/internal/state.go index 9713c7c..ba13b86 100644 --- a/internal/state.go +++ b/internal/state.go @@ -2,49 +2,104 @@ package internal import ( "encoding/json" + "errors" + "time" + "go.etcd.io/bbolt" ) -func (c *Config) WriteKey(bm BookMark) error { - db, err := bbolt.Open(c.DbPath, 0600, nil) +func (bw *BookWorm) writeBookMark(key string) error { + bm := bw.BookMarks[key] + if bm == nil { + return errors.New("BookMark is Nil") + } + db, err := bbolt.Open(bw.Cfg.DbPath, 0600, &bbolt.Options{Timeout: time.Second}) if err != nil { - panic(err) + return err + } + defer db.Close() + buf, err := json.Marshal(bm) + if err != nil { + return err } err = db.Update(func(tx *bbolt.Tx) error { - bookMarksBucket, err := tx.CreateBucketIfNotExists([]byte("BookMarks")) + bookMarksBucket, err := tx.CreateBucketIfNotExists([]byte("bookmarks")) if err != nil { return err } - buf, err := json.Marshal(bm) + return bookMarksBucket.Put([]byte(bm.Name), buf) + }) + return nil +} + +func (bw *BookWorm) deleteBookMark(key string) error { + bm := bw.BookMarks[key] + if bm == nil { + return errors.New("BookMark is Nil") + } + db, err := bbolt.Open(bw.Cfg.DbPath, 0600, &bbolt.Options{Timeout: time.Second}) + if err != nil { + return err + } + defer db.Close() + err = db.Update(func(tx *bbolt.Tx) error { + bookMarksBucket, err := tx.CreateBucketIfNotExists([]byte("bookmarks")) if err != nil { return err } - bookMarksBucket.Put([]byte(bm.Name), buf) - return nil + return bookMarksBucket.Delete([]byte(bm.Name)) }) return nil } -func (c *Config) GetBookMark(key string) (*BookMark, error) { - db, err := bbolt.Open(c.DbPath, 0600, &bbolt.Options{ReadOnly: true}) +func (c *Config) enumBookMarks() (map[string]*BookMark, error) { + db, err := bbolt.Open(c.DbPath, 0600, &bbolt.Options{Timeout: time.Second}) if err != nil { - panic(err) + return nil, err } - bmToReturn := &BookMark{} - err = db.View(func(tx *bbolt.Tx) error { - bookMarksBucket := tx.Bucket([]byte("BookMarks")) + defer db.Close() + bmsRaw := make(map[string][]byte) + db.View(func(tx *bbolt.Tx) error { + bookMarksBucket := tx.Bucket([]byte("bookmarks")) if bookMarksBucket == nil { return err } - buf := bookMarksBucket.Get([]byte(key)) - err := json.Unmarshal(buf, bmToReturn) - if err != nil { - return err + c := bookMarksBucket.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + bmsRaw[string(k)] = v } return nil }) + bookmarks := make(map[string]*BookMark) + for k, v := range bmsRaw { + b, err := bytesToBookMark(v) + if err != nil { + return nil, err + } + bookmarks[k] = b + } + return bookmarks, nil +} + +func (bw *BookWorm) _(key string) (*BookMark, error) { + db, err := bbolt.Open(bw.Cfg.DbPath, 0600, &bbolt.Options{ReadOnly: true, Timeout: time.Second}) + if err != nil { + panic(err) + } + defer db.Close() + var buf []byte + err = db.View( + func(tx *bbolt.Tx) error { + bookMarksBucket := tx.Bucket([]byte("bookmarks")) + if bookMarksBucket == nil { + return err + } + buf = bookMarksBucket.Get([]byte(key)) + return nil + }) if err != nil { return nil, err } - return bmToReturn, nil + + return bytesToBookMark(buf) } diff --git a/internal/config.go b/internal/util.go similarity index 80% rename from internal/config.go rename to internal/util.go index f89b4c8..bcec294 100644 --- a/internal/config.go +++ b/internal/util.go @@ -1,6 +1,7 @@ package internal import ( + "encoding/json" "errors" "os" "os/exec" @@ -15,9 +16,8 @@ var ( ) type Config struct { - DbPath string `json:"dbpath"` - BookMarks map[string]*BookMark `json:"bookmarks"` - LastOpened string `json:"lastopened"` + DbPath string `json:"dbpath"` + LastOpened string `json:"lastopened"` } func (c *Config) writeConfig() error { @@ -37,13 +37,14 @@ func (c *Config) writeConfig() error { } // Get config from system +// Returns an error if the Config cannot be found func getConfig() (*Config, error) { var cfg Config path := getConfigPath() _, err := os.Stat(path) // Create the config files if they don't exist - if os.IsNotExist(err) { - return initConfig() + if err != nil { + return nil, err } // Read in Config yamlBytes, err := os.ReadFile(path) @@ -64,33 +65,39 @@ func getConfigPath() string { // Returns the absolute path to the db file func getDbPath() string { - return configDir + "bookworm.db" + return configDir + "worm.db" } // Writes a new config & returns an *os.File // This will write to ~/.config/bookworm/config.yml +// .. or it will blow up func initConfig() (*Config, error) { configInfo, err := os.Stat(configDir) + // Create the config.yml if it's not there if os.IsNotExist(err) { - errr := os.Mkdir(configDir, 0777) - if errr != nil { - return nil, errr + err = os.Mkdir(configDir, 0666) + if err != nil { + return nil, err } - } else if err != nil { + } + if err != nil { return nil, err } if !configInfo.IsDir() { return nil, errors.New("~/.config/bookworm is not a directory!") } - _, err = os.Create(getConfigPath()) + _, err = os.Create( + getConfigPath(), + ) if err != nil { return nil, err } - return &Config{ + cfg := &Config{ DbPath: getDbPath(), - BookMarks: make(map[string]*BookMark), LastOpened: "nothing... yet", - }, nil + } + err = cfg.writeConfig() + return cfg, err } // openURL opens the specified URL in the default browser of the user. @@ -137,3 +144,12 @@ func IsValidUrl(url string) bool { return strings.Contains(url, "https://") || strings.Contains(url, "http://") } + +func bytesToBookMark(buf []byte) (*BookMark, error) { + bmToReturn := &BookMark{} + err := json.Unmarshal(buf, bmToReturn) + if err != nil { + return nil, err + } + return bmToReturn, nil +}