Переглянути джерело

feat(integration): add LinkTaco service for saving articles

Peter Sanchez 7 місяців тому
батько
коміт
4d656d2739
31 змінених файлів з 939 додано та 5 видалено
  1. 1 1
      README.md
  2. 16 0
      internal/database/migrations.go
  3. 24 0
      internal/integration/integration.go
  4. 146 0
      internal/integration/linktaco/linktaco.go
  5. 440 0
      internal/integration/linktaco/linktaco_test.go
  6. 11 0
      internal/locale/translations/de_DE.json
  7. 11 0
      internal/locale/translations/el_EL.json
  8. 11 0
      internal/locale/translations/en_US.json
  9. 11 0
      internal/locale/translations/es_ES.json
  10. 11 0
      internal/locale/translations/fi_FI.json
  11. 11 0
      internal/locale/translations/fr_FR.json
  12. 11 0
      internal/locale/translations/hi_IN.json
  13. 11 0
      internal/locale/translations/id_ID.json
  14. 11 0
      internal/locale/translations/it_IT.json
  15. 11 0
      internal/locale/translations/ja_JP.json
  16. 11 0
      internal/locale/translations/nan_Latn_pehoeji.json
  17. 11 0
      internal/locale/translations/nl_NL.json
  18. 11 0
      internal/locale/translations/pl_PL.json
  19. 11 0
      internal/locale/translations/pt_BR.json
  20. 11 0
      internal/locale/translations/ro_RO.json
  21. 11 0
      internal/locale/translations/ru_RU.json
  22. 11 0
      internal/locale/translations/tr_TR.json
  23. 12 1
      internal/locale/translations/uk_UA.json
  24. 11 0
      internal/locale/translations/zh_CN.json
  25. 11 0
      internal/locale/translations/zh_TW.json
  26. 5 0
      internal/model/integration.go
  27. 24 3
      internal/storage/integration.go
  28. 31 0
      internal/template/templates/views/integrations.html
  29. 15 0
      internal/ui/form/integration.go
  30. 5 0
      internal/ui/integration_show.go
  31. 11 0
      internal/ui/integration_update.go

+ 1 - 1
README.md

@@ -72,7 +72,7 @@ Features
 
 ### Integrations
 
-- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
+- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
 - Bookmarklet for subscribing to websites directly from any web browser.
 - Webhooks for real-time notifications or custom integrations.
 - Compatibility with existing mobile applications using the Fever or Google Reader API.

+ 16 - 0
internal/database/migrations.go

@@ -1135,4 +1135,20 @@ var migrations = []func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			CREATE TYPE linktaco_link_visibility AS ENUM (
+				'PUBLIC',
+				'PRIVATE'
+  			);
+			ALTER TABLE integrations
+				ADD COLUMN linktaco_enabled bool default 'f',
+				ADD COLUMN linktaco_api_token text default '',
+				ADD COLUMN linktaco_org_slug text default '',
+				ADD COLUMN linktaco_tags text default '',
+				ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 24 - 0
internal/integration/integration.go

@@ -15,6 +15,7 @@ import (
 	"miniflux.app/v2/internal/integration/karakeep"
 	"miniflux.app/v2/internal/integration/linkace"
 	"miniflux.app/v2/internal/integration/linkding"
+	"miniflux.app/v2/internal/integration/linktaco"
 	"miniflux.app/v2/internal/integration/linkwarden"
 	"miniflux.app/v2/internal/integration/matrixbot"
 	"miniflux.app/v2/internal/integration/notion"
@@ -242,6 +243,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
 		}
 	}
 
+	if userIntegrations.LinktacoEnabled {
+		slog.Debug("Sending entry to LinkTaco",
+			slog.Int64("user_id", userIntegrations.UserID),
+			slog.Int64("entry_id", entry.ID),
+			slog.String("entry_url", entry.URL),
+		)
+
+		client := linktaco.NewClient(
+			userIntegrations.LinktacoAPIToken,
+			userIntegrations.LinktacoOrgSlug,
+			userIntegrations.LinktacoTags,
+			userIntegrations.LinktacoVisibility,
+		)
+		if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
+			slog.Error("Unable to send entry to LinkTaco",
+				slog.Int64("user_id", userIntegrations.UserID),
+				slog.Int64("entry_id", entry.ID),
+				slog.String("entry_url", entry.URL),
+				slog.Any("error", err),
+			)
+		}
+	}
+
 	if userIntegrations.LinkwardenEnabled {
 		slog.Debug("Sending entry to linkwarden",
 			slog.Int64("user_id", userIntegrations.UserID),

+ 146 - 0
internal/integration/linktaco/linktaco.go

@@ -0,0 +1,146 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"miniflux.app/v2/internal/version"
+)
+
+const (
+	defaultClientTimeout = 10 * time.Second
+	defaultGraphQLURL    = "https://api.linktaco.com/query"
+	maxTags              = 10
+	maxDescriptionLength = 500
+)
+
+type Client struct {
+	graphqlURL string
+	apiToken   string
+	orgSlug    string
+	tags       string
+	visibility string
+}
+
+func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
+	if visibility == "" {
+		visibility = "PUBLIC"
+	}
+	return &Client{
+		graphqlURL: defaultGraphQLURL,
+		apiToken:   apiToken,
+		orgSlug:    orgSlug,
+		tags:       tags,
+		visibility: visibility,
+	}
+}
+
+func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
+	if c.apiToken == "" || c.orgSlug == "" {
+		return fmt.Errorf("linktaco: missing API token or organization slug")
+	}
+
+	description := entryContent
+	if len(description) > maxDescriptionLength {
+		description = description[:maxDescriptionLength]
+	}
+
+	// tags (limit to 10)
+	tags := strings.FieldsFunc(c.tags, func(c rune) bool {
+		return c == ',' || c == ' '
+	})
+	if len(tags) > maxTags {
+		tags = tags[:maxTags]
+	}
+	// tagsStr is used in GraphQL query to pass comma separated tags
+	tagsStr := strings.Join(tags, ",")
+
+	mutation := `
+		mutation AddLink($input: LinkInput!) {
+			addLink(input: $input) {
+				id
+				url
+				title
+			}
+		}
+	`
+
+	variables := map[string]any{
+		"input": map[string]any{
+			"url":         entryURL,
+			"title":       entryTitle,
+			"description": description,
+			"orgSlug":     c.orgSlug,
+			"visibility":  c.visibility,
+			"unread":      true,
+			"starred":     false,
+			"archive":     false,
+			"tags":        tagsStr,
+		},
+	}
+
+	requestBody, err := json.Marshal(map[string]any{
+		"query":     mutation,
+		"variables": variables,
+	})
+	if err != nil {
+		return fmt.Errorf("linktaco: unable to encode request body: %v", err)
+	}
+
+	request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
+	if err != nil {
+		return fmt.Errorf("linktaco: 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.apiToken)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
+	if err != nil {
+		return fmt.Errorf("linktaco: unable to send request: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
+	}
+
+	var graphqlResponse struct {
+		Data   json.RawMessage   `json:"data"`
+		Errors []json.RawMessage `json:"errors"`
+	}
+
+	if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
+		return fmt.Errorf("linktaco: unable to decode response: %v", err)
+	}
+
+	if len(graphqlResponse.Errors) > 0 {
+		// Try to extract error message
+		var errorMsg string
+		for _, errJSON := range graphqlResponse.Errors {
+			var errObj struct {
+				Message string `json:"message"`
+			}
+			if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
+				errorMsg = errObj.Message
+				break
+			}
+		}
+		if errorMsg == "" {
+			// Fallback. Should never be reached.
+			errorMsg = "GraphQL error occurred (fallback message)"
+		}
+		return fmt.Errorf("linktaco: %s", errorMsg)
+	}
+
+	return nil
+}

