Skip to content

Commit 2d1f085

Browse files
author
Lennard Schwarz
committed
feat: add auto-push option to readeck integration
1 parent dc12713 commit 2d1f085

28 files changed

+351
-25
lines changed

internal/database/migrations.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,15 +1368,22 @@ var migrations = [...]func(tx *sql.Tx) error{
13681368
},
13691369
func(tx *sql.Tx) (err error) {
13701370
sql := `
1371-
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
1372-
`
1371+
ALTER TABLE integrations ADD COLUMN karakeep_tags text default '';
1372+
`
13731373
_, err = tx.Exec(sql)
13741374
return err
13751375
},
13761376
func(tx *sql.Tx) (err error) {
13771377
sql := `
1378-
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
1379-
`
1378+
ALTER TABLE integrations ADD COLUMN linkwarden_collection_id int;
1379+
`
1380+
_, err = tx.Exec(sql)
1381+
return err
1382+
},
1383+
func(tx *sql.Tx) (err error) {
1384+
sql := `
1385+
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
1386+
`
13801387
_, err = tx.Exec(sql)
13811388
return err
13821389
},

internal/integration/integration.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,4 +685,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
685685
}
686686
}
687687
}
688+
689+
// Push each new entry to Readeck when push is enabled
690+
if userIntegrations.ReadeckPushEnabled {
691+
client := readeck.NewClient(
692+
userIntegrations.ReadeckURL,
693+
userIntegrations.ReadeckAPIKey,
694+
userIntegrations.ReadeckLabels,
695+
userIntegrations.ReadeckOnlyURL,
696+
)
697+
for _, entry := range entries {
698+
slog.Debug("Sending a new entry to Readeck",
699+
slog.Int64("user_id", userIntegrations.UserID),
700+
slog.Int64("entry_id", entry.ID),
701+
slog.String("entry_url", entry.URL),
702+
)
703+
704+
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
705+
slog.Error("Unable to send entry to Readeck",
706+
slog.Int64("user_id", userIntegrations.UserID),
707+
slog.Int64("entry_id", entry.ID),
708+
slog.String("entry_url", entry.URL),
709+
slog.Any("error", err),
710+
)
711+
}
712+
}
713+
}
688714
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package readeck
5+
6+
import (
7+
"encoding/json"
8+
"io"
9+
"mime/multipart"
10+
"net/http"
11+
"net/http/httptest"
12+
"strings"
13+
"testing"
14+
)
15+
16+
func TestCreateBookmark(t *testing.T) {
17+
entryURL := "https://example.com/article"
18+
entryTitle := "Example Title"
19+
entryContent := "<p>Some HTML content</p>"
20+
labels := "tag1,tag2"
21+
22+
tests := []struct {
23+
name string
24+
onlyURL bool
25+
baseURL string
26+
apiKey string
27+
labels string
28+
entryURL string
29+
entryTitle string
30+
entryContent string
31+
serverResponse func(w http.ResponseWriter, r *http.Request)
32+
wantErr bool
33+
errContains string
34+
}{
35+
{
36+
name: "successful bookmark creation with only URL",
37+
onlyURL: true,
38+
labels: labels,
39+
entryURL: entryURL,
40+
entryTitle: entryTitle,
41+
entryContent: entryContent,
42+
serverResponse: func(w http.ResponseWriter, r *http.Request) {
43+
if r.Method != http.MethodPost {
44+
t.Errorf("expected POST, got %s", r.Method)
45+
}
46+
if r.URL.Path != "/api/bookmarks/" {
47+
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
48+
}
49+
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
50+
t.Errorf("expected Authorization Bearer header, got %q", got)
51+
}
52+
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
53+
t.Errorf("expected Content-Type application/json, got %s", ct)
54+
}
55+
56+
body, _ := io.ReadAll(r.Body)
57+
var payload map[string]any
58+
if err := json.Unmarshal(body, &payload); err != nil {
59+
t.Fatalf("failed to parse JSON body: %v", err)
60+
}
61+
if u := payload["url"]; u != entryURL {
62+
t.Errorf("expected url %s, got %v", entryURL, u)
63+
}
64+
if title := payload["title"]; title != entryTitle {
65+
t.Errorf("expected title %s, got %v", entryTitle, title)
66+
}
67+
// Labels should be split into an array
68+
if raw := payload["labels"]; raw == nil {
69+
t.Errorf("expected labels to be set")
70+
} else if arr, ok := raw.([]any); ok {
71+
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
72+
t.Errorf("unexpected labels: %#v", arr)
73+
}
74+
} else {
75+
t.Errorf("labels should be an array, got %T", raw)
76+
}
77+
w.WriteHeader(http.StatusOK)
78+
},
79+
},
80+
{
81+
name: "successful bookmark creation with content (multipart)",
82+
onlyURL: false,
83+
labels: labels,
84+
entryURL: entryURL,
85+
entryTitle: entryTitle,
86+
entryContent: entryContent,
87+
serverResponse: func(w http.ResponseWriter, r *http.Request) {
88+
if r.Method != http.MethodPost {
89+
t.Errorf("expected POST, got %s", r.Method)
90+
}
91+
if r.URL.Path != "/api/bookmarks/" {
92+
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
93+
}
94+
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
95+
t.Errorf("expected Authorization Bearer header, got %q", got)
96+
}
97+
ct := r.Header.Get("Content-Type")
98+
if !strings.HasPrefix(ct, "multipart/form-data;") {
99+
t.Errorf("expected multipart/form-data, got %s", ct)
100+
}
101+
boundaryIdx := strings.Index(ct, "boundary=")
102+
if boundaryIdx == -1 {
103+
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
104+
}
105+
boundary := ct[boundaryIdx+len("boundary="):]
106+
mr := multipart.NewReader(r.Body, boundary)
107+
108+
seenLabels := []string{}
109+
var seenURL, seenTitle, seenFeature string
110+
var resourceHeader map[string]any
111+
var resourceBody string
112+
113+
for {
114+
part, err := mr.NextPart()
115+
if err == io.EOF {
116+
break
117+
}
118+
if err != nil {
119+
t.Fatalf("reading multipart: %v", err)
120+
}
121+
name := part.FormName()
122+
data, _ := io.ReadAll(part)
123+
switch name {
124+
case "url":
125+
seenURL = string(data)
126+
case "title":
127+
seenTitle = string(data)
128+
case "feature_find_main":
129+
seenFeature = string(data)
130+
case "labels":
131+
seenLabels = append(seenLabels, string(data))
132+
case "resource":
133+
// First line is JSON header, then newline, then content
134+
all := string(data)
135+
idx := strings.IndexByte(all, '\n')
136+
if idx == -1 {
137+
t.Fatalf("resource content missing header separator")
138+
}
139+
headerJSON := all[:idx]
140+
resourceBody = all[idx+1:]
141+
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
142+
t.Fatalf("invalid resource header JSON: %v", err)
143+
}
144+
}
145+
}
146+
147+
if seenURL != entryURL {
148+
t.Errorf("expected url %s, got %s", entryURL, seenURL)
149+
}
150+
if seenTitle != entryTitle {
151+
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
152+
}
153+
if seenFeature != "false" {
154+
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
155+
}
156+
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
157+
t.Errorf("unexpected labels: %#v", seenLabels)
158+
}
159+
if resourceHeader == nil {
160+
t.Fatalf("missing resource header")
161+
}
162+
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
163+
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
164+
}
165+
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
166+
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
167+
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
168+
}
169+
} else {
170+
t.Errorf("missing resource header 'headers' field")
171+
}
172+
if resourceBody != entryContent {
173+
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
174+
}
175+
176+
w.WriteHeader(http.StatusOK)
177+
},
178+
},
179+
{
180+
name: "error when server returns 400",
181+
onlyURL: true,
182+
labels: labels,
183+
entryURL: entryURL,
184+
entryTitle: entryTitle,
185+
entryContent: entryContent,
186+
serverResponse: func(w http.ResponseWriter, r *http.Request) {
187+
w.WriteHeader(http.StatusBadRequest)
188+
},
189+
wantErr: true,
190+
errContains: "unable to create bookmark",
191+
},
192+
{
193+
name: "error when missing baseURL or apiKey",
194+
onlyURL: true,
195+
baseURL: "",
196+
apiKey: "",
197+
labels: labels,
198+
entryURL: entryURL,
199+
entryTitle: entryTitle,
200+
entryContent: entryContent,
201+
serverResponse: nil,
202+
wantErr: true,
203+
errContains: "missing base URL or API key",
204+
},
205+
}
206+
207+
for _, tt := range tests {
208+
t.Run(tt.name, func(t *testing.T) {
209+
var serverURL string
210+
if tt.serverResponse != nil {
211+
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
212+
defer srv.Close()
213+
serverURL = srv.URL
214+
}
215+
baseURL := tt.baseURL
216+
if baseURL == "" {
217+
baseURL = serverURL
218+
}
219+
apiKey := tt.apiKey
220+
if apiKey == "" {
221+
apiKey = "test-api-key"
222+
}
223+
224+
client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
225+
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
226+
227+
if tt.wantErr {
228+
if err == nil {
229+
t.Fatalf("expected error, got none")
230+
}
231+
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
232+
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
233+
}
234+
} else if err != nil {
235+
t.Fatalf("unexpected error: %v", err)
236+
}
237+
})
238+
}
239+
}
240+
241+
func TestNewClient(t *testing.T) {
242+
baseURL := "https://readeck.example.com"
243+
apiKey := "key"
244+
labels := "tag1,tag2"
245+
onlyURL := true
246+
247+
c := NewClient(baseURL, apiKey, labels, onlyURL)
248+
if c.baseURL != baseURL {
249+
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
250+
}
251+
if c.apiKey != apiKey {
252+
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
253+
}
254+
if c.labels != labels {
255+
t.Errorf("expected labels %s, got %s", labels, c.labels)
256+
}
257+
if c.onlyURL != onlyURL {
258+
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
259+
}
260+
}

