Browse Source

feat: add Linkwarden collection ID support

Barend van der Walt 4 months ago
parent
commit
5bd5993a24

+ 7 - 0
internal/database/migrations.go

@@ -1373,4 +1373,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 linkwarden_collection_id int;
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 11 - 8
internal/integration/integration.go

@@ -271,23 +271,26 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
 	}
 
 	if userIntegrations.LinkwardenEnabled {
-		slog.Debug("Sending entry to linkwarden",
+		attrs := []any{
 			slog.Int64("user_id", userIntegrations.UserID),
 			slog.Int64("entry_id", entry.ID),
 			slog.String("entry_url", entry.URL),
-		)
+		}
+
+		if userIntegrations.LinkwardenCollectionId != nil {
+			attrs = append(attrs, slog.Int64("collection_id", *userIntegrations.LinkwardenCollectionId))
+		}
+
+		slog.Debug("Sending entry to linkwarden", attrs...)
 
 		client := linkwarden.NewClient(
 			userIntegrations.LinkwardenURL,
 			userIntegrations.LinkwardenAPIKey,
+			userIntegrations.LinkwardenCollectionId,
 		)
 		if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
-			slog.Error("Unable to send entry to Linkwarden",
-				slog.Int64("user_id", userIntegrations.UserID),
-				slog.Int64("entry_id", entry.ID),
-				slog.String("entry_url", entry.URL),
-				slog.Any("error", err),
-			)
+			attrs = append(attrs, slog.Any("error", err))
+			slog.Error("Unable to send entry to Linkwarden", attrs...)
 		}
 	}
 

+ 63 - 0
internal/integration/integration_test.go

@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package integration
+
+import (
+	"bytes"
+	"log/slog"
+	"strings"
+	"testing"
+
+	"miniflux.app/v2/internal/model"
+)
+
+func TestSendEntryLogsLinkwardenCollectionID(t *testing.T) {
+	var buf bytes.Buffer
+	handler := slog.NewJSONHandler(&buf, nil)
+	logger := slog.New(handler)
+	prev := slog.Default()
+	slog.SetDefault(logger)
+	defer slog.SetDefault(prev)
+
+	entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
+	coll := int64(12345)
+	userIntegrations := &model.Integration{
+		UserID:                 1,
+		LinkwardenEnabled:      true,
+		LinkwardenCollectionId: &coll,
+		LinkwardenURL:          "",
+		LinkwardenAPIKey:       "",
+	}
+
+	SendEntry(entry, userIntegrations)
+
+	out := buf.String()
+	if !strings.Contains(out, `"collection_id":12345`) {
+		t.Fatalf("expected collection_id in logs; got: %s", out)
+	}
+}
+
+func TestSendEntryLogsLinkwardenWithoutCollectionID(t *testing.T) {
+	var buf bytes.Buffer
+	handler := slog.NewJSONHandler(&buf, nil)
+	logger := slog.New(handler)
+	prev := slog.Default()
+	slog.SetDefault(logger)
+	defer slog.SetDefault(prev)
+
+	entry := &model.Entry{ID: 52, URL: "https://example.org/test.html", Title: "Test"}
+	userIntegrations := &model.Integration{
+		UserID:            1,
+		LinkwardenEnabled: true,
+		LinkwardenURL:     "",
+		LinkwardenAPIKey:  "",
+	}
+
+	SendEntry(entry, userIntegrations)
+
+	out := buf.String()
+	if strings.Contains(out, "collection_id") {
+		t.Fatalf("did not expect collection_id in logs; got: %s", out)
+	}
+}

+ 32 - 9
internal/integration/linkwarden/linkwarden.go

@@ -8,6 +8,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 	"time"
 