+ 440 - 0
internal/integration/linktaco/linktaco_test.go

@@ -0,0 +1,440 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package linktaco
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestCreateBookmark(t *testing.T) {
+	tests := []struct {
+		name           string
+		apiToken       string
+		orgSlug        string
+		tags           string
+		visibility     string
+		entryURL       string
+		entryTitle     string
+		entryContent   string
+		serverResponse func(w http.ResponseWriter, r *http.Request)
+		wantErr        bool
+		errContains    string
+	}{
+		{
+			name:         "successful bookmark creation",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			tags:         "tag1, tag2",
+			visibility:   "PUBLIC",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test Article",
+			entryContent: "Test content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				// Verify authorization header
+				auth := r.Header.Get("Authorization")
+				if auth != "Bearer test-token" {
+					t.Errorf("Expected Authorization header 'Bearer test-token', 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 mutation exists
+				if _, ok := req["query"]; !ok {
+					t.Error("Missing 'query' field in request")
+				}
+
+				// Return success response
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"data": map[string]interface{}{
+						"addLink": map[string]interface{}{
+							"id":    "123",
+							"url":   "https://example.com",
+							"title": "Test Article",
+						},
+					},
+				})
+			},
+			wantErr: false,
+		},
+		{
+			name:         "missing API token",
+			apiToken:     "",
+			orgSlug:      "test-org",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				// Should not be called
+				t.Error("Server should not be called when API token is missing")
+			},
+			wantErr:     true,
+			errContains: "missing API token or organization slug",
+		},
+		{
+			name:         "missing organization slug",
+			apiToken:     "test-token",
+			orgSlug:      "",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				// Should not be called
+				t.Error("Server should not be called when org slug is missing")
+			},
+			wantErr:     true,
+			errContains: "missing API token or organization slug",
+		},
+		{
+			name:         "GraphQL error response",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"errors": []interface{}{
+						map[string]interface{}{
+							"message": "Invalid input",
+						},
+					},
+				})
+			},
+			wantErr:     true,
+			errContains: "Invalid input",
+		},
+		{
+			name:         "HTTP error status",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusUnauthorized)
+			},
+			wantErr:     true,
+			errContains: "status=401",
+		},
+		{
+			name:         "private visibility permission error",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			visibility:   "PRIVATE",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"errors": []interface{}{
+						map[string]interface{}{
+							"message": "PRIVATE visibility requires a paid LinkTaco account",
+						},
+					},
+				})
+			},
+			wantErr:     true,
+			errContains: "PRIVATE visibility requires a paid LinkTaco account",
+		},
+		{
+			name:         "content truncation",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				body, _ := io.ReadAll(r.Body)
+				var req map[string]interface{}
+				json.Unmarshal(body, &req)
+
+				// Check that description was truncated
+				variables := req["variables"].(map[string]interface{})
+				input := variables["input"].(map[string]interface{})
+				description := input["description"].(string)
+
+				if len(description) != maxDescriptionLength {
+					t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
+				}
+
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"data": map[string]interface{}{
+						"addLink": map[string]interface{}{"id": "123"},
+					},
+				})
+			},
+			wantErr: false,
+		},
+		{
+			name:         "tag limiting",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			tags:         "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				body, _ := io.ReadAll(r.Body)
+				var req map[string]interface{}
+				json.Unmarshal(body, &req)
+
+				// Check that only 10 tags were sent
+				variables := req["variables"].(map[string]interface{})
+				input := variables["input"].(map[string]interface{})
+				tags := input["tags"].(string)
+
+				tagCount := len(strings.Split(tags, ","))
+				if tagCount != maxTags {
+					t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
+				}
+
+				w.WriteHeader(http.StatusOK)
+				json.NewEncoder(w).Encode(map[string]interface{}{
+					"data": map[string]interface{}{
+						"addLink": map[string]interface{}{"id": "123"},
+					},
+				})
+			},
+			wantErr: false,
+		},
+		{
+			name:         "invalid JSON response",
+			apiToken:     "test-token",
+			orgSlug:      "test-org",
+			entryURL:     "https://example.com",
+			entryTitle:   "Test",
+			entryContent: "Content",
+			serverResponse: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusOK)
+				w.Write([]byte("invalid json"))
+			},
+			wantErr:     true,
+			errContains: "unable to decode response",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create test server if we have a server response function
+			var serverURL string
+			if tt.serverResponse != nil {
+				server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
+				defer server.Close()
+				serverURL = server.URL
+			}
+
+			// Create client with test server URL
+			client := &Client{
+				graphqlURL: serverURL,
+				apiToken:   tt.apiToken,
+				orgSlug:    tt.orgSlug,
+				tags:       tt.tags,
+				visibility: tt.visibility,
+			}
+
+			// Call CreateBookmark
+			err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
+
+			// Check error expectations
+			if tt.wantErr {
+				if err == nil {
+					t.Errorf("Expected error but got none")
+				} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
+					t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
+				}
+			} else {
+				if err != nil {
+					t.Errorf("Unexpected error: %v", err)
+				}
+			}
+		})
+	}
+}
+
+func TestNewClient(t *testing.T) {
+	tests := []struct {
+		name               string
+		apiToken           string
+		orgSlug            string
+		tags               string
+		visibility         string
+		expectedVisibility string
+	}{
+		{
+			name:               "with all parameters",
+			apiToken:           "token",
+			orgSlug:            "org",
+			tags:               "tag1,tag2",
+			visibility:         "PRIVATE",
+			expectedVisibility: "PRIVATE",
+		},
+		{
+			name:               "empty visibility defaults to PUBLIC",
+			apiToken:           "token",
+			orgSlug:            "org",
+			tags:               "tag1",
+			visibility:         "",
+			expectedVisibility: "PUBLIC",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
+
+			if client.apiToken != tt.apiToken {
+				t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
+			}
+			if client.orgSlug != tt.orgSlug {
+				t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
+			}
+			if client.tags != tt.tags {
+				t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
+			}
+			if client.visibility != tt.expectedVisibility {
+				t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
+			}
+			if client.graphqlURL != defaultGraphQLURL {
+				t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
+			}
+		})
+	}
+}
+
+func TestGraphQLMutation(t *testing.T) {
+	// Test that the GraphQL mutation is properly formatted
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		var req map[string]interface{}
+		if err := json.Unmarshal(body, &req); err != nil {
+			t.Fatalf("Failed to parse request: %v", err)
+		}
+
+		// Verify mutation structure
+		query, ok := req["query"].(string)
+		if !ok {
+			t.Fatal("Missing query field")
+		}
+
+		// Check that mutation contains expected parts
+		if !strings.Contains(query, "mutation AddLink") {
+			t.Error("Mutation should contain 'mutation AddLink'")
+		}
+		if !strings.Contains(query, "$input: LinkInput!") {
+			t.Error("Mutation should contain input parameter")
+		}
+		if !strings.Contains(query, "addLink(input: $input)") {
+			t.Error("Mutation should contain addLink call")
+		}
+
+		// Verify variables structure
+		variables, ok := req["variables"].(map[string]interface{})
+		if !ok {
+			t.Fatal("Missing variables field")
+		}
+
+		input, ok := variables["input"].(map[string]interface{})
+		if !ok {
+			t.Fatal("Missing input in variables")
+		}
+
+		// Check all required fields
+		requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
+		for _, field := range requiredFields {
+			if _, ok := input[field]; !ok {
+				t.Errorf("Missing required field: %s", field)
+			}
+		}
+
+		// Return success
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"data": map[string]interface{}{
+				"addLink": map[string]interface{}{
+					"id": "123",
+				},
+			},
+		})
+	}))
+	defer server.Close()
+
+	client := &Client{
+		graphqlURL: server.URL,
+		apiToken:   "test-token",
+		orgSlug:    "test-org",
+		tags:       "test",
+		visibility: "PUBLIC",
+	}
+
+	err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+}
+
+func BenchmarkCreateBookmark(b *testing.B) {
+	// Create a mock server that always returns success
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"data": map[string]interface{}{
+				"addLink": map[string]interface{}{
+					"id": "123",
+				},
+			},
+		})
+	}))
+	defer server.Close()
+
+	client := &Client{
+		graphqlURL: server.URL,
+		apiToken:   "test-token",
+		orgSlug:    "test-org",
+		tags:       "tag1,tag2,tag3",
+		visibility: "PUBLIC",
+	}
+
+	// Run benchmark
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
+	}
+}
+
+func BenchmarkTagProcessing(b *testing.B) {
+	// Benchmark tag splitting and limiting
+	tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		tagsSplitFn := func(c rune) bool {
+			return c == ',' || c == ' '
+		}
+		splitTags := strings.FieldsFunc(tags, tagsSplitFn)
+		if len(splitTags) > maxTags {
+			splitTags = splitTags[:maxTags]
+		}
+		_ = strings.Join(splitTags, ",")
+	}
+}

