diff --git a/cmd/pkt/config.go b/cmd/pkt/config.go deleted file mode 100644 index 61e115b..0000000 --- a/cmd/pkt/config.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "log" - "os" - "os/user" - "path/filepath" -) - -func getBoltPath() string { - return filepath.Join(getConfigDir(), "pkt.bolt") -} - -func getConfigPath() string { - return filepath.Join(getConfigDir(), "config.json") -} - -func getConfigDir() string { - user, err := user.Current() - if err != nil { - log.Fatal(err) - } - - configDir := filepath.Join(user.HomeDir, ".config", "pkt") - err = os.MkdirAll(configDir, 0777) - if err != nil { - log.Fatal(err) - } - - return configDir -} diff --git a/cmd/pkt/main.go b/cmd/pkt/main.go index a0d6fba..e334c20 100644 --- a/cmd/pkt/main.go +++ b/cmd/pkt/main.go @@ -2,16 +2,16 @@ package main import ( "bufio" - "encoding/json" "flag" "fmt" - "io/ioutil" "log" "os" "strings" "github.com/tarrsalah/pkt" - "github.com/tarrsalah/pkt/store/bolt" + "github.com/tarrsalah/pkt/internal/bolt" + "github.com/tarrsalah/pkt/internal/config" + "github.com/tarrsalah/pkt/internal/ui" ) var ( @@ -68,13 +68,10 @@ run %s for usage. } func show() { - boltPath := getBoltPath() - configPath := getConfigPath() - - db := bolt.NewDB(boltPath) + db := bolt.NewDB() defer db.Close() - auth := loadAuth(configPath) + auth := config.GetAuth() client := pkt.NewClient(auth) oldItems := db.Get() @@ -91,44 +88,18 @@ func show() { db.Put(newItems) items := db.Get() - draw(items) + app := ui.NewWindow(items) + app.Run() } func auth() { - configPath := getConfigPath() r := bufio.NewReader(os.Stdin) fmt.Print("pkt: enter your consumer key: ") key, _ := r.ReadString('\n') client := pkt.NewClient(nil) auth := client.Authenticate(strings.TrimSpace(key)) - saveAuth(auth, configPath) + config.PutAuth(auth) log.Println("authorized!") } - -func loadAuth(configPath string) *pkt.Auth { - auth := &pkt.Auth{} - configFile, err := ioutil.ReadFile(configPath) - if err != nil { - log.Fatal(err) - } - - err = json.Unmarshal(configFile, auth) - if err != nil { - log.Fatal(err) - } - - return auth -} - -func saveAuth(auth *pkt.Auth, configPath string) { - configFile, err := json.MarshalIndent(auth, " ", " ") - if err != nil { - log.Fatal(err) - } - err = ioutil.WriteFile(configPath, configFile, 0644) - if err != nil { - log.Fatal(err) - } -} diff --git a/cmd/pkt/ui.go b/cmd/pkt/ui.go deleted file mode 100644 index b9cb9cb..0000000 --- a/cmd/pkt/ui.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/pkg/browser" - "github.com/rivo/tview" - "github.com/tarrsalah/pkt" -) - -func draw(items []pkt.Item) { - title := fmt.Sprintf("Pocket items (%d)", len(items)) - itemsTable := tview.NewTable().SetSelectable(true, false). - Select(0, 0).SetFixed(1, 1) - - itemsTable.SetTitle(title).SetTitleAlign(tview.AlignLeft) - itemsTable.SetBorder(true) - - headers := []string{ - "Title", - "tags", - } - - for i, header := range headers { - itemsTable.SetCell(0, i, &tview.TableCell{ - Text: header, - NotSelectable: true, - Align: tview.AlignLeft, - Color: tcell.ColorWhite, - BackgroundColor: tcell.ColorBlack, - Attributes: tcell.AttrBold, - }) - } - - for i, item := range items { - tags := "" - if len(item.Tags()) > 0 { - tags = "[yellow]" - for _, tag := range item.Tags() { - tags += fmt.Sprintf("(%s)", tag) - } - } - title := fmt.Sprintf("%d. %s [green](%s)", - i+1, item.Title(), - item.Host()) - - itemsTable.SetCell(i+1, 0, tview.NewTableCell(title). - SetTextColor(tcell.ColorLightYellow). - SetMaxWidth(1). - SetExpansion(3)) - - itemsTable.SetCell(i+1, 1, tview.NewTableCell(tags). - SetTextColor(tcell.ColorLightYellow). - SetMaxWidth(1). - SetExpansion(2)) - } - - itemsTable.SetSelectedFunc(func(row, column int) { - current := items[row-1] - browser.OpenURL(current.Url()) - }) - - app := tview.NewApplication() - grid := tview.NewGrid().SetRows(1). - AddItem(itemsTable, 0, 0, 2, 1, 0, 0, true) - - pages := tview.NewPages(). - AddAndSwitchToPage("main", grid, true) - - app.SetRoot(pages, true) - err := app.Run() - if err != nil { - panic(err) - } -} diff --git a/store/bolt/bolt.go b/internal/bolt/storage.go similarity index 88% rename from store/bolt/bolt.go rename to internal/bolt/storage.go index 1d9e017..f900aff 100644 --- a/store/bolt/bolt.go +++ b/internal/bolt/storage.go @@ -6,7 +6,9 @@ import ( "sort" "github.com/tarrsalah/pkt" + "github.com/tarrsalah/pkt/internal/config" "go.etcd.io/bbolt" + "path/filepath" ) var bucketName = []byte("pkt") @@ -15,7 +17,8 @@ type DB struct { db *bbolt.DB } -func NewDB(path string) DB { +func NewDB() DB { + path := filepath.Join(config.Dir(), "pkt.bolt") db, err := bbolt.Open(path, 0666, nil) if err != nil { log.Fatal(err) @@ -73,7 +76,7 @@ func (b DB) Put(items []pkt.Item) { if err != nil { return err } - b.Put([]byte(item.Id), v) + b.Put([]byte(item.ID), v) } return nil diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ab64364 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,61 @@ +package config + +import ( + "log" + "os" + "os/user" + "path/filepath" + + "encoding/json" + "github.com/tarrsalah/pkt" + "io/ioutil" +) + +// GetAuth returns the saved pocket credentials +func GetAuth() *pkt.Auth { + auth := &pkt.Auth{} + configFile, err := ioutil.ReadFile(Path()) + if err != nil { + log.Fatal(err) + } + + err = json.Unmarshal(configFile, auth) + if err != nil { + log.Fatal(err) + } + + return auth +} + +// PutAuth saves the pocket credentials into a file +func PutAuth(auth *pkt.Auth) { + content, err := json.MarshalIndent(auth, " ", " ") + if err != nil { + log.Fatal(err) + } + err = ioutil.WriteFile(Path(), content, 0644) + if err != nil { + log.Fatal(err) + } +} + +// Dir returns the path of the confi directory +func Dir() string { + user, err := user.Current() + if err != nil { + log.Fatal(err) + } + + configDir := filepath.Join(user.HomeDir, ".config", "pkt") + err = os.MkdirAll(configDir, 0777) + if err != nil { + log.Fatal(err) + } + + return configDir +} + +// Path returns the Path of the config file +func Path() string { + return filepath.Join(Dir(), "config.json") +} diff --git a/internal/ui/items_table.go b/internal/ui/items_table.go new file mode 100644 index 0000000..eca231d --- /dev/null +++ b/internal/ui/items_table.go @@ -0,0 +1,53 @@ +package ui + +import ( + "fmt" + "github.com/rivo/tview" + "github.com/tarrsalah/pkt" +) + +type itemsTable struct { + *tview.Table + items pkt.Items + handleSelect func(int, int) +} + +func newItemsTable() *itemsTable { + t := &itemsTable{ + Table: tview.NewTable(), + } + + t.SetBorder(true) + t.SetSelectable(true, false) + + return t +} + +func (t *itemsTable) Render() { + t.Clear() + for i, item := range t.items { + tags := fmt.Sprintf("[yellow] %s", item.Tags()) + title := fmt.Sprintf("%d. %s [green](%s)", + i+1, item.Title(), + item.Host()) + + t.SetCell(i, 0, tview.NewTableCell(title). + SetMaxWidth(1). + SetExpansion(3)) + + t.SetCell(i, 1, tview.NewTableCell(tags). + SetMaxWidth(1). + SetExpansion(2)) + } + + t.SetSelectedFunc(t.handleSelect) + t.SetTitle(t.title()) +} + +func (t *itemsTable) title() string { + count := len(t.items) + if count > 0 { + return fmt.Sprintf("Pocket items (%d)", count) + } + return "Pocket items" +} diff --git a/internal/ui/tags_table.go b/internal/ui/tags_table.go new file mode 100644 index 0000000..2b21e29 --- /dev/null +++ b/internal/ui/tags_table.go @@ -0,0 +1,52 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/tarrsalah/pkt" +) + +type tagsTable struct { + *tview.Table + tags pkt.Tags + selectedTags map[int]struct{} + handleSelect func(int, int) +} + +func newTagsTable() *tagsTable { + t := &tagsTable{ + Table: tview.NewTable(), + } + + t.SetBorder(true) + t.SetSelectable(true, false) + + return t +} + +func (t *tagsTable) Render() { + t.Clear() + for i, tag := range t.tags { + cell := tview.NewTableCell(tag.Label) + + if _, ok := t.selectedTags[i]; ok { + cell.SetTextColor(tcell.ColorYellow) + cell.SetAttributes(tcell.AttrUnderline | tcell.AttrBold) + } + t.Table.SetCell(i, 0, cell) + + } + t.SetSelectedFunc(t.handleSelect) + t.SetTitle(t.title()) +} + +func (t *tagsTable) title() string { + l := len(t.tags) + if l > 0 { + return fmt.Sprintf("Tags (%d)", len(t.tags)) + } + + return "Tags" +} diff --git a/internal/ui/window.go b/internal/ui/window.go new file mode 100644 index 0000000..f8b521b --- /dev/null +++ b/internal/ui/window.go @@ -0,0 +1,139 @@ +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/pkg/browser" + "github.com/rivo/tview" + "github.com/tarrsalah/pkt" + + "sort" +) + +// The Window model +type Window struct { + *tview.Application + + items pkt.Items + selectedItems pkt.Items + tags pkt.Tags + selectedTags map[int]struct{} + + itemsTable *itemsTable + tagsTable *tagsTable + + widgets []tview.Primitive + currentWidget int +} + +func (w *Window) nextWidget() { + next := w.currentWidget + 1 + if next >= len(w.widgets) { + next = 0 + } + + w.Application.SetFocus(w.widgets[next]) + w.currentWidget = next + +} + +func (w *Window) handleSelectItem(i, j int) { + browser.OpenURL(w.selectedItems[i].URL()) +} + +func (w *Window) handleSelectTag(i, j int) { + if _, ok := w.selectedTags[i]; ok { + delete(w.selectedTags, i) + } else { + w.selectedTags[i] = struct{}{} + } + + // TODO: move this logic to the business models + // Show all pocket items + if len(w.selectedTags) == 0 { + w.selectedItems = w.items + w.Render() + return + } + + // Show only pocket items with tag + w.selectedItems = make([]pkt.Item, 0) + + for _, item := range w.items { + isTagged := false + + for _, tag := range item.Tags() { + for i := range w.selectedTags { + if w.tags[i].Label == tag.Label { + isTagged = true + break + } + } + if isTagged { + break + } + } + + if isTagged { + w.selectedItems = append(w.selectedItems, item) + } + } + w.Render() +} + +// NewWindow returns a new UI window +func NewWindow(items pkt.Items) *Window { + w := &Window{ + Application: tview.NewApplication(), + selectedTags: make(map[int]struct{}), + } + + w.items = items + w.selectedItems = w.items + w.tags = w.items.Tags() + sort.Sort(w.tags) + + w.itemsTable = newItemsTable() + w.tagsTable = newTagsTable() + + w.itemsTable.handleSelect = w.handleSelectItem + w.tagsTable.handleSelect = w.handleSelectTag + + w.widgets = append(w.widgets, w.itemsTable) + w.widgets = append(w.widgets, w.tagsTable) + + flex := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(w.tagsTable, 0, 1, true). + AddItem(w.itemsTable, 0, 5, true) + + pages := tview.NewPages(). + AddAndSwitchToPage("main", flex, true) + + w.SetRoot(pages, true).EnableMouse(true) + + w.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyTAB: + w.nextWidget() + return event + default: + return event + } + }) + + w.Render() + w.Application.SetFocus(w.widgets[w.currentWidget]) + + return w +} + +// Render the children +func (w *Window) Render() { + w.itemsTable.items = w.selectedItems + + w.tagsTable.tags = w.tags + w.tagsTable.selectedTags = w.selectedTags + + w.itemsTable.Render() + w.tagsTable.Render() +} diff --git a/models.go b/models.go index f3c2a57..ea52aba 100644 --- a/models.go +++ b/models.go @@ -1,29 +1,61 @@ package pkt -import "net/url" +import ( + "fmt" + "net/url" +) -const PAGE_COUNT = 100 +// PageCount is the default response page count +const PageCount = 100 +// Tag is a packet tag type Tag struct { - Id string `json:"item_id"` + ID string `json:"item_id"` Label string `json:"tag"` } -type Store interface { - Get() []Item - Put([]Item) - Close() +func (t Tag) String() string { + return t.Label } +// Tags is a set of Tags +type Tags []Tag + +// Len return the length of a list of tags +func (t Tags) Len() int { + return len(t) +} + +// Swap swaps two tags +func (t Tags) Swap(i, j int) { + t[i], t[j] = t[j], t[i] +} + +// Less compares two tags by label +func (t Tags) Less(i, j int) bool { + return t[i].Label < t[j].Label +} + +func (t Tags) String() string { + s := "" + for _, tag := range t { + s += fmt.Sprintf("(%s)", tag) + } + + return s +} + +// Item is the poeckt item type Item struct { - Id string `json:"item_id"` - GivenUrl string `json:"given_url"` + ID string `json:"item_id"` + GivenURL string `json:"given_url"` GivenTitle string `json:"given_title"` ResolvedTitle string `json:"resolved_title"` AddedAt string `json:"time_added"` TagsMap map[string]Tag `json:"tags"` } +// Title gets the item's title func (item Item) Title() string { if len(item.ResolvedTitle) > 3 { return item.ResolvedTitle @@ -32,20 +64,78 @@ func (item Item) Title() string { return item.GivenTitle } -func (item Item) Url() string { - return item.GivenUrl +// URL returns the item's given URL +func (item Item) URL() string { + return item.GivenURL } +// Host returns the item's given URL host func (item Item) Host() string { - itemUrl, _ := url.Parse(item.GivenUrl) - return itemUrl.Host + itemURL, _ := url.Parse(item.GivenURL) + return itemURL.Host } -func (item Item) Tags() []string { - tags := []string{} +// Tags returns the list of the item's tag +func (item Item) Tags() Tags { + tags := []Tag{} for _, tag := range item.TagsMap { - tags = append(tags, tag.Label) + tags = append(tags, tag) } return tags } + +// Items is a list of items +type Items []Item + +// Tags return a list of tags from a list of items +func (items Items) Tags() Tags { + tags := []Tag{} + tagsMap := map[string]Tag{} + + _ = tagsMap + + for _, item := range items { + for _, tag := range item.Tags() { + tagsMap[tag.Label] = tag + } + } + + for _, tag := range tagsMap { + tags = append(tags, tag) + } + + return tags +} + +// getTaggedItems filter a list of items +func getTaggedItems(items []Item, filters []string) []Item { + if len(filters) == 0 { + filteredItems := make([]Item, len(items)) + copy(filteredItems, items) + return filteredItems + } + + filteredItems := []Item{} + for _, item := range items { + isTagged := false + for _, tag := range item.Tags() { + for _, filter := range filters { + if filter == tag.Label { + isTagged = true + break + } + } + + if isTagged { + break + } + } + + if isTagged { + filteredItems = append(filteredItems, item) + } + } + + return filteredItems +} diff --git a/models_test.go b/models_test.go new file mode 100644 index 0000000..27f0cde --- /dev/null +++ b/models_test.go @@ -0,0 +1,55 @@ +package pkt + +import ( + "testing" +) + +func TestGetTaggedItems(t *testing.T) { + items := []Item{ + { + TagsMap: map[string]Tag{ + "elixir": {Label: "elixir"}, + }, + }, + + { + TagsMap: map[string]Tag{ + "elixir": {Label: "elixir"}, + }, + }, + + { + TagsMap: map[string]Tag{ + "ruby": {Label: "ruby"}, + "python": {Label: "python"}, + }, + }, + + { + TagsMap: map[string]Tag{ + "elixir": {Label: "elixir"}, + "golang": {Label: "golang"}, + "python": {Label: "python"}, + }, + }, + } + + tests := []struct { + tags []string + result int + }{ + {[]string{"elixir"}, 3}, + {[]string{"python"}, 2}, + {[]string{"ruby"}, 1}, + {[]string{"golang"}, 1}, + } + + for _, tc := range tests { + filteredItems := getTaggedItems(items, tc.tags) + got := len(filteredItems) + + if len(filteredItems) != tc.result { + t.Errorf("Expected %d item(s) with %v filters, got %d", tc.result, tc.tags, got) + } + } +} diff --git a/retrieve.go b/retrieve.go index 49729e2..37377be 100644 --- a/retrieve.go +++ b/retrieve.go @@ -13,7 +13,7 @@ func (c *Client) Retrieve(after string, offset int) ([]Item, error) { Offset: offset, DetailType: "complete", Sort: "newest", - Count: PAGE_COUNT, + Count: PageCount, }, } response := retrieveResponse{} @@ -37,7 +37,7 @@ func (c *Client) RetrieveAll(after string) ([]Item, error) { } items = append(items, retrieved...) - offset = offset + PAGE_COUNT + offset = offset + PageCount } return items, nil