@@ -18,12 +19,23 @@ import (
 const defaultClientTimeout = 10 * time.Second
 
 type Client struct {
-	baseURL string
-	apiKey  string
+	baseURL      string
+	apiKey       string
+	collectionId *int64
 }
 
-func NewClient(baseURL, apiKey string) *Client {
-	return &Client{baseURL: baseURL, apiKey: apiKey}
+type linkwardenCollection struct {
+	Id *int64 `json:"id"`
+}
+
+type linkwardenRequest struct {
+	URL        string                `json:"url"`
+	Name       string                `json:"name"`
+	Collection *linkwardenCollection `json:"collection,omitempty"`
+}
+
+func NewClient(baseURL, apiKey string, collectionId *int64) *Client {
+	return &Client{baseURL: baseURL, apiKey: apiKey, collectionId: collectionId}
 }
 
 func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
@@ -36,10 +48,16 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 		return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
 	}
 
-	requestBody, err := json.Marshal(map[string]string{
-		"url":  entryURL,
-		"name": entryTitle,
-	})
+	payload := linkwardenRequest{
+		URL:  entryURL,
+		Name: entryTitle,
+	}
+
+	if c.collectionId != nil {
+		payload.Collection = &linkwardenCollection{Id: c.collectionId}
+	}
+
+	requestBody, err := json.Marshal(payload)
 
 	if err != nil {
 		return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
@@ -61,8 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 	}
 	defer response.Body.Close()
 
+	responseBody, err := io.ReadAll(response.Body)
+	if err != nil {
+		return fmt.Errorf("linkwarden: unable to read response body: %v", err)
+	}
+
 	if response.StatusCode >= 400 {
-		return fmt.Errorf("linkwarden: unable to create link: url=%s status=%d", apiEndpoint, response.StatusCode)
+		return fmt.Errorf("linkwarden: unable to create link: status=%d body=%s", response.StatusCode, string(responseBody))
 	}
 
 	return nil

+ 341 - 0
internal/integration/linkwarden/linkwarden_test.go

@@ -0,0 +1,341 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package linkwarden
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestCreateBookmark(t *testing.T) {
+	tests := []struct {
+		name           string
+		baseURL        string
+		apiKey         string
+		collectionId   *int64
+		entryURL       string
+		entryTitle     string
+		serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64)
+		wantErr        bool
+		errContains    string
+	}{
+		{
+			name:         "successful bookmark creation without collection",
+			baseURL:      "",
+			apiKey:       "test-api-key",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test Article",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				// Verify authorization header
+				auth := r.Header.Get("Authorization")
+				if auth != "Bearer test-api-key" {
+					t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
+				}
+
+				// Verify content type
+				contentType := r.Header.Get("Content-Type")
+				if contentType != "application/json" {
+					t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
+				}
+
+				// Parse and verify request
+				body, _ := io.ReadAll(r.Body)
+				var req map[string]interface{}
+				if err := json.Unmarshal(body, &req); err != nil {
+					t.Errorf("Failed to parse request body: %v", err)
+				}
+
+				// Verify URL
+				if reqURL := req["url"]; reqURL != "https://example.com" {
+					t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
+				}
+
+				// Verify title/name
+				if reqName := req["name"]; reqName != "Test Article" {
+					t.Errorf("Expected name 'Test Article', got %v", reqName)
+				}
+
+				// Verify collection is not present when nil
+				if _, ok := req["collection"]; ok {
+					t.Error("Expected collection field to be omitted when collectionId is nil")
+				}
+
+				// Return success response
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"id":   "123",
+					"url":  "https://example.com",
+					"name": "Test Article",
+				})
+			},
+			wantErr: false,
+		},
+		{
+			name:         "successful bookmark creation with collection",
+			baseURL:      "",
+			apiKey:       "test-api-key",
+			collectionId: int64Ptr(42),
+			entryURL:     "https://example.com/article",
+			entryTitle:   "Test Article With Collection",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				// Verify authorization header
+				auth := r.Header.Get("Authorization")
+				if auth != "Bearer test-api-key" {
+					t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
+				}
+
+				// Parse and verify request
+				body, _ := io.ReadAll(r.Body)
+				var req map[string]interface{}
+				if err := json.Unmarshal(body, &req); err != nil {
+					t.Errorf("Failed to parse request body: %v", err)
+				}
+
+				// Verify URL
+				if reqURL := req["url"]; reqURL != "https://example.com/article" {
+					t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
+				}
+
+				// Verify title/name
+				if reqName := req["name"]; reqName != "Test Article With Collection" {
+					t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
+				}
+
+				// Verify collection is present and correct
+				if collection, ok := req["collection"]; ok {
+					collectionMap, ok := collection.(map[string]interface{})
+					if !ok {
+						t.Error("Expected collection to be a map")
+					}
+					if collectionID, ok := collectionMap["id"]; ok {
+						// JSON numbers are float64
+						if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
+							t.Errorf("Expected collection id 42, got %v", collectionID)
+						}
+					} else {
+						t.Error("Expected collection to have 'id' field")
+					}
+				} else {
+					t.Error("Expected collection field to be present when collectionId is set")
+				}
+
+				// Return success response
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"id":   "124",
+					"url":  "https://example.com/article",
+					"name": "Test Article With Collection",
+				})
+			},
+			wantErr: false,
+		},
+		{
+			name:         "missing API key",
+			baseURL:      "",
+			apiKey:       "",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				// Should not be called
+				t.Error("Server should not be called when API key is missing")
+			},
+			wantErr:     true,
+			errContains: "missing base URL or API key",
+		},
+		{
+			name:         "server error",
+			baseURL:      "",
+			apiKey:       "test-api-key",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				w.WriteHeader(http.StatusInternalServerError)
+				w.Write([]byte(`{"error": "Internal server error"}`))
+			},
+			wantErr:     true,
+			errContains: "unable to create link: status=500",
+		},
+		{
+			name:         "bad request with null collection id error",
+			baseURL:      "",
+			apiKey:       "test-api-key",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				w.WriteHeader(http.StatusBadRequest)
+				w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
+			},
+			wantErr:     true,
+			errContains: "unable to create link: status=400",
+		},
+		{
+			name:         "unauthorized",
+			baseURL:      "",
+			apiKey:       "invalid-key",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				w.WriteHeader(http.StatusUnauthorized)
+				w.Write([]byte(`{"error": "Unauthorized"}`))
+			},
+			wantErr:     true,
+			errContains: "unable to create link: status=401",
+		},
+		{
+			name:         "invalid base URL",
+			baseURL:      ":",
+			apiKey:       "test-api-key",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				// Should not be called
+				t.Error("Server should not be called when base URL is invalid")
+			},
+			wantErr:     true,
+			errContains: "invalid API endpoint",
+		},
+		{
+			name:         "missing base URL",
+			baseURL:      "",
+			apiKey:       "",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				// Should not be called
+				t.Error("Server should not be called when base URL is missing")
+			},
+			wantErr:     true,
+			errContains: "missing base URL or API key",
+		},
+		{
+			name:         "network connection error",
+			baseURL:      "http://localhost:1", // Invalid port that should fail to connect
+			apiKey:       "test-api-key",
+			collectionId: nil,
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
+				// Should not be called due to connection failure
+				t.Error("Server should not be called when connection fails")
+			},
+			wantErr:     true,
+			errContains: "unable to send request",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
+			var server *httptest.Server
+			if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
+				server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					tt.serverResponse(w, r, t, tt.collectionId)
+				}))
+				defer server.Close()
+			}
+
+			// Use test server URL if baseURL is empty and we have a server
+			baseURL := tt.baseURL
+			if baseURL == "" && server != nil {
+				baseURL = server.URL
+			}
+
+			// Create client
+			client := NewClient(baseURL, tt.apiKey, tt.collectionId)
+
+			// Call CreateBookmark
+			err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
+
+			// Check error
+			if tt.wantErr {
+				if err == nil {
+					t.Error("Expected error, got nil")
+				} else if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
+					t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
+				}
+			} else {
+				if err != nil {
+					t.Errorf("Expected no error, got %v", err)
+				}
+			}
+		})
+	}
+}
+
+func TestNewClient(t *testing.T) {
+	tests := []struct {
+		name         string
+		baseURL      string
+		apiKey       string
+		collectionId *int64
+	}{
+		{
+			name:         "client without collection",
+			baseURL:      "https://linkwarden.example.com",
+			apiKey:       "test-key",
+			collectionId: nil,
+		},
+		{
+			name:         "client with collection",
+			baseURL:      "https://linkwarden.example.com",
+			apiKey:       "test-key",
+			collectionId: int64Ptr(123),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client := NewClient(tt.baseURL, tt.apiKey, tt.collectionId)
+
+			if client.baseURL != tt.baseURL {
+				t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
+			}
+
+			if client.apiKey != tt.apiKey {
+				t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
+			}
+
+			if tt.collectionId == nil {
+				if client.collectionId != nil {
+					t.Errorf("Expected collectionId to be nil, got %v", *client.collectionId)
+				}
+			} else {
+				if client.collectionId == nil {
+					t.Error("Expected collectionId to be set, got nil")
+				} else if *client.collectionId != *tt.collectionId {
+					t.Errorf("Expected collectionId %d, got %d", *tt.collectionId, *client.collectionId)
+				}
+			}
+		})
+	}
+}
+
+// Helper function to create int64 pointer
+func int64Ptr(i int64) *int64 {
+	return &i
+}
+
+// Helper function to check if string contains substring
+func contains(s, substr string) bool {
+	return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsSubstring(s, substr))
+}
+
+func containsSubstring(s, substr string) bool {
+	for i := 0; i <= len(s)-len(substr); i++ {
+		if s[i:i+len(substr)] == substr {
+			return true
+		}
+	}
+	return false
+}