+ 11 - 0
internal/locale/translations/de_DE.json

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
     "error.user_already_exists": "Dieser Benutzer existiert bereits.",
     "error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token und Organization Slug sind erforderlich.",
     "form.api_key.label.description": "API-Schlüsselbezeichnung",
     "form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
     "form.category.label.title": "Titel",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
     "form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
     "form.integration.linkding_tags": "Linkding-Tags",
+    "form.integration.linktaco_activate": "Einträge in LinkTaco speichern",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Holen Sie sich Ihr persönliches Zugriffstoken unter",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tags (max. 10, kommagetrennt)",
+    "form.integration.linktaco_tags_hint": "Maximal 10 Tags, kommagetrennt",
+    "form.integration.linktaco_visibility": "Sichtbarkeit",
+    "form.integration.linktaco_visibility_public": "Öffentlich",
+    "form.integration.linktaco_visibility_private": "Privat",
+    "form.integration.linktaco_visibility_hint": "PRIVATE Sichtbarkeit erfordert ein kostenpflichtiges LinkTaco-Konto",
     "form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
     "form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
     "form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
     "error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
     "error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
+    "error.linktaco_missing_required_fields": "Το LinkTaco API Token και το Organization Slug είναι απαραίτητα",
     "form.api_key.label.description": "Ετικέτα κλειδιού API",
     "form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
     "form.category.label.title": "Τίτλος",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
     "form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
     "form.integration.linkding_tags": "Ετικέτες Linkding",
