Use stdlib HTTP client for third-party integrations

This commit is contained in:
Frédéric Guillot 2023-08-13 21:58:45 -07:00
parent e5d9f2f5a0
commit 5e520ca5bf
34 changed files with 509 additions and 358 deletions

View File

@ -4,57 +4,64 @@
package apprise
import (
"bytes"
"encoding/json"
"fmt"
"net"
"strings"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 1 * time.Second
const defaultClientTimeout = 10 * time.Second
// Client represents a Apprise client.
type Client struct {
servicesURL string
baseURL string
}
// NewClient returns a new Apprise client.
func NewClient(serviceURL, baseURL string) *Client {
return &Client{serviceURL, baseURL}
}
// PushEntry pushes entry to apprise
func (c *Client) PushEntry(entry *model.Entry) error {
func (c *Client) SendNotification(entry *model.Entry) error {
if c.baseURL == "" || c.servicesURL == "" {
return fmt.Errorf("apprise: missing base URL or service URL")
}
_, err := net.DialTimeout("tcp", c.baseURL, defaultClientTimeout)
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
if err != nil {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
if err != nil {
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
}
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
}
clt := client.New(apiEndpoint)
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
data := &Data{
Urls: c.servicesURL,
Body: message,
}
response, error := clt.PostJSON(data)
if error != nil {
return fmt.Errorf("apprise: ending message failed: %v", error)
}
requestBody, err := json.Marshal(map[string]any{
"urls": c.servicesURL,
"body": message,
})
if err != nil {
return fmt.Errorf("apprise: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("apprise: request failed, status=%d", response.StatusCode)
}
} else {
return fmt.Errorf("%s %s %s", c.baseURL, "responding on port:", strings.Split(c.baseURL, ":")[1])
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("apprise: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("apprise: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil

View File

@ -1,9 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package apprise
type Data struct {
Urls string `json:"urls"`
Body string `json:"body"`
}

View File

@ -4,59 +4,77 @@
package espial // import "miniflux.app/v2/internal/integration/espial"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
// Document structure of an Espial document
type Document struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}
const defaultClientTimeout = 10 * time.Second
// Client represents an Espial client.
type Client struct {
baseURL string
apiKey string
}
// NewClient returns a new Espial client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
// AddEntry sends an entry to Espial.
func (c *Client) AddEntry(link, title, content, tags string) error {
func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("espial: missing base URL or API key")
}
doc := &Document{
Title: title,
Url: link,
ToRead: true,
Tags: tags,
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/add")
if err != nil {
return fmt.Errorf(`espial: invalid API endpoint: %v`, err)
return fmt.Errorf("espial: invalid API endpoint: %v", err)
}
clt := client.New(apiEndpoint)
clt.WithAuthorization("ApiKey " + c.apiKey)
response, err := clt.PostJSON(doc)
requestBody, err := json.Marshal(&espialDocument{
Title: entryTitle,
Url: entryURL,
ToRead: true,
Tags: espialTags,
})
if err != nil {
return fmt.Errorf("espial: unable to send entry: %v", err)
return fmt.Errorf("espial: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("espial: unable to send entry, status=%d", response.StatusCode)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("espial: 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", "ApiKey "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("espial: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
responseBody := new(bytes.Buffer)
responseBody.ReadFrom(response.Body)
return fmt.Errorf("espial: unable to create link: url=%s status=%d body=%s", apiEndpoint, response.StatusCode, responseBody.String())
}
return nil
}
type espialDocument struct {
Title string `json:"title,omitempty"`
Url string `json:"url,omitempty"`
ToRead bool `json:"toread,omitempty"`
Tags string `json:"tags,omitempty"`
}

View File

@ -5,42 +5,52 @@ package instapaper // import "miniflux.app/v2/internal/integration/instapaper"
import (
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
// Client represents an Instapaper client.
const defaultClientTimeout = 10 * time.Second
type Client struct {
username string
password string
}
// NewClient returns a new Instapaper client.
func NewClient(username, password string) *Client {
return &Client{username: username, password: password}
}
// AddURL sends a link to Instapaper.
func (c *Client) AddURL(link, title string) error {
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.username == "" || c.password == "" {
return fmt.Errorf("instapaper: missing credentials")
return fmt.Errorf("instapaper: missing username or password")
}
values := url.Values{}
values.Add("url", link)
values.Add("title", title)
values.Add("url", entryURL)
values.Add("title", entryTitle)
apiURL := "https://www.instapaper.com/api/add?" + values.Encode()
clt := client.New(apiURL)
clt.WithCredentials(c.username, c.password)
response, err := clt.Get()
apiEndpoint := "https://www.instapaper.com/api/add?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
if err != nil {
return fmt.Errorf("instapaper: unable to send url: %v", err)
return fmt.Errorf("instapaper: unable to create request: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("instapaper: unable to send url, status=%d", response.StatusCode)
request.SetBasicAuth(c.username, c.password)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("instapaper: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return fmt.Errorf("instapaper: unable to add URL: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil

View File

@ -29,7 +29,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
logger.Debug("[Integration] Sending entry #%d %q for user #%d to Pinboard", entry.ID, entry.URL, integration.UserID)
client := pinboard.NewClient(integration.PinboardToken)
err := client.AddBookmark(
err := client.CreateBookmark(
entry.URL,
entry.Title,
integration.PinboardTags,
@ -62,7 +62,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.WallabagOnlyURL,
)
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@ -74,7 +74,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.NotionToken,
integration.NotionPageID,
)
if err := client.AddEntry(entry.URL, entry.Title); err != nil {
if err := client.UpdateDocument(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@ -100,8 +100,8 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.EspialAPIKey,
)
if err := client.AddEntry(entry.URL, entry.Title, entry.Content, integration.EspialTags); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
if err := client.CreateLink(entry.URL, entry.Title, integration.EspialTags); err != nil {
logger.Error("[Integration] Unable to send entry #%d to Espial for user #%d: %v", entry.ID, integration.UserID, err)
}
}
@ -123,7 +123,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.LinkdingTags,
integration.LinkdingMarkAsUnread,
)
if err := client.AddEntry(entry.Title, entry.URL); err != nil {
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@ -135,7 +135,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.ReadwiseAPIKey,
)
if err := client.AddEntry(entry.URL); err != nil {
if err := client.CreateDocument(entry.URL); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@ -149,7 +149,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.ShioriPassword,
)
if err := client.AddBookmark(entry.URL, entry.Title); err != nil {
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] Unable to send entry #%d to Shiori for user #%d: %v", entry.ID, integration.UserID, err)
}
}
@ -162,7 +162,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.ShaarliAPISecret,
)
if err := client.AddLink(entry.URL, entry.Title); err != nil {
if err := client.CreateLink(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] Unable to send entry #%d to Shaarli for user #%d: %v", entry.ID, integration.UserID, err)
}
}
@ -197,8 +197,8 @@ func PushEntry(entry *model.Entry, integration *model.Integration) {
integration.AppriseServicesURL,
integration.AppriseURL,
)
err := client.PushEntry(entry)
if err != nil {
if err := client.SendNotification(entry); err != nil {
logger.Error("[Integration] push entry to apprise failed: %v", err)
}
}

View File

@ -4,22 +4,19 @@
package linkding // import "miniflux.app/v2/internal/integration/linkding"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
// Document structure of a Linkding document
type Document struct {
Url string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`
}
const defaultClientTimeout = 10 * time.Second
// Client represents an Linkding client.
type Client struct {
baseURL string
apiKey string
@ -27,43 +24,61 @@ type Client struct {
unread bool
}
// NewClient returns a new Linkding client.
func NewClient(baseURL, apiKey, tags string, unread bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, unread: unread}
}
// AddEntry sends an entry to Linkding.
func (c *Client) AddEntry(title, entryURL string) error {
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("linkding: missing credentials")
return fmt.Errorf("linkding: missing base URL or API key")
}
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
doc := &Document{
Url: entryURL,
Title: title,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
if err != nil {
return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
}
clt := client.New(apiEndpoint)
clt.WithAuthorization("Token " + c.apiKey)
response, err := clt.PostJSON(doc)
requestBody, err := json.Marshal(&linkdingBookmark{
Url: entryURL,
Title: entryTitle,
TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
Unread: c.unread,
})
if err != nil {
return fmt.Errorf("linkding: unable to send entry: %v", err)
return fmt.Errorf("linkding: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("linkding: unable to send entry, status=%d", response.StatusCode)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkding: 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", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkding: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linkding: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type linkdingBookmark struct {
Url string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
TagNames []string `json:"tag_names,omitempty"`
Unread bool `json:"unread,omitempty"`
}

View File

@ -4,51 +4,83 @@
package notion
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
// Client represents a Notion client.
const defaultClientTimeout = 10 * time.Second
type Client struct {
token string
pageID string
apiToken string
pageID string
}
// NewClient returns a new Notion client.
func NewClient(token, pageID string) *Client {
return &Client{token, pageID}
func NewClient(apiToken, pageID string) *Client {
return &Client{apiToken, pageID}
}
func (c *Client) AddEntry(entryURL string, entryTitle string) error {
if c.token == "" || c.pageID == "" {
return fmt.Errorf("notion: missing credentials")
func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
if c.apiToken == "" || c.pageID == "" {
return fmt.Errorf("notion: missing API token or page ID")
}
clt := client.New("https://api.notion.com/v1/blocks/" + c.pageID + "/children")
block := &Data{
Children: []Block{
apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
requestBody, err := json.Marshal(&notionDocument{
Children: []block{
{
Object: "block",
Type: "bookmark",
Bookmark: Bookmark{
Caption: []interface{}{},
Bookmark: bookmarkObject{
Caption: []any{},
URL: entryURL,
},
},
},
}
clt.WithAuthorization("Bearer " + c.token)
customHeaders := map[string]string{
"Notion-Version": "2022-06-28",
}
clt.WithCustomHeaders(customHeaders)
response, error := clt.PatchJSON(block)
if error != nil {
return fmt.Errorf("notion: unable to patch entry: %v", error)
})
if err != nil {
return fmt.Errorf("notion: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("notion: request failed, status=%d", response.StatusCode)
request, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("notion: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Notion-Version", "2022-06-28")
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("notion: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("notion: unable to update document: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type notionDocument struct {
Children []block `json:"children"`
}
type block struct {
Object string `json:"object"`
Type string `json:"type"`
Bookmark bookmarkObject `json:"bookmark"`
}
type bookmarkObject struct {
Caption []any `json:"caption"`
URL string `json:"url"`
}

View File

@ -1,19 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package notion
type Data struct {
Children []Block `json:"children"`
}
type Block struct {
Object string `json:"object"`
Type string `json:"type"`
Bookmark Bookmark `json:"bookmark"`
}
type Bookmark struct {
Caption []interface{} `json:"caption"` // Assuming the "caption" field can have different types
URL string `json:"url"`
}

View File

@ -4,42 +4,30 @@
package nunuxkeeper // import "miniflux.app/v2/internal/integration/nunuxkeeper"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
// Document structure of a Nununx Keeper document
type Document struct {
Title string `json:"title,omitempty"`
Origin string `json:"origin,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"contentType,omitempty"`
}
const defaultClientTimeout = 10 * time.Second
// Client represents an Nunux Keeper client.
type Client struct {
baseURL string
apiKey string
}
// NewClient returns a new Nunux Keeepr client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
// AddEntry sends an entry to Nunux Keeper.
func (c *Client) AddEntry(link, title, content string) error {
func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("nunux-keeper: missing credentials")
}
doc := &Document{
Title: title,
Origin: link,
Content: content,
ContentType: "text/html",
return fmt.Errorf("nunux-keeper: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/v2/documents")
@ -47,16 +35,42 @@ func (c *Client) AddEntry(link, title, content string) error {
return fmt.Errorf(`nunux-keeper: invalid API endpoint: %v`, err)
}
clt := client.New(apiEndpoint)
clt.WithCredentials("api", c.apiKey)
response, err := clt.PostJSON(doc)
requestBody, err := json.Marshal(&nunuxKeeperDocument{
Title: entryTitle,
Origin: entryURL,
Content: entryContent,
ContentType: "text/html",
})
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send entry: %v", err)
return fmt.Errorf("notion: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("nunux-keeper: unable to send entry, status=%d", response.StatusCode)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("nunux-keeper: unable to create request: %v", err)
}
request.SetBasicAuth("api", c.apiKey)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("nunux-keeper: unable to create document: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type nunuxKeeperDocument struct {
Title string `json:"title,omitempty"`
Origin string `json:"origin,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"contentType,omitempty"`
}

View File

@ -5,23 +5,24 @@ package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
import (
"fmt"
"net/http"
"net/url"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
// Client represents a Pinboard client.
const defaultClientTimeout = 10 * time.Second
type Client struct {
authToken string
}
// NewClient returns a new Pinboard client.
func NewClient(authToken string) *Client {
return &Client{authToken: authToken}
}
// AddBookmark sends a link to Pinboard.
func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error {
func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
if c.authToken == "" {
return fmt.Errorf("pinboard: missing auth token")
}
@ -33,19 +34,29 @@ func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error
values := url.Values{}
values.Add("auth_token", c.authToken)
values.Add("url", link)
values.Add("description", title)
values.Add("tags", tags)
values.Add("url", entryURL)
values.Add("description", entryTitle)
values.Add("tags", pinboardTags)
values.Add("toread", toRead)
clt := client.New("https://api.pinboard.in/v1/posts/add?" + values.Encode())
response, err := clt.Get()
apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
if err != nil {
return fmt.Errorf("pinboard: unable to send bookmark: %v", err)
return fmt.Errorf("pinboard: unable to create request: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("pinboard: unable to send bookmark, status=%d", response.StatusCode)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("pinboard: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("pinboard: unable to create a bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil

View File

@ -4,12 +4,13 @@
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"net/http"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
// Connector manages the authorization flow with Pocket to get a personal access token.
@ -24,72 +25,82 @@ func NewConnector(consumerKey string) *Connector {
// RequestToken fetches a new request token from Pocket API.
func (c *Connector) RequestToken(redirectURL string) (string, error) {
type req struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
clt := client.New("https://getpocket.com/v3/oauth/request")
response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
apiEndpoint := "https://getpocket.com/v3/oauth/request"
requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
if err != nil {
return "", fmt.Errorf("pocket: unable to fetch request token: %v", err)
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("pocket: unable to fetch request token, status=%d", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
values, err := url.ParseQuery(string(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("pocket: unable to parse response: %v", err)
return "", fmt.Errorf("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
code := values.Get("code")
if code == "" {
return "", errors.New("pocket: code is empty")
var result createTokenResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
return code, nil
if result.Code == "" {
return "", errors.New("pocket: request token is empty")
}
return result.Code, nil
}
// AccessToken fetches a new access token once the end-user authorized the application.
func (c *Connector) AccessToken(requestToken string) (string, error) {
type req struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
clt := client.New("https://getpocket.com/v3/oauth/authorize")
response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, Code: requestToken})
apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
if err != nil {
return "", fmt.Errorf("pocket: unable to fetch access token: %v", err)
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("pocket: unable to fetch access token, status=%d", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
values, err := url.ParseQuery(string(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("pocket: unable to parse response: %v", err)
return "", fmt.Errorf("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
token := values.Get("access_token")
if token == "" {
return "", errors.New("pocket: access_token is empty")
var result authorizeReponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
return token, nil
if result.AccessToken == "" {
return "", errors.New("pocket: access token is empty")
}
return result.AccessToken, nil
}
// AuthorizationURL returns the authorization URL for the end-user.
@ -100,3 +111,22 @@ func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
redirectURL,
)
}
type createTokenRequest struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
type createTokenResponse struct {
Code string `json:"code"`
}
type authorizeRequest struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
type authorizeReponse struct {
AccessToken string `json:"access_token"`
Username string `json:"username"`
}

View File

@ -4,51 +4,67 @@
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
// Client represents a Pocket client.
const defaultClientTimeout = 10 * time.Second
type Client struct {
consumerKey string
accessToken string
}
// NewClient returns a new Pocket client.
func NewClient(consumerKey, accessToken string) *Client {
return &Client{consumerKey, accessToken}
}
// AddURL sends a single link to Pocket.
func (c *Client) AddURL(link, title string) error {
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.consumerKey == "" || c.accessToken == "" {
return fmt.Errorf("pocket: missing credentials")
return fmt.Errorf("pocket: missing consumer key or access token")
}
type body struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}
data := &body{
apiEndpoint := "https://getpocket.com/v3/add"
requestBody, err := json.Marshal(&createItemRequest{
AccessToken: c.accessToken,
ConsumerKey: c.consumerKey,
Title: title,
URL: link,
}
clt := client.New("https://getpocket.com/v3/add")
response, err := clt.PostJSON(data)
Title: entryTitle,
URL: entryURL,
})
if err != nil {
return fmt.Errorf("pocket: unable to send url: %v", err)
return fmt.Errorf("pocket: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("pocket: unable to send url, status=%d", response.StatusCode)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type createItemRequest struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}

View File

@ -6,61 +6,64 @@
package readwise // import "miniflux.app/v2/internal/integration/readwise"
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"net/http"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/version"
)
// Document structure of a Readwise Reader document
// This initial version accepts only the one required field, the URL
type Document struct {
Url string `json:"url"`
}
const (
readwiseApiEndpoint = "https://readwise.io/api/v3/save/"
defaultClientTimeout = 10 * time.Second
)
// Client represents a Readwise Reader client.
type Client struct {
apiKey string
}
// NewClient returns a new Readwise Reader client.
func NewClient(apiKey string) *Client {
return &Client{apiKey: apiKey}
}
// AddEntry sends an entry to Readwise Reader.
func (c *Client) AddEntry(link string) error {
func (c *Client) CreateDocument(entryURL string) error {
if c.apiKey == "" {
return fmt.Errorf("readwise: missing API key")
}
doc := &Document{
Url: link,
}
requestBody, err := json.Marshal(&readwiseDocument{
URL: entryURL,
})
apiURL, err := getAPIEndpoint("https://readwise.io/api/v3/save/")
if err != nil {
return err
return fmt.Errorf("readwise: unable to encode request body: %v", err)
}
clt := client.New(apiURL)
clt.WithAuthorization("Token " + c.apiKey)
response, err := clt.PostJSON(doc)
request, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("readwise: unable to send entry: %v", err)
return fmt.Errorf("readwise: unable to create request: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("readwise: unable to send entry, status=%d", response.StatusCode)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Token "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readwise: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("readwise: unable to create document: url=%s status=%d", readwiseApiEndpoint, response.StatusCode)
}
return nil
}
func getAPIEndpoint(pathURL string) (string, error) {
u, err := url.Parse(pathURL)
if err != nil {
return "", fmt.Errorf("readwise: invalid API endpoint: %v", err)
}
return u.String(), nil
type readwiseDocument struct {
URL string `json:"url"`
}

View File

@ -29,7 +29,7 @@ func NewClient(baseURL, apiSecret string) *Client {
return &Client{baseURL: baseURL, apiSecret: apiSecret}
}
func (c *Client) AddLink(entryURL, entryTitle string) error {
func (c *Client) CreateLink(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiSecret == "" {
return fmt.Errorf("shaarli: missing base URL or API secret")
}
@ -49,7 +49,7 @@ func (c *Client) AddLink(entryURL, entryTitle string) error {
return fmt.Errorf("shaarli: unable to encode request body: %v", err)
}
request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shaarli: unable to create request: %v", err)
}

View File

@ -26,7 +26,7 @@ func NewClient(baseURL, username, password string) *Client {
return &Client{baseURL: baseURL, username: username, password: password}
}
func (c *Client) AddBookmark(entryURL, entryTitle string) error {
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.username == "" || c.password == "" {
return fmt.Errorf("shiori: missing base URL, username or password")
}
@ -51,13 +51,12 @@ func (c *Client) AddBookmark(entryURL, entryTitle string) error {
return fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("shiori: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Session-Id", sessionID)
@ -87,7 +86,7 @@ func (c *Client) authenticate() (sessionID string, err error) {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("shiori: unable to create request: %v", err)
}

View File

@ -4,16 +4,20 @@
package wallabag // import "miniflux.app/v2/internal/integration/wallabag"
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"miniflux.app/v2/internal/http/client"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
// Client represents a Wallabag client.
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
clientID string
@ -23,16 +27,13 @@ type Client struct {
onlyURL bool
}
// NewClient returns a new Wallabag client.
func NewClient(baseURL, clientID, clientSecret, username, password string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, onlyURL}
}
// AddEntry sends a link to Wallabag.
// Pass an empty string in `content` to let Wallabag fetch the article content.
func (c *Client) AddEntry(link, title, content string) error {
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return fmt.Errorf("wallabag: missing credentials")
return fmt.Errorf("wallabag: missing base URL, client ID, client secret, username or password")
}
accessToken, err := c.getAccessToken()
@ -40,29 +41,47 @@ func (c *Client) AddEntry(link, title, content string) error {
return err
}
return c.createEntry(accessToken, link, title, content)
return c.createEntry(accessToken, entryURL, entryTitle, entryContent)
}
func (c *Client) createEntry(accessToken, link, title, content string) error {
endpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent string) error {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
if err != nil {
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
}
data := map[string]string{"url": link, "title": title}
if !c.onlyURL {
data["content"] = content
if c.onlyURL {
entryContent = ""
}
clt := client.New(endpoint)
clt.WithAuthorization("Bearer " + accessToken)
response, err := clt.PostJSON(data)
requestBody, err := json.Marshal(&createEntryRequest{
URL: entryURL,
Title: entryTitle,
Content: entryContent,
})
if err != nil {
return fmt.Errorf("wallabag: unable to post entry using %q endpoint: %v", endpoint, err)
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("wallabag: request failed using %q, status=%d", endpoint, response.StatusCode)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("wallbag: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+accessToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("wallabag: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
@ -76,27 +95,37 @@ func (c *Client) getAccessToken() (string, error) {
values.Add("username", c.username)
values.Add("password", c.password)
endpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
if err != nil {
return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
}
clt := client.New(endpoint)
response, err := clt.PostForm(values)
request, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))
if err != nil {
return "", fmt.Errorf("wallabag: unable to get access token using %q endpoint: %v", endpoint, err)
return "", fmt.Errorf("wallbag: unable to create request: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("wallabag: request failed using %q, status=%d", endpoint, response.StatusCode)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
token, err := decodeTokenResponse(response.Body)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", err
return "", fmt.Errorf("wallabag: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return token.AccessToken, nil
var responseBody tokenResponse
if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {
return "", fmt.Errorf("wallabag: unable to decode token response: %v", err)
}
return responseBody.AccessToken, nil
}
type tokenResponse struct {
@ -107,13 +136,8 @@ type tokenResponse struct {
TokenType string `json:"token_type"`
}
func decodeTokenResponse(body io.Reader) (*tokenResponse, error) {
var token tokenResponse
decoder := json.NewDecoder(body)
if err := decoder.Decode(&token); err != nil {
return nil, fmt.Errorf("wallabag: unable to decode token response: %v", err)
}
return &token, nil
type createEntryRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
}

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Αποθήκευση άρθρων στο Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Τελικό σημείο Nunux Keeper API",
"form.integration.nunux_keeper_api_key": "Κλειδί API Nunux Keeper",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise Services URLs (seperated by comma)",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Save entries to Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Tallenna artikkelit Nunux Keeperiin",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-päätepiste",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-avain",

View File

@ -353,11 +353,11 @@
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
"form.integration.wallabag_password": "Mot de passe de Wallabag",
"form.integration.notion_activate": "Sauvegarder les articles vers Notion",
"form.integration.notion_page_id": "l'identifiant de la page Notion",
"form.integration.notion_page_id": "Identifiant de la page Notion",
"form.integration.notion_token": "Jeton d'accès de l'API de Notion",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_activate": "Emvoyer les articles vers Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise services",
"form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
"form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
"form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",

View File

@ -354,7 +354,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Simpan artikel ke Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Titik URL API Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Kunci API Nunux Keeper",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
"form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",

View File

@ -359,7 +359,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Salvar itens no Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Endpoint de API do Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Chave de API do Nunux Keeper",

View File

@ -359,7 +359,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
"form.integration.nunux_keeper_api_key": "API-ключ Nunux Keeper",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",

View File

@ -360,7 +360,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Зберігати статті до Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
"form.integration.nunux_keeper_api_key": "Ключ API Nunux Keeper",

View File

@ -355,7 +355,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端点",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",

View File

@ -357,7 +357,7 @@
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Apprise services urls seperated by comma",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "儲存文章到 Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端點",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 金鑰",