diff --git a/internal/integration/pinboard/pinboard.go b/internal/integration/pinboard/pinboard.go index a0cb6a51..adc2a30f 100644 --- a/internal/integration/pinboard/pinboard.go +++ b/internal/integration/pinboard/pinboard.go @@ -4,6 +4,8 @@ package pinboard // import "miniflux.app/v2/internal/integration/pinboard" import ( + "encoding/xml" + "errors" "fmt" "net/http" "net/url" @@ -12,6 +14,9 @@ import ( "miniflux.app/v2/internal/version" ) +var errPostNotFound = fmt.Errorf("pinboard: post not found") +var errMissingCredentials = fmt.Errorf("pinboard: missing auth token") + const defaultClientTimeout = 10 * time.Second type Client struct { @@ -24,20 +29,27 @@ func NewClient(authToken string) *Client { func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error { if c.authToken == "" { - return fmt.Errorf("pinboard: missing auth token") + return errMissingCredentials } - toRead := "no" + // We check if the url is already bookmarked to avoid overriding existing data. + post, err := c.getBookmark(entryURL) + + if err != nil && errors.Is(err, errPostNotFound) { + post = NewPost(entryURL, entryTitle) + } else if err != nil { + // In case of any other error, we return immediately to avoid overriding existing data. + return err + } + + post.addTag(pinboardTags) if markAsUnread { - toRead = "yes" + post.SetToread() } values := url.Values{} values.Add("auth_token", c.authToken) - values.Add("url", entryURL) - values.Add("description", entryTitle) - values.Add("tags", pinboardTags) - values.Add("toread", toRead) + post.AddValues(values) apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode() request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil) @@ -61,3 +73,46 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA return nil } + +// getBookmark fetches a bookmark from Pinboard. https://www.pinboard.in/api/#posts_get +func (c *Client) getBookmark(entryURL string) (*Post, error) { + if c.authToken == "" { + return nil, errMissingCredentials + } + + values := url.Values{} + values.Add("auth_token", c.authToken) + values.Add("url", entryURL) + + apiEndpoint := "https://api.pinboard.in/v1/posts/get?" + values.Encode() + request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("pinboard: unable to create request: %v", err) + } + + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("User-Agent", "Miniflux/"+version.Version) + + httpClient := &http.Client{Timeout: defaultClientTimeout} + response, err := httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err) + } + defer response.Body.Close() + + if response.StatusCode >= 400 { + return nil, fmt.Errorf("pinboard: unable to fetch bookmark, status=%d", response.StatusCode) + } + + var results posts + err = xml.NewDecoder(response.Body).Decode(&results) + if err != nil { + return nil, fmt.Errorf("pinboard: unable to decode XML: %v", err) + } + + if len(results.Posts) == 0 { + return nil, errPostNotFound + } + + return &results.Posts[0], nil +} diff --git a/internal/integration/pinboard/post.go b/internal/integration/pinboard/post.go new file mode 100644 index 00000000..46cbf7e8 --- /dev/null +++ b/internal/integration/pinboard/post.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package pinboard // import "miniflux.app/v2/internal/integration/pinboard" + +import ( + "encoding/xml" + "net/url" + "strings" + "time" +) + +// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42 +type Post struct { + XMLName xml.Name `xml:"post"` + Url string `xml:"href,attr"` + Description string `xml:"description,attr"` + Tags string `xml:"tag,attr"` + Extended string `xml:"extended,attr"` + Date time.Time `xml:"time,attr"` + Shared string `xml:"shared,attr"` + Toread string `xml:"toread,attr"` +} + +// Posts A result of a Pinboard API call +type posts struct { + XMLName xml.Name `xml:"posts"` + Posts []Post `xml:"post"` +} + +func NewPost(url string, description string) *Post { + return &Post{ + Url: url, + Description: description, + Date: time.Now(), + Toread: "no", + } +} + +func (p *Post) addTag(tag string) { + if !strings.Contains(p.Tags, tag) { + p.Tags += " " + tag + } +} + +func (p *Post) SetToread() { + p.Toread = "yes" +} + +func (p *Post) AddValues(values url.Values) { + values.Add("url", p.Url) + values.Add("description", p.Description) + values.Add("tags", p.Tags) + if p.Toread != "" { + values.Add("toread", p.Toread) + } + if p.Shared != "" { + values.Add("shared", p.Shared) + } + values.Add("dt", p.Date.Format(time.RFC3339)) + values.Add("extended", p.Extended) +}