Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -1380,4 +1380,11 @@ var migrations = [...]func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_push_enabled bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
}
26 changes: 26 additions & 0 deletions internal/integration/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,4 +685,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
}
}

// Push each new entry to Readeck when push is enabled
if userIntegrations.ReadeckPushEnabled {
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
for _, entry := range entries {
slog.Debug("Sending a new entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)

if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
}
260 changes: 260 additions & 0 deletions internal/integration/readeck/readeck_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package readeck

import (
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestCreateBookmark(t *testing.T) {
entryURL := "https://example.com/article"
entryTitle := "Example Title"
entryContent := "<p>Some HTML content</p>"
labels := "tag1,tag2"

tests := []struct {
name string
onlyURL bool
baseURL string
apiKey string
labels string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation with only URL",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", ct)
}

body, _ := io.ReadAll(r.Body)
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("failed to parse JSON body: %v", err)
}
if u := payload["url"]; u != entryURL {
t.Errorf("expected url %s, got %v", entryURL, u)
}
if title := payload["title"]; title != entryTitle {
t.Errorf("expected title %s, got %v", entryTitle, title)
}
// Labels should be split into an array
if raw := payload["labels"]; raw == nil {
t.Errorf("expected labels to be set")
} else if arr, ok := raw.([]any); ok {
if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
t.Errorf("unexpected labels: %#v", arr)
}
} else {
t.Errorf("labels should be an array, got %T", raw)
}
w.WriteHeader(http.StatusOK)
},
},
{
name: "successful bookmark creation with content (multipart)",
onlyURL: false,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/bookmarks/" {
t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
t.Errorf("expected Authorization Bearer header, got %q", got)
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/form-data;") {
t.Errorf("expected multipart/form-data, got %s", ct)
}
boundaryIdx := strings.Index(ct, "boundary=")
if boundaryIdx == -1 {
t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
}
boundary := ct[boundaryIdx+len("boundary="):]
mr := multipart.NewReader(r.Body, boundary)

seenLabels := []string{}
var seenURL, seenTitle, seenFeature string
var resourceHeader map[string]any
var resourceBody string

for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading multipart: %v", err)
}
name := part.FormName()
data, _ := io.ReadAll(part)
switch name {
case "url":
seenURL = string(data)
case "title":
seenTitle = string(data)
case "feature_find_main":
seenFeature = string(data)
case "labels":
seenLabels = append(seenLabels, string(data))
case "resource":
// First line is JSON header, then newline, then content
all := string(data)
idx := strings.IndexByte(all, '\n')
if idx == -1 {
t.Fatalf("resource content missing header separator")
}
headerJSON := all[:idx]
resourceBody = all[idx+1:]
if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
t.Fatalf("invalid resource header JSON: %v", err)
}
}
}

if seenURL != entryURL {
t.Errorf("expected url %s, got %s", entryURL, seenURL)
}
if seenTitle != entryTitle {
t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
}
if seenFeature != "false" {
t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
}
if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
t.Errorf("unexpected labels: %#v", seenLabels)
}
if resourceHeader == nil {
t.Fatalf("missing resource header")
}
if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
}
if headers, ok := resourceHeader["headers"].(map[string]any); ok {
if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
}
} else {
t.Errorf("missing resource header 'headers' field")
}
if resourceBody != entryContent {
t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
}

w.WriteHeader(http.StatusOK)
},
},
{
name: "error when server returns 400",
onlyURL: true,
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
},
wantErr: true,
errContains: "unable to create bookmark",
},
{
name: "error when missing baseURL or apiKey",
onlyURL: true,
baseURL: "",
apiKey: "",
labels: labels,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: nil,
wantErr: true,
errContains: "missing base URL or API key",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var serverURL string
if tt.serverResponse != nil {
srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer srv.Close()
serverURL = srv.URL
}
baseURL := tt.baseURL
if baseURL == "" {
baseURL = serverURL
}
apiKey := tt.apiKey
if apiKey == "" {
apiKey = "test-api-key"
}

client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)

if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got none")
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}

func TestNewClient(t *testing.T) {
baseURL := "https://readeck.example.com"
apiKey := "key"
labels := "tag1,tag2"
onlyURL := true

c := NewClient(baseURL, apiKey, labels, onlyURL)
if c.baseURL != baseURL {
t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
}
if c.apiKey != apiKey {
t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
}
if c.labels != labels {
t.Errorf("expected labels %s, got %s", labels, c.labels)
}
if c.onlyURL != onlyURL {
t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
}
}
3 changes: 2 additions & 1 deletion internal/locale/translations/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"form.integration.readeck_endpoint": "Readeck-URL",
"form.integration.readeck_labels": "Readeck-Labels",
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.readeck_push_activate": "Neue Artikel automatisch in Readeck speichern",
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
Expand Down Expand Up @@ -630,4 +631,4 @@
"time_elapsed.yesterday": "gestern",
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
"tooltip.logged_user": "Angemeldet als %s"
}
}
3 changes: 2 additions & 1 deletion internal/locale/translations/el_EL.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_labels": "Ετικέτες Readeck",
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Αποθήκευση καταχωρήσεων στο Readwise Reader",
"form.integration.readwise_api_key": "Διακριτικό πρόσβασης Readwise Reader",
"form.integration.readwise_api_key_link": "Λήψη του διακριτικού πρόσβασης Readwise",
Expand Down Expand Up @@ -630,4 +631,4 @@
"time_elapsed.yesterday": "χθες",
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
"tooltip.logged_user": "Συνδεδεμένος/η ως %s"
}
}
1 change: 1 addition & 0 deletions internal/locale/translations/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
Expand Down
3 changes: 2 additions & 1 deletion internal/locale/translations/es_ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_labels": "Etiquetas de Readeck",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
Expand Down Expand Up @@ -630,4 +631,4 @@
"time_elapsed.yesterday": "ayer",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s"
}
}
3 changes: 2 additions & 1 deletion internal/locale/translations/fi_FI.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
Expand Down Expand Up @@ -630,4 +631,4 @@
"time_elapsed.yesterday": "eilen",
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä"
}
}
3 changes: 2 additions & 1 deletion internal/locale/translations/fr_FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_labels": "Libellés Readeck",
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.readeck_push_activate": "Automatically push new entries to Readeck",
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
Expand Down Expand Up @@ -630,4 +631,4 @@
"time_elapsed.yesterday": "hier",
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
"tooltip.logged_user": "Connecté en tant que %s"
}
}
Loading