+ 2 - 1
internal/locale/translations/de_DE.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
     "form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
     "form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
     "form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
     "form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
@@ -629,4 +630,4 @@
     "time_elapsed.yesterday": "gestern",
     "tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
     "tooltip.logged_user": "Angemeldet als %s"
-}
+}

+ 1 - 0
internal/locale/translations/el_EL.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
     "form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
     "form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
     "form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
     "form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",

+ 2 - 1
internal/locale/translations/en_US.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Save entries to Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API key",
     "form.integration.linkwarden_endpoint": "Linkwarden Base URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Push new entries to Matrix",
     "form.integration.matrix_bot_chat_id": "ID of Matrix Room",
     "form.integration.matrix_bot_password": "Password for Matrix user",
@@ -629,4 +630,4 @@
     "time_elapsed.yesterday": "yesterday",
     "tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
     "tooltip.logged_user": "Logged in as %s"
-}
+}

+ 1 - 0
internal/locale/translations/es_ES.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
     "form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
     "form.integration.linkwarden_endpoint": "URL base de Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
     "form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
     "form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",

+ 1 - 0
internal/locale/translations/fi_FI.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
     "form.integration.linkwarden_api_key": "Linkwarden API-avain",
     "form.integration.linkwarden_endpoint": "Linkwarden Base URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
     "form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
     "form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",

