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.
This commit is contained in:
Ztec 2024-06-25 17:10:21 +02:00 committed by Frédéric Guillot
parent a46e702536
commit 4498ba10e8
2 changed files with 124 additions and 7 deletions

View file

@ -4,6 +4,8 @@
package pinboard // import "miniflux.app/v2/internal/integration/pinboard" package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
import ( import (
"encoding/xml"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -12,6 +14,9 @@ import (
"miniflux.app/v2/internal/version" "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 const defaultClientTimeout = 10 * time.Second
type Client struct { type Client struct {
@ -24,20 +29,27 @@ func NewClient(authToken string) *Client {
func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error { func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
if c.authToken == "" { 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 { if markAsUnread {
toRead = "yes" post.SetToread()
} }
values := url.Values{} values := url.Values{}
values.Add("auth_token", c.authToken) values.Add("auth_token", c.authToken)
values.Add("url", entryURL) post.AddValues(values)
values.Add("description", entryTitle)
values.Add("tags", pinboardTags)
values.Add("toread", toRead)
apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode() apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil) request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
@ -61,3 +73,46 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
return nil 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
}

View file

@ -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)
}