+    "form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
+    "form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
+    "form.integration.linktaco_visibility": "Ορατότητα",
+    "form.integration.linktaco_visibility_public": "Δημόσια",
+    "form.integration.linktaco_visibility_private": "Ιδιωτική",
+    "form.integration.linktaco_visibility_hint": "Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco",
     "form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
     "form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
     "form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",

+ 11 - 0
internal/locale/translations/en_US.json

@@ -89,6 +89,7 @@
     "error.different_passwords": "Passwords are not the same.",
     "error.duplicate_fever_username": "There is already someone else with the same Fever username!",
     "error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token and Organization Slug are required",
     "error.duplicate_linked_account": "There is already someone associated with this provider!",
     "error.duplicated_feed": "This feed already exists.",
     "error.empty_file": "This file is empty.",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Mark bookmark as unread",
     "form.integration.linkding_endpoint": "Linkding API Endpoint",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "Save entries to LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Get your personal access token at",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tags (max 10, comma-separated)",
+    "form.integration.linktaco_tags_hint": "Maximum 10 tags, comma-separated",
+    "form.integration.linktaco_visibility": "Visibility",
+    "form.integration.linktaco_visibility_public": "Public",
+    "form.integration.linktaco_visibility_private": "Private",
+    "form.integration.linktaco_visibility_hint": "PRIVATE visibility requires a paid LinkTaco account",
     "form.integration.linkwarden_activate": "Save entries to Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API key",
     "form.integration.linkwarden_endpoint": "Linkwarden Base URL",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
     "error.user_already_exists": "Este usuario ya existe.",
     "error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token y Organization Slug son obligatorios.",
     "form.api_key.label.description": "Etiqueta de clave API",
     "form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
     "form.category.label.title": "Título",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Marcar marcador como no leído",
     "form.integration.linkding_endpoint": "Acceso API de Linkding",
     "form.integration.linkding_tags": "Etiquetas de Linkding",
+    "form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Etiquetas (máx. 10, separadas por comas)",
+    "form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
+    "form.integration.linktaco_visibility": "Visibilidad",
+    "form.integration.linktaco_visibility_public": "Público",
+    "form.integration.linktaco_visibility_private": "Privado",
+    "form.integration.linktaco_visibility_hint": "La visibilidad PRIVADA requiere una cuenta de pago de LinkTaco",
     "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",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
     "error.user_already_exists": "Käyttäjä on jo olemassa.",
     "error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
     "form.api_key.label.description": "API Key Label",
     "form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
     "form.category.label.title": "Otsikko",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
     "form.integration.linkding_endpoint": "Linkding API-päätepiste",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunnistesi osoitteesta",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tagit (enintään 10, pilkuilla erotettu)",
+    "form.integration.linktaco_tags_hint": "Enintään 10 tagia, pilkuilla erotettu",
+    "form.integration.linktaco_visibility": "Näkyvyys",
+    "form.integration.linktaco_visibility_public": "Julkinen",
+    "form.integration.linktaco_visibility_private": "Yksityinen",
+    "form.integration.linktaco_visibility_hint": "YKSITYINEN näkyvyys vaatii maksullisen LinkTaco-tilin",
     "form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
     "form.integration.linkwarden_api_key": "Linkwarden API-avain",
     "form.integration.linkwarden_endpoint": "Linkwarden Base URL",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
     "error.user_already_exists": "Cet utilisateur existe déjà.",
     "error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
+    "error.linktaco_missing_required_fields": "Le token API LinkTaco et le slug de l'organisation sont requis.",
     "form.api_key.label.description": "Libellé de la clé d'API",
     "form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
     "form.category.label.title": "Titre",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Marquer le lien comme non lu",
     "form.integration.linkding_endpoint": "URL de l'API de Linkding",
     "form.integration.linkding_tags": "Libellés",
+    "form.integration.linktaco_activate": "Sauvegarder les entrées vers LinkTaco",
+    "form.integration.linktaco_api_token": "Token API LinkTaco",
+    "form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
+    "form.integration.linktaco_org_slug": "Slug de l'organisation",
+    "form.integration.linktaco_tags": "Tags (max. 10, séparés par des virgules)",
+    "form.integration.linktaco_tags_hint": "Maximum 10 tags, séparés par des virgules",
+    "form.integration.linktaco_visibility": "Visibilité",
+    "form.integration.linktaco_visibility_public": "Public",
+    "form.integration.linktaco_visibility_private": "Privé",
+    "form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
     "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",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
     "error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
     "error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token और Organization Slug आवश्यक हैं",
     "form.api_key.label.description": "एपीआई कुंजी लेबल",
     "form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
     "form.category.label.title": "शीर्षक",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
     "form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
+    "form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
+    "form.integration.linktaco_visibility": "दृश्यता",
+    "form.integration.linktaco_visibility_public": "सार्वजनिक",
+    "form.integration.linktaco_visibility_private": "निजी",
+    "form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
     "form.integration.linkwarden_activate": "Save entries to Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API key",
     "form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",

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

@@ -157,6 +157,7 @@
     "error.unlink_account_without_password": "Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.",
     "error.user_already_exists": "Pengguna ini sudah ada.",
     "error.user_mandatory_fields": "Harus ada nama pengguna.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token dan Organization Slug diperlukan",
     "form.api_key.label.description": "Label Kunci API",
     "form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
     "form.category.label.title": "Judul",
@@ -247,6 +248,16 @@
     "form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
     "form.integration.linkding_endpoint": "Titik URL API Linkding",
     "form.integration.linkding_tags": "Tanda Linkding",
