From 4498ba10e837ab736bd29164aef9856002983a4a Mon Sep 17 00:00:00 2001 From: Ztec Date: Tue, 25 Jun 2024 17:10:21 +0200 Subject: [PATCH] Fix: Integration with Pinboard: Preserve Existing Bookmarks The Issue: When saving an entry that is already bookmarked on Pinboard, Miniflux was overriding all existing data on Pinboard. This action removed any extended content or, worse, changed the private settings to public, making previously private bookmarks publicly available. The Fix: Now, upon saving an entry as a bookmark, I first fetch it. If it already exists, I apply the necessary modifications (adding tags and any state) that Miniflux would have normally done, then add it again. This way, no data is lost in the process. Pinboard has a stable API, so I don't anticipate any new fields being added soon. I manually tested the integration by hitting the save button in the following situations: - Entry URL does not exist on Pinboard: - Bookmark is properly added on Pinboard with tags and "to read" status according to Miniflux settings. - Entry URL already exists on Pinboard: - Existing data remains unchanged. - Tags from Miniflux settings are properly added to the bookmark. - "To read" status is set to yes when the option is checked in Miniflux. Nothing is changed otherwise. --- internal/integration/pinboard/pinboard.go | 69 ++++++++++++++++++++--- internal/integration/pinboard/post.go | 62 ++++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 internal/integration/pinboard/post.go 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) +}