mirror of https://github.com/miniflux/v2.git
Compare commits
8 Commits
d4304c77f6
...
8539f9f186
Author | SHA1 | Date |
---|---|---|
rootknight | 8539f9f186 | |
fin444 | a631bd527d | |
Alpha Chen | ca62b0b36b | |
Kioubit | 7d6a4243c1 | |
dependabot[bot] | d056aa1f73 | |
dependabot[bot] | 018e24404e | |
rootknight | be2d507a9d | |
rootknight | 797534ffd9 |
|
@ -30,7 +30,7 @@ jobs:
|
|||
with:
|
||||
go-version: "1.22.x"
|
||||
- run: "go vet ./..."
|
||||
- uses: golangci/golangci-lint-action@v4
|
||||
- uses: golangci/golangci-lint-action@v5
|
||||
with:
|
||||
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
|
|
2
go.mod
2
go.mod
|
@ -3,7 +3,7 @@ module miniflux.app/v2
|
|||
// +heroku goVersion go1.22
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.1
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/andybalholm/brotli v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.10.0
|
||||
|
|
4
go.sum
4
go.sum
|
@ -1,5 +1,5 @@
|
|||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
|
|
|
@ -2021,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFetchNebulaWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.FetchNebulaWatchTime()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOdyseeWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
|
||||
|
|
|
@ -56,6 +56,7 @@ const (
|
|||
defaultMediaResourceTypes = "image"
|
||||
defaultMediaProxyURL = ""
|
||||
defaultFilterEntryMaxAgeDays = 0
|
||||
defaultFetchNebulaWatchTime = false
|
||||
defaultFetchOdyseeWatchTime = false
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||
|
@ -140,6 +141,7 @@ type Options struct {
|
|||
mediaProxyMode string
|
||||
mediaProxyResourceTypes []string
|
||||
mediaProxyCustomURL string
|
||||
fetchNebulaWatchTime bool
|
||||
fetchOdyseeWatchTime bool
|
||||
fetchYouTubeWatchTime bool
|
||||
filterEntryMaxAgeDays int
|
||||
|
@ -216,6 +218,7 @@ func NewOptions() *Options {
|
|||
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||
|
@ -486,6 +489,12 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
|
|||
return o.youTubeEmbedUrlOverride
|
||||
}
|
||||
|
||||
// FetchNebulaWatchTime returns true if the Nebula video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchNebulaWatchTime() bool {
|
||||
return o.fetchNebulaWatchTime
|
||||
}
|
||||
|
||||
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchOdyseeWatchTime() bool {
|
||||
|
@ -647,6 +656,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
|||
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
|
||||
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
|
||||
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
|
||||
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
|
||||
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
|
||||
"HTTPS": o.HTTPS,
|
||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||
|
|
|
@ -259,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
|||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
|
|
|
@ -888,4 +888,14 @@ var migrations = []func(tx *sql.Tx) error{
|
|||
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
|
|
@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
|
|||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
// Cookie names.
|
||||
const (
|
||||
CookieAppSessionID = "MinifluxAppSessionID"
|
||||
CookieUserSessionID = "MinifluxUserSessionID"
|
||||
|
||||
// Cookie duration in days.
|
||||
cookieDuration = 30
|
||||
)
|
||||
|
||||
// New creates a new cookie.
|
||||
|
@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
|||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import (
|
|||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
|
@ -359,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.OmnivoreEnabled {
|
||||
slog.Debug("Sending entry to Omnivore",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
|
@ -376,6 +378,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.RaindropEnabled {
|
||||
slog.Debug("Sending entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
|
||||
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
collectionID string
|
||||
tags []string
|
||||
}
|
||||
|
||||
func NewClient(token, collectionID, tags string) *Client {
|
||||
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
|
||||
}
|
||||
|
||||
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
|
||||
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
if c.token == "" {
|
||||
return fmt.Errorf("raindrop: missing token")
|
||||
}
|
||||
|
||||
var request *http.Request
|
||||
requestBodyJson, err := json.Marshal(&raindrop{
|
||||
Link: entryURL,
|
||||
Title: entryTitle,
|
||||
Collection: collection{Id: c.collectionID},
|
||||
Tags: c.tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: 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.token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type collection struct {
|
||||
Id string `json:"$id"`
|
||||
}
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||
"form.integration.matrix_bot_url": "Matrix server URL",
|
||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Save entries to readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
||||
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
||||
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
||||
"form.integration.readeck_api_key": "Readeck API-avain",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||
"form.integration.matrix_bot_url": "URL du serveur Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
|
|
|
@ -442,6 +442,10 @@
|
|||
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
||||
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
||||
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
||||
"form.integration.readeck_api_key": "Kunci API Readeck",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del server Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
|
||||
"form.integration.readeck_api_key": "API key dell'account Readeck",
|
||||
|
|
|
@ -442,6 +442,10 @@
|
|||
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
||||
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
|
||||
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck に記事を保存する",
|
||||
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck の API key",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
||||
"form.integration.matrix_bot_url": "URL van de Matrix-server",
|
||||
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Opslaan naar Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||
|
|
|
@ -462,6 +462,10 @@
|
|||
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
||||
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
|
|
|
@ -452,6 +452,10 @@
|
|||
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Salvar itens no Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
|
||||
"form.integration.readeck_api_key": "Chave de API do Readeck",
|
||||
|
|
|
@ -462,6 +462,10 @@
|
|||
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
||||
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
|
||||
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
|
||||
"form.integration.readeck_api_key": "API-ключ Readeck",
|
||||
|
|
|
@ -222,6 +222,10 @@
|
|||
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
|
||||
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
|
||||
"form.integration.readeck_api_key": "Readeck API Anahtarı",
|
||||
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
|
||||
|
|
|
@ -462,6 +462,10 @@
|
|||
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
||||
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
|
||||
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||
|
|
|
@ -442,6 +442,10 @@
|
|||
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
||||
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "保存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端点",
|
||||
"form.integration.readeck_api_key": "Readeck API 密钥",
|
||||
|
|
|
@ -442,6 +442,10 @@
|
|||
"form.integration.matrix_bot_password": "Matrix 的密碼",
|
||||
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "儲存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端點",
|
||||
"form.integration.readeck_api_key": "Readeck API 金鑰",
|
||||
|
|
|
@ -90,4 +90,8 @@ type Integration struct {
|
|||
OmnivoreEnabled bool
|
||||
OmnivoreAPIKey string
|
||||
OmnivoreURL string
|
||||
RaindropEnabled bool
|
||||
RaindropToken string
|
||||
RaindropCollectionID string
|
||||
RaindropTags string
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
|
||||
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
|
||||
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
|
@ -277,6 +278,25 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
|||
}
|
||||
}
|
||||
|
||||
if shouldFetchNebulaWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchNebulaWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchOdyseeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchOdyseeWatchTime(entry.URL)
|
||||
|
@ -311,6 +331,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
|||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchNebulaWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := nebulaRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
|
@ -350,6 +378,38 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
|||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := strconv.ParseInt(durs, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur / 60), nil
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
|
|
|
@ -193,7 +193,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
|||
rssbridge_url,
|
||||
omnivore_enabled,
|
||||
omnivore_api_key,
|
||||
omnivore_url
|
||||
omnivore_url,
|
||||
raindrop_enabled,
|
||||
raindrop_token,
|
||||
raindrop_collection_id,
|
||||
raindrop_tags
|
||||
FROM
|
||||
integrations
|
||||
WHERE
|
||||
|
@ -286,6 +290,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
|||
&integration.OmnivoreEnabled,
|
||||
&integration.OmnivoreAPIKey,
|
||||
&integration.OmnivoreURL,
|
||||
&integration.RaindropEnabled,
|
||||
&integration.RaindropToken,
|
||||
&integration.RaindropCollectionID,
|
||||
&integration.RaindropTags,
|
||||
)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
|
@ -386,9 +394,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
|||
omnivore_url=$81,
|
||||
linkwarden_enabled=$82,
|
||||
linkwarden_url=$83,
|
||||
linkwarden_api_key=$84
|
||||
linkwarden_api_key=$84,
|
||||
raindrop_enabled=$85,
|
||||
raindrop_token=$86,
|
||||
raindrop_collection_id=$87,
|
||||
raindrop_tags=$88
|
||||
WHERE
|
||||
user_id=$85
|
||||
user_id=$89
|
||||
`
|
||||
_, err := s.db.Exec(
|
||||
query,
|
||||
|
@ -476,6 +488,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
|||
integration.LinkwardenEnabled,
|
||||
integration.LinkwardenURL,
|
||||
integration.LinkwardenAPIKey,
|
||||
integration.RaindropEnabled,
|
||||
integration.RaindropToken,
|
||||
integration.RaindropCollectionID,
|
||||
integration.RaindropTags,
|
||||
integration.UserID,
|
||||
)
|
||||
|
||||
|
@ -513,7 +529,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
|
|||
readeck_enabled='t' OR
|
||||
shaarli_enabled='t' OR
|
||||
webhook_enabled='t' OR
|
||||
omnivore_enabled='t'
|
||||
omnivore_enabled='t' OR
|
||||
raindrop_enabled='t'
|
||||
)
|
||||
`
|
||||
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<title>{{template "title" .}} - Miniflux</title>
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Miniflux">
|
||||
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
|
||||
|
|
|
@ -326,6 +326,28 @@
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.RaindropEnabled }}open{{ end }}>
|
||||
<summary>Raindrop</summary>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="raindrop_enabled" value="1" {{ if .form.RaindropEnabled }}checked{{ end }}> {{ t "form.integration.raindrop_activate" }}
|
||||
</label>
|
||||
|
||||
<label for="form-raindrop-token">{{ t "form.integration.raindrop_token" }}</label>
|
||||
<input type="text" name="raindrop_token" id="form-raindrop-token" value="{{ .form.RaindropToken }}" spellcheck="false">
|
||||
|
||||
<label for="form-raindrop-collection-id">{{ t "form.integration.raindrop_collection_id" }}</label>
|
||||
<input type="text" name="raindrop_collection_id" id="form-raindrop-collection-id" value="{{ .form.RaindropCollectionID }}" spellcheck="false">
|
||||
|
||||
<label for="form-raindrop-tags">{{ t "form.integration.raindrop_tags" }}</label>
|
||||
<input type="text" name="raindrop_tags" id="form-raindrop-tags" value="{{ .form.RaindropTags }}" spellcheck="false">
|
||||
|
||||
<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.ReadeckEnabled }}open{{ end }}>
|
||||
<summary>Readeck</summary>
|
||||
<div class="form-section">
|
||||
|
|
|
@ -96,6 +96,10 @@ type IntegrationForm struct {
|
|||
OmnivoreEnabled bool
|
||||
OmnivoreAPIKey string
|
||||
OmnivoreURL string
|
||||
RaindropEnabled bool
|
||||
RaindropToken string
|
||||
RaindropCollectionID string
|
||||
RaindropTags string
|
||||
}
|
||||
|
||||
// Merge copy form values to the model.
|
||||
|
@ -181,6 +185,10 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
|
|||
integration.OmnivoreEnabled = i.OmnivoreEnabled
|
||||
integration.OmnivoreAPIKey = i.OmnivoreAPIKey
|
||||
integration.OmnivoreURL = i.OmnivoreURL
|
||||
integration.RaindropEnabled = i.RaindropEnabled
|
||||
integration.RaindropToken = i.RaindropToken
|
||||
integration.RaindropCollectionID = i.RaindropCollectionID
|
||||
integration.RaindropTags = i.RaindropTags
|
||||
}
|
||||
|
||||
// NewIntegrationForm returns a new IntegrationForm.
|
||||
|
@ -269,6 +277,10 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
|
|||
OmnivoreEnabled: r.FormValue("omnivore_enabled") == "1",
|
||||
OmnivoreAPIKey: r.FormValue("omnivore_api_key"),
|
||||
OmnivoreURL: r.FormValue("omnivore_url"),
|
||||
RaindropEnabled: r.FormValue("raindrop_enabled") == "1",
|
||||
RaindropToken: r.FormValue("raindrop_token"),
|
||||
RaindropCollectionID: r.FormValue("raindrop_collection_id"),
|
||||
RaindropTags: r.FormValue("raindrop_tags"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -110,6 +110,10 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
|
|||
OmnivoreEnabled: integration.OmnivoreEnabled,
|
||||
OmnivoreAPIKey: integration.OmnivoreAPIKey,
|
||||
OmnivoreURL: integration.OmnivoreURL,
|
||||
RaindropEnabled: integration.RaindropEnabled,
|
||||
RaindropToken: integration.RaindropToken,
|
||||
RaindropCollectionID: integration.RaindropCollectionID,
|
||||
RaindropTags: integration.RaindropTags,
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
|
|
@ -244,6 +244,12 @@ Set the value to 1 to disable the internal scheduler service\&.
|
|||
.br
|
||||
Default is false (The internal scheduler service is enabled)\&.
|
||||
.TP
|
||||
.B FETCH_NEBULA_WATCH_TIME
|
||||
Set the value to 1 to scrape video duration from Nebula website and
|
||||
use it as a reading time\&.
|
||||
.br
|
||||
Disabled by default\&.
|
||||
.TP
|
||||
.B FETCH_ODYSEE_WATCH_TIME
|
||||
Set the value to 1 to scrape video duration from Odysee website and
|
||||
use it as a reading time\&.
|
||||
|
|
Loading…
Reference in New Issue