+    "form.integration.linktaco_activate": "Simpan entri ke LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Dapatkan token akses pribadi Anda di",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tag (maksimal 10, dipisahkan koma)",
+    "form.integration.linktaco_tags_hint": "Maksimal 10 tag, dipisahkan koma",
+    "form.integration.linktaco_visibility": "Visibilitas",
+    "form.integration.linktaco_visibility_public": "Publik",
+    "form.integration.linktaco_visibility_private": "Pribadi",
+    "form.integration.linktaco_visibility_hint": "Visibilitas PRIBADI membutuhkan akun LinkTaco berbayar",
     "form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
     "form.integration.linkwarden_api_key": "Kunci API Linkwarden",
     "form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
     "error.user_already_exists": "Questo utente esiste già.",
     "error.user_mandatory_fields": "Il nome utente è obbligatorio.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug sono richiesti",
     "form.api_key.label.description": "Etichetta chiave API",
     "form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
     "form.category.label.title": "Titolo",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Segna i preferiti come non letti",
     "form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "Salva le voci in LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Ottieni il tuo token di accesso personale su",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tag (massimo 10, separati da virgola)",
+    "form.integration.linktaco_tags_hint": "Massimo 10 tag, separati da virgola",
+    "form.integration.linktaco_visibility": "Visibilità",
+    "form.integration.linktaco_visibility_public": "Pubblico",
+    "form.integration.linktaco_visibility_private": "Privato",
+    "form.integration.linktaco_visibility_hint": "La visibilità PRIVATA richiede un account LinkTaco a pagamento",
     "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",

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

@@ -157,6 +157,7 @@
     "error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
     "error.user_already_exists": "このユーザーは既に存在します。",
     "error.user_mandatory_fields": "ユーザー名が必要です。",
+    "error.linktaco_missing_required_fields": "LinkTaco API TokenとOrganization Slugが必要です",
     "form.api_key.label.description": "API キーラベル",
     "form.category.hide_globally": "未読一覧に記事を表示しない",
     "form.category.label.title": "タイトル",
@@ -247,6 +248,16 @@
     "form.integration.linkding_bookmark": "ブックマークを未読にする",
     "form.integration.linkding_endpoint": "Linkding の API Endpoint",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "LinkTacoでエントリを保存する",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "パーソナルアクセストークンを取得する",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "タグ (最大10件、カンマ区切り)",
+    "form.integration.linktaco_tags_hint": "最大10件のタグ、カンマ区切り",
+    "form.integration.linktaco_visibility": "公開設定",
+    "form.integration.linktaco_visibility_public": "公開",
+    "form.integration.linktaco_visibility_private": "非公開",
+    "form.integration.linktaco_visibility_hint": "非公開設定には有料のLinkTacoアカウントが必要です",
     "form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
     "form.integration.linkwarden_api_key": "Linkwarden の API key",
     "form.integration.linkwarden_endpoint": "リンクワーデン ベース URL",

+ 11 - 0
internal/locale/translations/nan_Latn_pehoeji.json

@@ -157,6 +157,7 @@
     "error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
     "error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
     "error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token kâh Organization Slug sio̍kêi",
     "form.api_key.label.description": "API só-sîkhan-á",
     "form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
     "form.category.label.title": "Piau-tôe",
@@ -247,6 +248,16 @@
     "form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
     "form.integration.linkding_endpoint": "Linkding API thâu",
     "form.integration.linkding_tags": "Linkding khan-á",
+    "form.integration.linktaco_activate": "Pó-chûn siau-sit kàu LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Chhú-tek lí ê kò-jîn chún-chhú token tī",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "khan-á (siōn-koân 10, iōng tō͘-tiám keh khui)",
+    "form.integration.linktaco_tags_hint": "Siōn-koân 10 khan-á, iōng tō͘-tiám keh khui",
+    "form.integration.linktaco_visibility": "Kò-chhiah-kì sìa?",
+    "form.integration.linktaco_visibility_public": "Kò-chhiah-kì",
+    "form.integration.linktaco_visibility_private": "Su-lîn",
+    "form.integration.linktaco_visibility_hint": "Su-lîn sìa tík tio̍h-ài chù-hêng LinkTaco kháu-chō",
     "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",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
     "error.user_already_exists": "Deze gebruiker bestaat al.",
     "error.user_mandatory_fields": "Gebruikersnaam is verplicht",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token en Organization Slug zijn verplicht",
     "form.api_key.label.description": "API-sleutel omschrijving",
     "form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
     "form.category.label.title": "Titel",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
     "form.integration.linkding_endpoint": "Linkding URL",
     "form.integration.linkding_tags": "Linkding tags",
+    "form.integration.linktaco_activate": "Artikelen opslaan in LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Verkrijg uw persoonlijke toegangstoken op",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tags (max 10, kommagescheiden)",
+    "form.integration.linktaco_tags_hint": "Maximaal 10 tags, kommagescheiden",
+    "form.integration.linktaco_visibility": "Zichtbaarheid",
+    "form.integration.linktaco_visibility_public": "Openbaar",
+    "form.integration.linktaco_visibility_private": "Privé",
+    "form.integration.linktaco_visibility_hint": "PRIVÉ zichtbaarheid vereist een betaald LinkTaco account",
     "form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
     "form.integration.linkwarden_endpoint": "Linkwarden Basis URL",

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

@@ -163,6 +163,7 @@
     "error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
     "error.user_already_exists": "Ten użytkownik już istnieje.",
     "error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token i Organization Slug są wymagane",
     "form.api_key.label.description": "Etykieta klucza API",
     "form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
     "form.category.label.title": "Tytuł",
@@ -253,6 +254,16 @@
     "form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
     "form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
     "form.integration.linkding_tags": "Znaczniki Linkding",
+    "form.integration.linktaco_activate": "Zapisuj wpisy w LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Uzyskaj swój osobisty token dostępu w",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Znaczniki (max 10, oddzielone przecinkami)",
+    "form.integration.linktaco_tags_hint": "Maksymalnie 10 znaczników, oddzielone przecinkami",
+    "form.integration.linktaco_visibility": "Widoczność",
+    "form.integration.linktaco_visibility_public": "Publiczne",
+    "form.integration.linktaco_visibility_private": "Prywatne",
+    "form.integration.linktaco_visibility_hint": "Widoczność PRYWATNA wymaga płatnego konta LinkTaco",
     "form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
     "form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
     "form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwardena",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Você deve definir uma senha, senão não será possível efetuar a sessão novamente.",
     "error.user_already_exists": "Esse usuário já existe.",
     "error.user_mandatory_fields": "O nome de usuário é obrigatório.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug são obrigatórios",
     "form.api_key.label.description": "Etiqueta da chave de API",
     "form.category.hide_globally": "Ocultar entradas na lista global não lida",
     "form.category.label.title": "Título",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Salvar marcador como não lido",
     "form.integration.linkding_endpoint": "Endpoint de API do Linkding",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "Salvar itens no LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Obtenha seu token de acesso pessoal em",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tags (máx 10, separadas por vírgula)",
