diff --git a/internal/database/migrations.go b/internal/database/migrations.go index fa3c3972..df2a5dc7 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -888,4 +888,14 @@ var migrations = []func(tx *sql.Tx) error{ _, err = tx.Exec(`DROP INDEX entries_feed_url_idx`) return err }, + func(tx *sql.Tx) (err error) { + sql := ` + ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f'; + ALTER TABLE integrations ADD COLUMN raindrop_token text default ''; + ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default ''; + ALTER TABLE integrations ADD COLUMN raindrop_tags text default ''; + ` + _, err = tx.Exec(sql) + return err + }, } diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 710679ff..64447bc9 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -19,6 +19,7 @@ import ( "miniflux.app/v2/internal/integration/omnivore" "miniflux.app/v2/internal/integration/pinboard" "miniflux.app/v2/internal/integration/pocket" + "miniflux.app/v2/internal/integration/raindrop" "miniflux.app/v2/internal/integration/readeck" "miniflux.app/v2/internal/integration/readwise" "miniflux.app/v2/internal/integration/shaarli" @@ -359,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) { ) } } + if userIntegrations.OmnivoreEnabled { slog.Debug("Sending entry to Omnivore", slog.Int64("user_id", userIntegrations.UserID), @@ -376,6 +378,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) { ) } } + + if userIntegrations.RaindropEnabled { + slog.Debug("Sending entry to Raindrop", + slog.Int64("user_id", userIntegrations.UserID), + slog.Int64("entry_id", entry.ID), + slog.String("entry_url", entry.URL), + ) + + client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags) + if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil { + slog.Error("Unable to send entry to Raindrop", + slog.Int64("user_id", userIntegrations.UserID), + slog.Int64("entry_id", entry.ID), + slog.String("entry_url", entry.URL), + slog.Any("error", err), + ) + } + } } // PushEntries pushes a list of entries to activated third-party providers during feed refreshes. diff --git a/internal/integration/raindrop/raindrop.go b/internal/integration/raindrop/raindrop.go new file mode 100644 index 00000000..52506db1 --- /dev/null +++ b/internal/integration/raindrop/raindrop.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package raindrop // import "miniflux.app/v2/internal/integration/raindrop" + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "miniflux.app/v2/internal/version" +) + +const defaultClientTimeout = 10 * time.Second + +type Client struct { + token string + collectionID string + tags []string +} + +func NewClient(token, collectionID, tags string) *Client { + return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")} +} + +// https://developer.raindrop.io/v1/raindrops/single#create-raindrop +func (c *Client) CreateRaindrop(entryURL, entryTitle string) error { + if c.token == "" { + return fmt.Errorf("raindrop: missing token") + } + + var request *http.Request + requestBodyJson, err := json.Marshal(&raindrop{ + Link: entryURL, + Title: entryTitle, + Collection: collection{Id: c.collectionID}, + Tags: c.tags, + }) + if err != nil { + return fmt.Errorf("raindrop: unable to encode request body: %v", err) + } + + request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson)) + if err != nil { + return fmt.Errorf("raindrop: unable to create request: %v", err) + } + request.Header.Set("Content-Type", "application/json") + + request.Header.Set("User-Agent", "Miniflux/"+version.Version) + request.Header.Set("Authorization", "Bearer "+c.token) + + httpClient := &http.Client{Timeout: defaultClientTimeout} + response, err := httpClient.Do(request) + if err != nil { + return fmt.Errorf("raindrop: unable to send request: %v", err) + } + defer response.Body.Close() + + if response.StatusCode >= 400 { + return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode) + } + + return nil +} + +type raindrop struct { + Link string `json:"link"` + Title string `json:"title"` + Collection collection `json:"collection,omitempty"` + Tags []string `json:"tags"` +} + +type collection struct { + Id string `json:"$id"` +} diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json index 61e14f8e..7a9b1625 100644 --- a/internal/locale/translations/en_US.json +++ b/internal/locale/translations/en_US.json @@ -469,6 +469,10 @@ "form.integration.webhook_secret": "Webhook Secret", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_url": "RSS-Bridge server URL", + "form.integration.raindrop_activate": "Save entries to Raindrop", + "form.integration.raindrop_token": "(Test) Token", + "form.integration.raindrop_collection_id": "Collection ID", + "form.integration.raindrop_tags": "Tags (comma-separated)", "form.api_key.label.description": "API Key Label", "form.submit.loading": "Loading…", "form.submit.saving": "Saving…", diff --git a/internal/model/integration.go b/internal/model/integration.go index 4ab70c18..05e4cec7 100644 --- a/internal/model/integration.go +++ b/internal/model/integration.go @@ -90,4 +90,8 @@ type Integration struct { OmnivoreEnabled bool OmnivoreAPIKey string OmnivoreURL string + RaindropEnabled bool + RaindropToken string + RaindropCollectionID string + RaindropTags string } diff --git a/internal/storage/integration.go b/internal/storage/integration.go index d3f3d0eb..2b848ac3 100644 --- a/internal/storage/integration.go +++ b/internal/storage/integration.go @@ -193,7 +193,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) { rssbridge_url, omnivore_enabled, omnivore_api_key, - omnivore_url + omnivore_url, + raindrop_enabled, + raindrop_token, + raindrop_collection_id, + raindrop_tags FROM integrations WHERE @@ -286,6 +290,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) { &integration.OmnivoreEnabled, &integration.OmnivoreAPIKey, &integration.OmnivoreURL, + &integration.RaindropEnabled, + &integration.RaindropToken, + &integration.RaindropCollectionID, + &integration.RaindropTags, ) switch { case err == sql.ErrNoRows: @@ -386,9 +394,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error { omnivore_url=$81, linkwarden_enabled=$82, linkwarden_url=$83, - linkwarden_api_key=$84 + linkwarden_api_key=$84, + raindrop_enabled=$85, + raindrop_token=$86, + raindrop_collection_id=$87, + raindrop_tags=$88 WHERE - user_id=$85 + user_id=$89 ` _, err := s.db.Exec( query, @@ -476,6 +488,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error { integration.LinkwardenEnabled, integration.LinkwardenURL, integration.LinkwardenAPIKey, + integration.RaindropEnabled, + integration.RaindropToken, + integration.RaindropCollectionID, + integration.RaindropTags, integration.UserID, ) @@ -513,7 +529,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) { readeck_enabled='t' OR shaarli_enabled='t' OR webhook_enabled='t' OR - omnivore_enabled='t' + omnivore_enabled='t' OR + raindrop_enabled='t' ) ` if err := s.db.QueryRow(query, userID).Scan(&result); err != nil { diff --git a/internal/template/templates/views/integrations.html b/internal/template/templates/views/integrations.html index 41504147..66d03154 100644 --- a/internal/template/templates/views/integrations.html +++ b/internal/template/templates/views/integrations.html @@ -326,6 +326,28 @@ +
+ Raindrop +
+ + + + + + + + + + + +
+ +
+
+
+
Readeck
diff --git a/internal/ui/form/integration.go b/internal/ui/form/integration.go index 7bc5cf91..7809a3ff 100644 --- a/internal/ui/form/integration.go +++ b/internal/ui/form/integration.go @@ -96,6 +96,10 @@ type IntegrationForm struct { OmnivoreEnabled bool OmnivoreAPIKey string OmnivoreURL string + RaindropEnabled bool + RaindropToken string + RaindropCollectionID string + RaindropTags string } // Merge copy form values to the model. @@ -181,6 +185,10 @@ func (i IntegrationForm) Merge(integration *model.Integration) { integration.OmnivoreEnabled = i.OmnivoreEnabled integration.OmnivoreAPIKey = i.OmnivoreAPIKey integration.OmnivoreURL = i.OmnivoreURL + integration.RaindropEnabled = i.RaindropEnabled + integration.RaindropToken = i.RaindropToken + integration.RaindropCollectionID = i.RaindropCollectionID + integration.RaindropTags = i.RaindropTags } // NewIntegrationForm returns a new IntegrationForm. @@ -269,6 +277,10 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm { OmnivoreEnabled: r.FormValue("omnivore_enabled") == "1", OmnivoreAPIKey: r.FormValue("omnivore_api_key"), OmnivoreURL: r.FormValue("omnivore_url"), + RaindropEnabled: r.FormValue("raindrop_enabled") == "1", + RaindropToken: r.FormValue("raindrop_token"), + RaindropCollectionID: r.FormValue("raindrop_collection_id"), + RaindropTags: r.FormValue("raindrop_tags"), } } diff --git a/internal/ui/integration_show.go b/internal/ui/integration_show.go index 03bc73b6..8b3299a4 100644 --- a/internal/ui/integration_show.go +++ b/internal/ui/integration_show.go @@ -110,6 +110,10 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) { OmnivoreEnabled: integration.OmnivoreEnabled, OmnivoreAPIKey: integration.OmnivoreAPIKey, OmnivoreURL: integration.OmnivoreURL, + RaindropEnabled: integration.RaindropEnabled, + RaindropToken: integration.RaindropToken, + RaindropCollectionID: integration.RaindropCollectionID, + RaindropTags: integration.RaindropTags, } sess := session.New(h.store, request.SessionID(r))