Skip to content

Commit 4d656d2

Browse files
authored
feat(integration): add LinkTaco service for saving articles
1 parent 983291c commit 4d656d2

31 files changed

+939
-5
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Features
7272

7373
### Integrations
7474

75-
- 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.
75+
- 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.
7676
- Bookmarklet for subscribing to websites directly from any web browser.
7777
- Webhooks for real-time notifications or custom integrations.
7878
- Compatibility with existing mobile applications using the Fever or Google Reader API.

internal/database/migrations.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,4 +1135,20 @@ var migrations = []func(tx *sql.Tx) error{
11351135
_, err = tx.Exec(sql)
11361136
return err
11371137
},
1138+
func(tx *sql.Tx) (err error) {
1139+
sql := `
1140+
CREATE TYPE linktaco_link_visibility AS ENUM (
1141+
'PUBLIC',
1142+
'PRIVATE'
1143+
);
1144+
ALTER TABLE integrations
1145+
ADD COLUMN linktaco_enabled bool default 'f',
1146+
ADD COLUMN linktaco_api_token text default '',
1147+
ADD COLUMN linktaco_org_slug text default '',
1148+
ADD COLUMN linktaco_tags text default '',
1149+
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
1150+
`
1151+
_, err = tx.Exec(sql)
1152+
return err
1153+
},
11381154
}

internal/integration/integration.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"miniflux.app/v2/internal/integration/karakeep"
1616
"miniflux.app/v2/internal/integration/linkace"
1717
"miniflux.app/v2/internal/integration/linkding"
18+
"miniflux.app/v2/internal/integration/linktaco"
1819
"miniflux.app/v2/internal/integration/linkwarden"
1920
"miniflux.app/v2/internal/integration/matrixbot"
2021
"miniflux.app/v2/internal/integration/notion"
@@ -242,6 +243,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
242243
}
243244
}
244245

246+
if userIntegrations.LinktacoEnabled {
247+
slog.Debug("Sending entry to LinkTaco",
248+
slog.Int64("user_id", userIntegrations.UserID),
249+
slog.Int64("entry_id", entry.ID),
250+
slog.String("entry_url", entry.URL),
251+
)
252+
253+
client := linktaco.NewClient(
254+
userIntegrations.LinktacoAPIToken,
255+
userIntegrations.LinktacoOrgSlug,
256+
userIntegrations.LinktacoTags,
257+
userIntegrations.LinktacoVisibility,
258+
)
259+
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
260+
slog.Error("Unable to send entry to LinkTaco",
261+
slog.Int64("user_id", userIntegrations.UserID),
262+
slog.Int64("entry_id", entry.ID),
263+
slog.String("entry_url", entry.URL),
264+
slog.Any("error", err),
265+
)
266+
}
267+
}
268+
245269
if userIntegrations.LinkwardenEnabled {
246270
slog.Debug("Sending entry to linkwarden",
247271
slog.Int64("user_id", userIntegrations.UserID),
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"fmt"
10+
"net/http"
11+
"strings"
12+
"time"
13+
14+
"miniflux.app/v2/internal/version"
15+
)
16+
17+
const (
18+
defaultClientTimeout = 10 * time.Second
19+
defaultGraphQLURL = "https://api.linktaco.com/query"
20+
maxTags = 10
21+
maxDescriptionLength = 500
22+
)
23+
24+
type Client struct {
25+
graphqlURL string
26+
apiToken string
27+
orgSlug string
28+
tags string
29+
visibility string
30+
}
31+
32+
func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
33+
if visibility == "" {
34+
visibility = "PUBLIC"
35+
}
36+
return &Client{
37+
graphqlURL: defaultGraphQLURL,
38+
apiToken: apiToken,
39+
orgSlug: orgSlug,
40+
tags: tags,
41+
visibility: visibility,
42+
}
43+
}
44+
45+
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
46+
if c.apiToken == "" || c.orgSlug == "" {
47+
return fmt.Errorf("linktaco: missing API token or organization slug")
48+
}
49+
50+
description := entryContent
51+
if len(description) > maxDescriptionLength {
52+
description = description[:maxDescriptionLength]
53+
}
54+
55+
// tags (limit to 10)
56+
tags := strings.FieldsFunc(c.tags, func(c rune) bool {
57+
return c == ',' || c == ' '
58+
})
59+
if len(tags) > maxTags {
60+
tags = tags[:maxTags]
61+
}
62+
// tagsStr is used in GraphQL query to pass comma separated tags
63+
tagsStr := strings.Join(tags, ",")
64+
65+
mutation := `
66+
mutation AddLink($input: LinkInput!) {
67+
addLink(input: $input) {
68+
id
69+
url
70+
title
71+
}
72+
}
73+
`
74+
75+
variables := map[string]any{
76+
"input": map[string]any{
77+
"url": entryURL,
78+
"title": entryTitle,
79+
"description": description,
80+
"orgSlug": c.orgSlug,
81+
"visibility": c.visibility,
82+
"unread": true,
83+
"starred": false,
84+
"archive": false,
85+
"tags": tagsStr,
86+
},
87+
}
88+
89+
requestBody, err := json.Marshal(map[string]any{
90+
"query": mutation,
91+
"variables": variables,
92+
})
93+
if err != nil {
94+
return fmt.Errorf("linktaco: unable to encode request body: %v", err)
95+
}
96+
97+
request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
98+
if err != nil {
99+
return fmt.Errorf("linktaco: unable to create request: %v", err)
100+
}
101+
102+
request.Header.Set("Content-Type", "application/json")
103+
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
104+
request.Header.Set("Authorization", "Bearer "+c.apiToken)
105+
106+
httpClient := &http.Client{Timeout: defaultClientTimeout}
107+
response, err := httpClient.Do(request)
108+
if err != nil {
109+
return fmt.Errorf("linktaco: unable to send request: %v", err)
110+
}
111+
defer response.Body.Close()
112+
113+
if response.StatusCode >= 400 {
114+
return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
115+
}
116+
117+
var graphqlResponse struct {
118+
Data json.RawMessage `json:"data"`
119+
Errors []json.RawMessage `json:"errors"`
120+
}
121+
122+
if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
123+
return fmt.Errorf("linktaco: unable to decode response: %v", err)
124+
}
125+
126+
if len(graphqlResponse.Errors) > 0 {
127+
// Try to extract error message
128+
var errorMsg string
129+
for _, errJSON := range graphqlResponse.Errors {
130+
var errObj struct {
131+
Message string `json:"message"`
132+
}
133+
if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
134+
errorMsg = errObj.Message
135+
break
136+
}
137+
}
138+
if errorMsg == "" {
139+
// Fallback. Should never be reached.
140+
errorMsg = "GraphQL error occurred (fallback message)"
141+
}
142+
return fmt.Errorf("linktaco: %s", errorMsg)
143+
}
144+
145+
return nil
146+
}

0 commit comments

Comments
 (0)