+    "form.integration.linktaco_tags_hint": "Máximo 10 tags, separadas por vírgula",
+    "form.integration.linktaco_visibility": "Visibilidade",
+    "form.integration.linktaco_visibility_public": "Público",
+    "form.integration.linktaco_visibility_private": "Privado",
+    "form.integration.linktaco_visibility_hint": "Visibilidade PRIVADA requer uma conta LinkTaco paga",
     "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",

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

@@ -163,6 +163,7 @@
     "error.unlink_account_without_password": "Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.",
     "error.user_already_exists": "Acest utilizator există deja.",
     "error.user_mandatory_fields": "Numele utilizatorului este obligatoriu.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token și Organization Slug sunt necesare",
     "form.api_key.label.description": "Etichetă Cheie API",
     "form.category.hide_globally": "Ascunde intrările în lista globală de articole necitite",
     "form.category.label.title": "Titlu",
@@ -253,6 +254,16 @@
     "form.integration.linkding_bookmark": "Marchează semnele de carte ca necitite",
     "form.integration.linkding_endpoint": "Endpoint API Linkding",
     "form.integration.linkding_tags": "TAG-uri Linkding",
+    "form.integration.linktaco_activate": "Salvează înregistrările în LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Obțineți jetonul de acces personal la",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Tag-uri (maxim 10, separate prin virgule)",
+    "form.integration.linktaco_tags_hint": "Maxim 10 tag-uri, separate prin virgule",
+    "form.integration.linktaco_visibility": "Vizibilitate",
+    "form.integration.linktaco_visibility_public": "Public",
+    "form.integration.linktaco_visibility_private": "Privat",
+    "form.integration.linktaco_visibility_hint": "Vizibilitatea PRIVATĂ necesită un cont LinkTaco plătit",
     "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",

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

@@ -163,6 +163,7 @@
     "error.unlink_account_without_password": "Вы должны установить пароль, иначе вы не сможете войти снова.",
     "error.user_already_exists": "Этот пользователь уже существует.",
     "error.user_mandatory_fields": "Имя пользователя обязательно.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token и Organization Slug обязательны",
     "form.api_key.label.description": "Описание API-ключа",
     "form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
     "form.category.label.title": "Название",
@@ -253,6 +254,16 @@
     "form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
     "form.integration.linkding_endpoint": "Конечная точка Linkding API",
     "form.integration.linkding_tags": "Теги Linkding",
+    "form.integration.linktaco_activate": "Сохранять статьи в LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Получить ваш персональный токен доступа на",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Теги (макс. 10, через запятую)",
+    "form.integration.linktaco_tags_hint": "Максимум 10 тегов, через запятую",
+    "form.integration.linktaco_visibility": "Видимость",
+    "form.integration.linktaco_visibility_public": "Публично",
+    "form.integration.linktaco_visibility_private": "Приватно",
+    "form.integration.linktaco_visibility_hint": "ПРИВАТНАЯ видимость требует платного аккаунта LinkTaco",
     "form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
     "form.integration.linkwarden_api_key": "API-ключ Linkwarden",
     "form.integration.linkwarden_endpoint": "Базовый URL-адрес Linkwarden",

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

@@ -160,6 +160,7 @@
     "error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
     "error.user_already_exists": "Bu kullanıcı zaten mevcut.",
     "error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token ve Organization Slug gereklidir",
     "form.api_key.label.description": "API Anahtar Etiketi",
     "form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
     "form.category.label.title": "Başlık",
@@ -250,6 +251,16 @@
     "form.integration.linkding_bookmark": "Yer imini okunmadı olarak işaretle",
     "form.integration.linkding_endpoint": "Linkding API Uç Noktası",
     "form.integration.linkding_tags": "Linkding Etiketleri",
+    "form.integration.linktaco_activate": "Makaleleri LinkTaco'ya kaydet",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Kişisel erişim tokenınızı edinin",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Etiketler (maks 10, virgülle ayrılmış)",
+    "form.integration.linktaco_tags_hint": "Maksimum 10 etiket, virgülle ayrılmış",
+    "form.integration.linktaco_visibility": "Görünürlük",
+    "form.integration.linktaco_visibility_public": "Genel",
+    "form.integration.linktaco_visibility_private": "Özel",
+    "form.integration.linktaco_visibility_hint": "ÖZEL görünürlük ücretli bir LinkTaco hesabı gerektirir",
     "form.integration.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
     "form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
     "form.integration.linkwarden_endpoint": "Linkwarden Temel URL'si",

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

@@ -162,7 +162,8 @@
     "error.unable_to_update_user": "Не вдається оновити користувача.",
     "error.unlink_account_without_password": "Ви маєте встановити пароль, щоб мати можливість увійти наступного разу",
     "error.user_already_exists": "Такий користувач вже існує.",
-    "error.user_mandatory_fields": "Ім’я користувача є обов’язковим.",
+    "error.user_mandatory_fields": "Ім'я користувача є обов'язковим.",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token і Organization Slug є обов'язковими",
     "form.api_key.label.description": "Назва ключа API",
     "form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
     "form.category.label.title": "Назва",
@@ -253,6 +254,16 @@
     "form.integration.linkding_bookmark": "Відмічати закладку як непрочитану",
     "form.integration.linkding_endpoint": "Linkding API Endpoint",
     "form.integration.linkding_tags": "Linkding Tags",