internal/locale/translations/de_DE.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
"form.integration.readeck_endpoint": "Readeck-URL",
308308
"form.integration.readeck_labels": "Readeck-Labels",
309309
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
310+
"form.integration.readeck_push_activate": "Neue Artikel automatisch in Readeck speichern",
310311
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
311312
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
312313
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
@@ -630,4 +631,4 @@
630631
"time_elapsed.yesterday": "gestern",
631632
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
632633
"tooltip.logged_user": "Angemeldet als %s"
633-
}
634+
}

internal/locale/translations/el_EL.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
308308
"form.integration.readeck_labels": "Ετικέτες Readeck",
309309
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
310+
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
310311
"form.integration.readwise_activate": "Αποθήκευση καταχωρήσεων στο Readwise Reader",
311312
"form.integration.readwise_api_key": "Διακριτικό πρόσβασης Readwise Reader",
312313
"form.integration.readwise_api_key_link": "Λήψη του διακριτικού πρόσβασης Readwise",
@@ -630,4 +631,4 @@
630631
"time_elapsed.yesterday": "χθες",
631632
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
632633
"tooltip.logged_user": "Συνδεδεμένος/η ως %s"
633-
}
634+
}

internal/locale/translations/en_US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
"form.integration.readeck_endpoint": "Readeck URL",
308308
"form.integration.readeck_labels": "Readeck Labels",
309309
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
310+
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
310311
"form.integration.readwise_activate": "Save entries to Readwise Reader",
311312
"form.integration.readwise_api_key": "Readwise Reader Access Token",
312313
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",

internal/locale/translations/es_ES.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
"form.integration.readeck_endpoint": "Acceso API de Readeck",
308308
"form.integration.readeck_labels": "Etiquetas de Readeck",
309309
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
310+
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
310311
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
311312
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
312313
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
@@ -630,4 +631,4 @@
630631
"time_elapsed.yesterday": "ayer",
631632
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
632633
"tooltip.logged_user": "Registrado como %s"
633-
}
634+
}

internal/locale/translations/fi_FI.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@
307307
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
308308
"form.integration.readeck_labels": "Readeck Labels",
309309
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
310+
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
310311
"form.integration.readwise_activate": "Save entries to Readwise Reader",
311312
"form.integration.readwise_api_key": "Readwise Reader Access Token",
312313
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
@@ -630,4 +631,4 @@
630631
"time_elapsed.yesterday": "eilen",
631632
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
632633
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä"
633-
}
634+
}

0 commit comments

Comments
 (0)