+ 1 - 0
internal/locale/translations/fr_FR.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
     "form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
     "form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
     "form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
     "form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",

+ 1 - 0
internal/locale/translations/hi_IN.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Save entries to Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API key",
     "form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
     "form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
     "form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",

+ 1 - 0
internal/locale/translations/id_ID.json

@@ -263,6 +263,7 @@
     "form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
     "form.integration.linkwarden_api_key": "Kunci API Linkwarden",
     "form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Kirim entri baru ke Matrix",
     "form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
     "form.integration.matrix_bot_password": "Kata Sandi Matrix",

+ 1 - 0
internal/locale/translations/it_IT.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Salva gli articoli su Linkwarden",
     "form.integration.linkwarden_api_key": "API key dell'account Linkwarden",
     "form.integration.linkwarden_endpoint": "URL di base di Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Trasferimento di nuovi articoli a Matrix",
     "form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
     "form.integration.matrix_bot_password": "Password per l'utente Matrix",

+ 1 - 0
internal/locale/translations/ja_JP.json

@@ -263,6 +263,7 @@
     "form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
     "form.integration.linkwarden_api_key": "Linkwarden の API key",
     "form.integration.linkwarden_endpoint": "リンクワーデン ベース URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "新しい記事をMatrixに転送する",
     "form.integration.matrix_bot_chat_id": "MatrixルームのID",
     "form.integration.matrix_bot_password": "Matrixユーザ用パスワード",

+ 2 - 1
internal/locale/translations/nan_Latn_pehoeji.json