+    "form.integration.linktaco_activate": "Зберігати статті в LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "Отримайте ваш персональний токен доступу на",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "Теги (макс. 10, через кому)",
+    "form.integration.linktaco_tags_hint": "Максимум 10 тегів, через кому",
+    "form.integration.linktaco_visibility": "Видимість",
+    "form.integration.linktaco_visibility_public": "Публічно",
+    "form.integration.linktaco_visibility_private": "Приватно",
+    "form.integration.linktaco_visibility_hint": "ПРИВАТНА видимість потребує платного акаунта LinkTaco",
     "form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
     "form.integration.linkwarden_api_key": "Ключ API Linkwarden",
     "form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",

+ 11 - 0
internal/locale/translations/zh_CN.json

@@ -157,6 +157,7 @@
     "error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
     "error.user_already_exists": "此用户已存在。",
     "error.user_mandatory_fields": "必须填写用户名。",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
     "form.api_key.label.description": "API 密钥标签",
     "form.category.hide_globally": "在全局未读列表中隐藏条目",
     "form.category.label.title": "标题",
@@ -247,6 +248,16 @@
     "form.integration.linkding_bookmark": "将书签标记为未读",
     "form.integration.linkding_endpoint": "Linkding API 端点",
     "form.integration.linkding_tags": "Linkding 标签",
+    "form.integration.linktaco_activate": "保存条目到 LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "在此获取您的个人访问令牌",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "标签(最多10个,逗号分隔)",
+    "form.integration.linktaco_tags_hint": "最多10个标签,逗号分隔",
+    "form.integration.linktaco_visibility": "可见性",
+    "form.integration.linktaco_visibility_public": "公开",
+    "form.integration.linktaco_visibility_private": "私人",
+    "form.integration.linktaco_visibility_hint": "私人可见性需要付费的 LinkTaco 帐户",
     "form.integration.linkwarden_activate": "保存条目到 Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API 密钥",
     "form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",

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

@@ -157,6 +157,7 @@
     "error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
     "error.user_already_exists": "使用者已存在",
     "error.user_mandatory_fields": "必須填寫使用者名稱",
+    "error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
     "form.api_key.label.description": "API 金鑰標籤",
     "form.category.hide_globally": "在全域未讀列表中隱藏文章",
     "form.category.label.title": "標題",
@@ -247,6 +248,16 @@
     "form.integration.linkding_bookmark": "標記為未讀",
     "form.integration.linkding_endpoint": "Linkding API 端點",
     "form.integration.linkding_tags": "Linkding 標籤",
+    "form.integration.linktaco_activate": "儲存文章到 LinkTaco",
+    "form.integration.linktaco_api_token": "LinkTaco API Token",
+    "form.integration.linktaco_api_token_hint": "在此取得您的個人存取權杖",
+    "form.integration.linktaco_org_slug": "Organization Slug",
+    "form.integration.linktaco_tags": "標籤(最多10個,逗號分隔)",
+    "form.integration.linktaco_tags_hint": "最多10個標籤,逗號分隔",
+    "form.integration.linktaco_visibility": "可見性",
+    "form.integration.linktaco_visibility_public": "公開",
+    "form.integration.linktaco_visibility_private": "私人",
+    "form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳戶",
     "form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
     "form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
     "form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",

+ 5 - 0
internal/model/integration.go

