diff --git a/app/widget_maker.go b/app/widget_maker.go index f3ce6d5e..244b30cf 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -40,6 +40,7 @@ import ( "github.com/wtfutil/wtf/modules/newrelic" "github.com/wtfutil/wtf/modules/opsgenie" "github.com/wtfutil/wtf/modules/pagerduty" + "github.com/wtfutil/wtf/modules/pocket" "github.com/wtfutil/wtf/modules/power" "github.com/wtfutil/wtf/modules/resourceusage" "github.com/wtfutil/wtf/modules/rollbar" @@ -202,6 +203,9 @@ func MakeWidget( case "prettyweather": settings := prettyweather.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = prettyweather.NewWidget(app, settings) + case "pocket": + settings := pocket.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = pocket.NewWidget(app, pages, settings) case "resourceusage": settings := resourceusage.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = resourceusage.NewWidget(app, settings) diff --git a/modules/pocket/client.go b/modules/pocket/client.go new file mode 100644 index 00000000..377dafa5 --- /dev/null +++ b/modules/pocket/client.go @@ -0,0 +1,261 @@ +package pocket + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +// Client pocket client Documention at https://getpocket.com/developer/docs/overview +type Client struct { + consumerKey string + accessToken *string + baseURL string + redirectURL string +} + +//NewClient returns a new PocketClient +func NewClient(consumerKey, redirectURL string) *Client { + return &Client{ + consumerKey: consumerKey, + redirectURL: redirectURL, + baseURL: "https://getpocket.com/v3", + } + +} + +//Item represents link in pocket api +type Item struct { + ItemID string `json:"item_id"` + ResolvedID string `json:"resolved_id"` + GivenURL string `json:"given_url"` + GivenTitle string `json:"given_title"` + Favorite string `json:"favorite"` + Status string `json:"status"` + TimeAdded string `json:"time_added"` + TimeUpdated string `json:"time_updated"` + TimeRead string `json:"time_read"` + TimeFavorited string `json:"time_favorited"` + SortID int `json:"sort_id"` + ResolvedTitle string `json:"resolved_title"` + ResolvedURL string `json:"resolved_url"` + Excerpt string `json:"excerpt"` + IsArticle string `json:"is_article"` + IsIndex string `json:"is_index"` + HasVideo string `json:"has_video"` + HasImage string `json:"has_image"` + WordCount string `json:"word_count"` + Lang string `json:"lang"` + TimeToRead int `json:"time_to_read"` + TopImageURL string `json:"top_image_url"` + ListenDurationEstimate int `json:"listen_duration_estimate"` +} + +//ItemLists represent list of links +type ItemLists struct { + Status int `json:"status"` + Complete int `json:"complete"` + List map[string]Item `json:"list"` + Since int `json:"since"` +} + +type request struct { + requestBody interface{} + method string + result interface{} + headers map[string]string + url string +} + +func (client *Client) request(req request, result interface{}) error { + jsonValues, err := json.Marshal(req.requestBody) + if err != nil { + return err + } + request, err := http.NewRequest(req.method, req.url, bytes.NewBuffer(jsonValues)) + if err != nil { + return err + } + + for key, value := range req.headers { + request.Header.Add(key, value) + } + + resp, err := http.DefaultClient.Do(request) + + if err != nil { + return err + } + defer resp.Body.Close() + + responseBody, err := ioutil.ReadAll(resp.Body) + + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf(`server responded with [%d]:%s,url:%s`, resp.StatusCode, responseBody, req.url) + } + + if err := json.Unmarshal(responseBody, &result); err != nil { + return fmt.Errorf(`Could not unmarshal url [%s] + response [%s] request[%s] error:%v`, req.url, responseBody, jsonValues, err) + } + + return nil + +} + +type obtainRequestTokenRequest struct { + ConsumerKey string `json:"consumer_key"` + RedirectURI string `json:"redirect_uri"` +} + +//ObtainRequestToken get request token to be used in the auth workflow +func (client *Client) ObtainRequestToken() (code string, err error) { + url := fmt.Sprintf("%s/oauth/request", client.baseURL) + requestData := obtainRequestTokenRequest{ConsumerKey: client.consumerKey, RedirectURI: client.redirectURL} + + var responseData map[string]string + req := request{ + method: "POST", + url: url, + requestBody: requestData, + } + req.headers = map[string]string{ + "X-Accept": "application/json", + "Content-Type": "application/json", + } + err = client.request(req, &responseData) + + if err != nil { + return code, err + } + + return responseData["code"], nil + +} + +//CreateAuthLink create authorization link to redirect the user to +func (client *Client) CreateAuthLink(requestToken string) string { + return fmt.Sprintf("https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s", requestToken, client.redirectURL) +} + +type accessTokenRequest struct { + ConsumerKey string `json:"consumer_key"` + RequestCode string `json:"code"` +} + +// accessTokenResponse represents +type accessTokenResponse struct { + AccessToken string `json:"access_token"` +} + +//GetAccessToken exchange request token for accesstoken +func (client *Client) GetAccessToken(requestToken string) (accessToken string, err error) { + url := fmt.Sprintf("%s/oauth/authorize", client.baseURL) + requestData := accessTokenRequest{ + ConsumerKey: client.consumerKey, + RequestCode: requestToken, + } + req := request{ + method: "POST", + url: url, + requestBody: requestData, + } + req.headers = map[string]string{ + "X-Accept": "application/json", + "Content-Type": "application/json", + } + + var response accessTokenResponse + err = client.request(req, &response) + if err != nil { + return "", err + } + return response.AccessToken, nil + +} + +/*LinkState represents links states to be retrived +According to the api https://getpocket.com/developer/docs/v3/retrieve +there are 3 states: + 1-archive + 2-unread + 3-all +however archive does not really well work and returns links that are in the +unread list +buy inspecting getpocket I found out that there is an undocumanted read state +*/ +type LinkState string + +const ( + //Read links that has been read (undocumanted) + Read LinkState = "read" + //Unread links has not been read + Unread LinkState = "unread" +) + +// GetLinks retrive links of a given states https://getpocket.com/developer/docs/v3/retrieve +func (client *Client) GetLinks(state LinkState) (response ItemLists, err error) { + url := fmt.Sprintf("%s/get?sort=newest&state=%s&consumer_key=%s&access_token=%s", client.baseURL, state, client.consumerKey, *client.accessToken) + req := request{ + method: "GET", + url: url, + } + req.headers = map[string]string{ + "X-Accept": "application/json", + "Content-Type": "application/json", + } + + err = client.request(req, &response) + return response, err +} + +//Action represents a mutation to link +type Action string + +const ( + //Archive to put the link in the archived list (read list) + Archive Action = "archive" + //ReAdd to put the link back in the to reed list + ReAdd Action = "readd" +) + +type actionParams struct { + Action Action `json:"action"` + ItemID string `json:"item_id"` +} + +//ModifyLink change the state of the link +func (client *Client) ModifyLink(action Action, itemID string) (ok bool, err error) { + + actions := []actionParams{ + { + Action: action, + ItemID: itemID, + }, + } + + urlActionParm, err := json.Marshal(actions) + if err != nil { + return false, err + } + url := fmt.Sprintf("%s/send?consumer_key=%s&access_token=%s&actions=%s", client.baseURL, client.consumerKey, *client.accessToken, urlActionParm) + + req := request{ + method: "GET", + url: url, + } + + err = client.request(req, nil) + + if err != nil { + return false, err + } + + return true, nil + +} diff --git a/modules/pocket/item_service.go b/modules/pocket/item_service.go new file mode 100644 index 00000000..4d3b7d2c --- /dev/null +++ b/modules/pocket/item_service.go @@ -0,0 +1,19 @@ +package pocket + +import "sort" + +type sortByTimeAdded []Item + +func (a sortByTimeAdded) Len() int { return len(a) } +func (a sortByTimeAdded) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a sortByTimeAdded) Less(i, j int) bool { return a[i].TimeAdded > a[j].TimeAdded } + +func orderItemResponseByKey(response ItemLists) []Item { + + var items sortByTimeAdded + for _, v := range response.List { + items = append(items, v) + } + sort.Sort(items) + return items +} diff --git a/modules/pocket/keyboard.go b/modules/pocket/keyboard.go new file mode 100644 index 00000000..2b3263c0 --- /dev/null +++ b/modules/pocket/keyboard.go @@ -0,0 +1,13 @@ +package pocket + +import "github.com/gdamore/tcell" + +func (widget *Widget) initializeKeyboardControls() { + + widget.InitializeCommonControls(widget.Refresh) + widget.SetKeyboardChar("a", widget.toggleLink, "Toggle Link") + widget.SetKeyboardChar("t", widget.toggleView, "Toggle view (links ,archived links)") + widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select Next Link") + widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select Previous Link") + widget.SetKeyboardKey(tcell.KeyEnter, widget.openLink, "Open Link in the browser") +} diff --git a/modules/pocket/settings.go b/modules/pocket/settings.go new file mode 100644 index 00000000..df79216c --- /dev/null +++ b/modules/pocket/settings.go @@ -0,0 +1,27 @@ +package pocket + +import ( + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" +) + +const ( + defaultFocusable = true + defaultTitle = "Pocket" +) + +type Settings struct { + common *cfg.Common + consumerKey string + requestKey *string + accessToken *string +} + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), + consumerKey: ymlConfig.UString("consumerKey"), + } + + return &settings +} diff --git a/modules/pocket/widget.go b/modules/pocket/widget.go new file mode 100644 index 00000000..19927816 --- /dev/null +++ b/modules/pocket/widget.go @@ -0,0 +1,232 @@ +package pocket + +import ( + "fmt" + "io/ioutil" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/logger" + "github.com/wtfutil/wtf/utils" + "github.com/wtfutil/wtf/view" + "gopkg.in/yaml.v2" +) + +type Widget struct { + view.ScrollableWidget + view.KeyboardWidget + + settings *Settings + client *Client + items []Item + archivedView bool +} + +func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := Widget{ + KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common), + ScrollableWidget: view.NewScrollableWidget(app, settings.common), + settings: settings, + client: NewClient(settings.consumerKey, "http://localhost"), + archivedView: false, + } + + widget.CommonSettings() + widget.View.SetInputCapture(widget.InputCapture) + widget.SetRenderFunction(widget.Render) + widget.View.SetScrollable(true) + widget.View.SetRegions(true) + widget.KeyboardWidget.SetView(widget.View) + widget.initializeKeyboardControls() + widget.Selected = -1 + widget.SetItemCount(0) + return &widget +} + +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) Render() { + + widget.Redraw(widget.content) +} + +func (widget *Widget) Refresh() { + if widget.client.accessToken == nil { + metaData, err := readMetaDataFromDisk() + if err != nil || metaData.AccessToken == nil { + widget.Redraw(widget.authorizeWorkFlow) + return + } + widget.client.accessToken = metaData.AccessToken + } + + state := Unread + if widget.archivedView == true { + state = Read + } + response, err := widget.client.GetLinks(state) + if err != nil { + widget.SetItemCount(0) + } + + widget.items = orderItemResponseByKey(response) + widget.SetItemCount(len(widget.items)) + widget.Redraw(widget.content) +} + +/* -------------------- Unexported Functions -------------------- */ + +type pocketMetaData struct { + AccessToken *string +} + +func writeMetaDataToDisk(metaData pocketMetaData) error { + + fileData, err := yaml.Marshal(metaData) + if err != nil { + return fmt.Errorf("Could not write token to disk %v", err) + } + + wtfConfigDir, err := cfg.WtfConfigDir() + + if err != nil { + return nil + } + + filePath := fmt.Sprintf("%s/%s", wtfConfigDir, "pocket.data") + err = ioutil.WriteFile(filePath, fileData, 0644) + + return err +} + +func readMetaDataFromDisk() (pocketMetaData, error) { + wtfConfigDir, err := cfg.WtfConfigDir() + var metaData pocketMetaData + if err != nil { + return metaData, err + } + filePath := fmt.Sprintf("%s/%s", wtfConfigDir, "pocket.data") + fileData, err := utils.ReadFileBytes(filePath) + + if err != nil { + return metaData, err + } + + err = yaml.Unmarshal(fileData, &metaData) + + return metaData, err + +} + +/* + Authorization workflow is documented at https://getpocket.com/developer/docs/authentication + broken to 4 steps : + 1- Obtain a platform consumer key from http://getpocket.com/developer/apps/new. + 2- Obtain a request token + 3- Redirect user to Pocket to continue authorization + 4- Receive the callback from Pocket, this wont be used + 5- Convert a request token into a Pocket access token +*/ +func (widget *Widget) authorizeWorkFlow() (string, string, bool) { + title := widget.CommonSettings().Title + + if widget.settings.requestKey == nil { + requestToken, err := widget.client.ObtainRequestToken() + + if err != nil { + logger.Log(err.Error()) + return title, err.Error(), true + } + widget.settings.requestKey = &requestToken + redirectURL := widget.client.CreateAuthLink(requestToken) + content := fmt.Sprintf("Please click on %s to Authorize the app", redirectURL) + return title, content, true + } + + if widget.settings.accessToken == nil { + accessToken, err := widget.client.GetAccessToken(*widget.settings.requestKey) + if err != nil { + logger.Log(err.Error()) + redirectURL := widget.client.CreateAuthLink(*widget.settings.requestKey) + content := fmt.Sprintf("Please click on %s to Authorize the app", redirectURL) + return title, content, true + } + content := "Authorized" + widget.settings.accessToken = &accessToken + + metaData := pocketMetaData{ + AccessToken: &accessToken, + } + + err = writeMetaDataToDisk(metaData) + if err != nil { + content = err.Error() + } + + return title, content, true + } + + content := "Authorized" + return title, content, true + +} + +func (widget *Widget) toggleView() { + widget.archivedView = !widget.archivedView + widget.Refresh() +} + +func (widget *Widget) openLink() { + sel := widget.GetSelected() + if sel >= 0 && widget.items != nil && sel < len(widget.items) { + item := &widget.items[sel] + utils.OpenFile(item.GivenURL) + } +} + +func (widget *Widget) toggleLink() { + sel := widget.GetSelected() + action := Archive + if widget.archivedView == true { + action = ReAdd + } + + if sel >= 0 && widget.items != nil && sel < len(widget.items) { + item := &widget.items[sel] + _, err := widget.client.ModifyLink(action, item.ItemID) + if err != nil { + logger.Log(err.Error()) + } + } + + widget.Refresh() +} + +func (widget *Widget) formatItem(item Item, isSelected bool) string { + foreColor, backColor := widget.settings.common.Colors.RowTheme.EvenForeground, widget.settings.common.Colors.RowTheme.EvenBackground + text := item.ResolvedTitle + if isSelected == true { + foreColor = widget.settings.common.Colors.RowTheme.HighlightedForeground + backColor = widget.settings.common.Colors.RowTheme.HighlightedBackground + + } + + return fmt.Sprintf("[%s:%s]%s[white]", foreColor, backColor, tview.Escape(text)) +} + +func (widget *Widget) content() (string, string, bool) { + title := widget.CommonSettings().Title + currentViewTitle := "Reading List" + if widget.archivedView == true { + currentViewTitle = "Archived list" + } + + title = fmt.Sprintf("%s-%s", title, currentViewTitle) + content := "" + + for i, v := range widget.items { + content += widget.formatItem(v, i == widget.Selected) + "\n" + } + + return title, content, false +}