@@ -263,6 +263,7 @@
     "form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API só-sî",
     "form.integration.linkwarden_endpoint": "Linkwarden Base URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
     "form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
     "form.integration.matrix_bot_password": "Matrix bi̍t-bé",
@@ -611,4 +612,4 @@
     "time_elapsed.yesterday": "cha-hng",
     "tooltip.keyboard_shortcuts": "Khoài-sok khí:%s",
     "tooltip.logged_user": "Chit-má teng-lo̍k--ê:  %s"
-}
+}

+ 1 - 0
internal/locale/translations/nl_NL.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
     "form.integration.linkwarden_endpoint": "Linkwarden Basis URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
     "form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
     "form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",

+ 1 - 0
internal/locale/translations/pl_PL.json

@@ -269,6 +269,7 @@
     "form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
     "form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
     "form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
     "form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
     "form.integration.matrix_bot_password": "Hasło do Matrix",

+ 1 - 0
internal/locale/translations/pt_BR.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
     "form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
     "form.integration.linkwarden_endpoint": "URL base do Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Transferir novos artigos para o Matrix",
     "form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
     "form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",

+ 1 - 0
internal/locale/translations/ro_RO.json

@@ -269,6 +269,7 @@
     "form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
     "form.integration.linkwarden_api_key": "Cheie API Linkwarden",
     "form.integration.linkwarden_endpoint": "URL-ul de bază Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Împinge intrările noi pe Matrix",
     "form.integration.matrix_bot_chat_id": "ID-ul Camerei Matrix",
     "form.integration.matrix_bot_password": "Parola utilizatorului Matrix",

+ 1 - 0
internal/locale/translations/ru_RU.json

@@ -269,6 +269,7 @@
     "form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
     "form.integration.linkwarden_api_key": "API-ключ Linkwarden",
     "form.integration.linkwarden_endpoint": "Базовый URL-адрес Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Отправлять статьи в Matrix",
     "form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
     "form.integration.matrix_bot_password": "Пароль пользователя Matrix",

+ 1 - 0
internal/locale/translations/tr_TR.json

@@ -266,6 +266,7 @@
     "form.integration.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
     "form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
     "form.integration.linkwarden_endpoint": "Linkwarden Temel URL'si",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Yeni makaleleri Matrix'e aktarın",
     "form.integration.matrix_bot_chat_id": "Matrix odasının kimliği",
     "form.integration.matrix_bot_password": "Matrix kullanıcısı için parola",

+ 1 - 0
internal/locale/translations/uk_UA.json

@@ -269,6 +269,7 @@
     "form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
     "form.integration.linkwarden_api_key": "Ключ API Linkwarden",
     "form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "Перенесення нових статей в Матрицю",
     "form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
     "form.integration.matrix_bot_password": "Пароль для користувача Matrix",

+ 2 - 1
internal/locale/translations/zh_CN.json

@@ -263,6 +263,7 @@
     "form.integration.linkwarden_activate": "保存条目到 Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API 密钥",
     "form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "推送新条目到 Matrix",
     "form.integration.matrix_bot_chat_id": "Matrix 房间 ID",
     "form.integration.matrix_bot_password": "Matrix 用户密码",
@@ -611,4 +612,4 @@
     "time_elapsed.yesterday": "昨天",
     "tooltip.keyboard_shortcuts": "键盘快捷键:%s",
     "tooltip.logged_user": "登录用户:%s"
-}
+}

+ 1 - 0
internal/locale/translations/zh_TW.json

@@ -263,6 +263,7 @@
     "form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
     "form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
+    "form.integration.linkwarden_collection_id": "Linkwarden Collection ID",
     "form.integration.matrix_bot_activate": "推送文章到 Matrix",
     "form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
     "form.integration.matrix_bot_password": "Matrix 密碼",

+ 1 - 0
internal/model/integration.go