@@ -59,6 +59,11 @@ type Integration struct {
 	LinkdingAPIKey                   string
 	LinkdingTags                     string
 	LinkdingMarkAsUnread             bool
+	LinktacoEnabled                  bool
+	LinktacoAPIToken                 string
+	LinktacoOrgSlug                  string
+	LinktacoTags                     string
+	LinktacoVisibility               string
 	LinkwardenEnabled                bool
 	LinkwardenURL                    string
 	LinkwardenAPIKey                 string

+ 24 - 3
internal/storage/integration.go

@@ -220,7 +220,12 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			rssbridge_token,
 			karakeep_enabled,
 			karakeep_api_key,
-			karakeep_url
+			karakeep_url,
+			linktaco_enabled,
+			linktaco_api_token,
+			linktaco_org_slug,
+			linktaco_tags,
+			linktaco_visibility
 		FROM
 			integrations
 		WHERE
@@ -340,6 +345,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.KarakeepEnabled,
 		&integration.KarakeepAPIKey,
 		&integration.KarakeepURL,
+		&integration.LinktacoEnabled,
+		&integration.LinktacoAPIToken,
+		&integration.LinktacoOrgSlug,
+		&integration.LinktacoTags,
+		&integration.LinktacoVisibility,
 	)
 	switch {
 	case err == sql.ErrNoRows:
@@ -467,9 +477,14 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			rssbridge_token=$108,
 			karakeep_enabled=$109,
 			karakeep_api_key=$110,
-			karakeep_url=$111
+			karakeep_url=$111,
+			linktaco_enabled=$112,
+			linktaco_api_token=$113,
+			linktaco_org_slug=$114,
+			linktaco_tags=$115,
+			linktaco_visibility=$116
 		WHERE
-			user_id=$112
+			user_id=$117
 	`
 	_, err := s.db.Exec(
 		query,
@@ -584,6 +599,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 		integration.KarakeepEnabled,
 		integration.KarakeepAPIKey,
 		integration.KarakeepURL,
+		integration.LinktacoEnabled,
+		integration.LinktacoAPIToken,
+		integration.LinktacoOrgSlug,
+		integration.LinktacoTags,
+		integration.LinktacoVisibility,
 		integration.UserID,
 	)
 
@@ -614,6 +634,7 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
 				readwise_enabled='t' OR
 				linkace_enabled='t' OR
 				linkding_enabled='t' OR
+				linktaco_enabled='t' OR
 				linkwarden_enabled='t' OR
 				apprise_enabled='t' OR
 				shiori_enabled='t' OR

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

@@ -228,6 +228,37 @@
         </div>
     </details>
 
+    <details {{ if .form.LinktacoEnabled }}open{{ end }}>
+        <summary>LinkTaco</summary>
+        <div class="form-section">
+            <label>
+                <input type="checkbox" name="linktaco_enabled" value="1" {{ if .form.LinktacoEnabled }}checked{{ end }}> {{ t "form.integration.linktaco_activate" }}
+            </label>
+
+            <label for="form-linktaco-api-token">{{ t "form.integration.linktaco_api_token" }}</label>
+            <input type="password" name="linktaco_api_token" id="form-linktaco-api-token" value="{{ .form.LinktacoAPIToken }}" spellcheck="false">
+            <p class="hint">{{ t "form.integration.linktaco_api_token_hint" }} <a href="https://linktaco.com/oauth2/personal" target="_blank" rel="noopener noreferrer">https://linktaco.com/oauth2/personal</a></p>
+
+            <label for="form-linktaco-org-slug">{{ t "form.integration.linktaco_org_slug" }}</label>
+            <input type="text" name="linktaco_org_slug" id="form-linktaco-org-slug" value="{{ .form.LinktacoOrgSlug }}" placeholder="my-organization" spellcheck="false">
+
+            <label for="form-linktaco-tags">{{ t "form.integration.linktaco_tags" }}</label>
+            <input type="text" name="linktaco_tags" id="form-linktaco-tags" value="{{ .form.LinktacoTags }}" placeholder="miniflux, bookmarks" spellcheck="false">
+            <p class="hint">{{ t "form.integration.linktaco_tags_hint" }}</p>
+
+            <label for="form-linktaco-visibility">{{ t "form.integration.linktaco_visibility" }}</label>
+            <select name="linktaco_visibility" id="form-linktaco-visibility">
+                <option value="PUBLIC" {{ if eq .form.LinktacoVisibility "PUBLIC" }}selected{{ end }}>{{ t "form.integration.linktaco_visibility_public" }}</option>
+                <option value="PRIVATE" {{ if eq .form.LinktacoVisibility "PRIVATE" }}selected{{ end }}>{{ t "form.integration.linktaco_visibility_private" }}</option>
+            </select>
+            <p class="hint">{{ t "form.integration.linktaco_visibility_hint" }}</p>
+
+            <div class="buttons">
+                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+            </div>
+        </div>
+    </details>
+
     <details {{ if .form.LinkwardenEnabled }}open{{ end }}>
         <summary>Linkwarden</summary>
         <div class="form-section">

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

@@ -62,6 +62,11 @@ type IntegrationForm struct {
 	LinkdingAPIKey                   string
 	LinkdingTags                     string
 	LinkdingMarkAsUnread             bool
+	LinktacoEnabled                  bool
+	LinktacoAPIToken                 string
+	LinktacoOrgSlug                  string
+	LinktacoTags                     string
+	LinktacoVisibility               string
 	LinkwardenEnabled                bool
 	LinkwardenURL                    string
 	LinkwardenAPIKey                 string
@@ -175,6 +180,11 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.LinkdingAPIKey = i.LinkdingAPIKey
 	integration.LinkdingTags = i.LinkdingTags
 	integration.LinkdingMarkAsUnread = i.LinkdingMarkAsUnread
+	integration.LinktacoEnabled = i.LinktacoEnabled
+	integration.LinktacoAPIToken = i.LinktacoAPIToken
+	integration.LinktacoOrgSlug = i.LinktacoOrgSlug
+	integration.LinktacoTags = i.LinktacoTags
+	integration.LinktacoVisibility = i.LinktacoVisibility
 	integration.LinkwardenEnabled = i.LinkwardenEnabled
 	integration.LinkwardenURL = i.LinkwardenURL
 	integration.LinkwardenAPIKey = i.LinkwardenAPIKey
@@ -290,6 +300,11 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		LinkdingAPIKey:                   r.FormValue("linkding_api_key"),
 		LinkdingTags:                     r.FormValue("linkding_tags"),
 		LinkdingMarkAsUnread:             r.FormValue("linkding_mark_as_unread") == "1",
+		LinktacoEnabled:                  r.FormValue("linktaco_enabled") == "1",
+		LinktacoAPIToken:                 r.FormValue("linktaco_api_token"),
+		LinktacoOrgSlug:                  r.FormValue("linktaco_org_slug"),
+		LinktacoTags:                     r.FormValue("linktaco_tags"),
+		LinktacoVisibility:               r.FormValue("linktaco_visibility"),
 		LinkwardenEnabled:                r.FormValue("linkwarden_enabled") == "1",
 		LinkwardenURL:                    r.FormValue("linkwarden_url"),
 		LinkwardenAPIKey:                 r.FormValue("linkwarden_api_key"),

+ 5 - 0
internal/ui/integration_show.go

@@ -75,6 +75,11 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		LinkdingAPIKey:                   integration.LinkdingAPIKey,
 		LinkdingTags:                     integration.LinkdingTags,
 		LinkdingMarkAsUnread:             integration.LinkdingMarkAsUnread,
+		LinktacoEnabled:                  integration.LinktacoEnabled,
+		LinktacoAPIToken:                 integration.LinktacoAPIToken,
+		LinktacoOrgSlug:                  integration.LinktacoOrgSlug,
+		LinktacoTags:                     integration.LinktacoTags,
+		LinktacoVisibility:               integration.LinktacoVisibility,
 		LinkwardenEnabled:                integration.LinkwardenEnabled,
 		LinkwardenURL:                    integration.LinkwardenURL,
 		LinkwardenAPIKey:                 integration.LinkwardenAPIKey,

+ 11 - 0
internal/ui/integration_update.go

@@ -75,6 +75,17 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 		integration.WebhookSecret = ""
 	}
 
+	if integrationForm.LinktacoEnabled {
+		if integrationForm.LinktacoAPIToken == "" || integrationForm.LinktacoOrgSlug == "" {
+			sess.NewFlashErrorMessage(printer.Print("error.linktaco_missing_required_fields"))
+			html.Redirect(w, r, route.Path(h.router, "integrations"))
+			return
+		}
+		if integration.LinktacoVisibility == "" {
+			integration.LinktacoVisibility = "PUBLIC"
+		}
+	}
+
 	err = h.store.UpdateIntegration(integration)
 	if err != nil {
 		html.ServerError(w, r, err)