@@ -68,6 +68,7 @@ type Integration struct {
 	LinkwardenEnabled                bool
 	LinkwardenURL                    string
 	LinkwardenAPIKey                 string
+	LinkwardenCollectionId           *int64
 	MatrixBotEnabled                 bool
 	MatrixBotUser                    string
 	MatrixBotPassword                string

+ 6 - 2
internal/storage/integration.go

@@ -164,6 +164,7 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			linkwarden_enabled,
 			linkwarden_url,
 			linkwarden_api_key,
+			linkwarden_collection_id,
 			matrix_bot_enabled,
 			matrix_bot_user,
 			matrix_bot_password,
@@ -291,6 +292,7 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.LinkwardenEnabled,
 		&integration.LinkwardenURL,
 		&integration.LinkwardenAPIKey,
+		&integration.LinkwardenCollectionId,
 		&integration.MatrixBotEnabled,
 		&integration.MatrixBotUser,
 		&integration.MatrixBotPassword,
@@ -491,9 +493,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			linktaco_org_slug=$116,
 			linktaco_tags=$117,
 			linktaco_visibility=$118,
-			archiveorg_enabled=$119
+			archiveorg_enabled=$119,
+			linkwarden_collection_id=$120
 		WHERE
-			user_id=$120
+			user_id=$121
 	`
 	_, err := s.db.Exec(
 		query,
@@ -616,6 +619,7 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 		integration.LinktacoTags,
 		integration.LinktacoVisibility,
 		integration.ArchiveorgEnabled,
+		integration.LinkwardenCollectionId,
 		integration.UserID,
 	)
 

+ 3 - 0
internal/template/templates/views/integrations.html

@@ -284,6 +284,9 @@
             <label for="form-linkwarden-api-key">{{ t "form.integration.linkwarden_api_key" }}</label>
             <input type="text" name="linkwarden_api_key" id="form-linkwarden-api-key" value="{{ .form.LinkwardenAPIKey }}" spellcheck="false">
 
+            <label for="form-linkwarden-collection-id">{{ t "form.integration.linkwarden_collection_id" }}</label>
+            <input type="number" name="linkwarden_collection_id" id="form-linkwarden-collection-id" value="{{ .form.LinkwardenCollectionId }}" spellcheck="false">
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>

+ 3 - 0
internal/ui/form/integration.go

@@ -71,6 +71,7 @@ type IntegrationForm struct {
 	LinkwardenEnabled                bool
 	LinkwardenURL                    string
 	LinkwardenAPIKey                 string
+	LinkwardenCollectionId           *int64
 	MatrixBotEnabled                 bool
 	MatrixBotUser                    string
 	MatrixBotPassword                string
@@ -192,6 +193,7 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.LinkwardenEnabled = i.LinkwardenEnabled
 	integration.LinkwardenURL = i.LinkwardenURL
 	integration.LinkwardenAPIKey = i.LinkwardenAPIKey
+	integration.LinkwardenCollectionId = i.LinkwardenCollectionId
 	integration.MatrixBotEnabled = i.MatrixBotEnabled
 	integration.MatrixBotUser = i.MatrixBotUser
 	integration.MatrixBotPassword = i.MatrixBotPassword
@@ -315,6 +317,7 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		LinkwardenEnabled:                r.FormValue("linkwarden_enabled") == "1",
 		LinkwardenURL:                    r.FormValue("linkwarden_url"),
 		LinkwardenAPIKey:                 r.FormValue("linkwarden_api_key"),
+		LinkwardenCollectionId:           optionalInt64Field(r.FormValue("linkwarden_collection_id")),
 		MatrixBotEnabled:                 r.FormValue("matrix_bot_enabled") == "1",
 		MatrixBotUser:                    r.FormValue("matrix_bot_user"),
 		MatrixBotPassword:                r.FormValue("matrix_bot_password"),

+ 1 - 0
internal/ui/integration_show.go

@@ -84,6 +84,7 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		LinkwardenEnabled:                integration.LinkwardenEnabled,
 		LinkwardenURL:                    integration.LinkwardenURL,
 		LinkwardenAPIKey:                 integration.LinkwardenAPIKey,
+		LinkwardenCollectionId:           integration.LinkwardenCollectionId,
 		MatrixBotEnabled:                 integration.MatrixBotEnabled,
 		MatrixBotUser:                    integration.MatrixBotUser,
 		MatrixBotPassword:                integration.MatrixBotPassword,