Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da951164d5 | |||
| a0988f6c16 | |||
| fddc861e41 | |||
| 9288001f09 | |||
| da261a5fc7 | |||
| edeb5f0366 | |||
| 65ff328804 | |||
| 7759ea1b43 | |||
| 7c5d6cf35f | |||
| 39cc1887ea | |||
| b0a3b4d5d9 | |||
| 469f23968e | |||
| 051bdecabd | |||
| fe10d4302d | |||
| b9dfd5bf6d | |||
| 51030ef1a8 | |||
| 2bcc4b8399 | |||
| ea4d0a4f72 | |||
| 5c5ad19c43 | |||
| 191f3a7ad7 | |||
| 366928b35d | |||
| 3b654fefa7 | |||
| 0adbcc3a04 | |||
| 7fdb450446 | |||
| 86285f5a05 | |||
| 8a4b4e459e | |||
| 879446bdb2 | |||
| ef633dc427 | |||
| 86c0cc61ba | |||
| ffe3ed4b9a | |||
| 5c4df786de | |||
| ee8c6621e9 | |||
| f748513df6 | |||
| e1050e21b5 | |||
| e555e442fb | |||
| 600dea6ce5 | |||
| e07203ad46 | |||
| f16735fd6d | |||
| 562a7b79a5 | |||
| cb610230d9 | |||
| 628b2b388d | |||
| faa70f3019 | |||
| cfe410f202 | |||
| c2ac2bfb83 | |||
| c326d5574b | |||
| f1c8c060c0 | |||
| 6944fb1e50 | |||
| 7d21298fab | |||
| 95201fc5cf | |||
| 5e335995e1 | |||
| 70f126fc5a | |||
| 38cdc4d3df | |||
| 4ab1cdd2e9 | |||
| f3e48505df | |||
| 60c75ab3b6 | |||
| 349f040921 | |||
| 74376cd33c | |||
| 2a4d2985c4 | |||
| 907941394b | |||
| 88ea0ade3e | |||
| fcf9fde118 | |||
| 5c38688783 | |||
| e0850fc648 | |||
| 8708a109b3 | |||
| 0fe787bb93 | |||
| 2dffcfeadc | |||
| 4bbc12e3b2 |
@@ -1,7 +1,7 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go:1.22
|
||||
image: mcr.microsoft.com/devcontainers/go:1.23
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
labels: wishlist
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
- run: "go vet ./..."
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
|
||||
args: >
|
||||
--timeout 10m
|
||||
--exclude-dirs=tests
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
with:
|
||||
version: "2024.1.1"
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
Version 2.2.3 (November 10, 2024)
|
||||
---------------------------------
|
||||
|
||||
* fix: unable to change password due to a typo in SQL parameter
|
||||
* fix: show only one player when there are several audio/video enclosures
|
||||
* feat(mediaproxy): pass original filename in `Content-Disposition` header
|
||||
* feat(mediaproxy): implement referer spoofing for restricted media resources
|
||||
* feat(integration): update Shiori integration to use new API endpoints for login/bookmark
|
||||
* build(deps): bump `golang.org/x/text` from `0.19.0` to `0.20.0`
|
||||
* build(deps): bump `golang.org/x/term` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.30.0` to `0.31.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.28.0` to `0.29.0`
|
||||
|
||||
Version 2.2.2 (October 29, 2024)
|
||||
--------------------------------
|
||||
|
||||
* fix(webauthn): add backup eligibility flag workaround to avoid a 401 response
|
||||
* fix: update `Last-Modified` if it changes in a 304 response
|
||||
* feat(webauthn): show help message regarding username and non-discoverable credentials
|
||||
* feat(rss): calculate hash based on item title/content for feeds without GUID and link
|
||||
* feat(locale): update Chinese translations
|
||||
* feat(locale): update Polish translations
|
||||
* feat(integration): add Cubox integration
|
||||
* feat(client): add `custom_js` field to Go API client
|
||||
* feat(api): add endpoint for user integration status
|
||||
* feat: update feed icon during force refresh
|
||||
* feat: take `Retry-After` header into consideration for rate limited feeds
|
||||
* feat: set entry URL to rewritten URL if a rewrite rule is defined
|
||||
* feat: replace `xurls` third-party module with an ad-hoc regexp
|
||||
* feat: add new settings option to allow external fonts
|
||||
* feat: add custom user JavaScript similar to custom CSS
|
||||
* chore: update test case comment
|
||||
* build(deps): bump `golang.org/x/net` from `0.29.0` to `0.30.0`
|
||||
* build(deps): bump `github.com/yuin/goldmark` from `1.7.4` to `1.7.8`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.37` to `2.21.1`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build(deps): bump `github.com/andybalholm/brotli` from `1.1.0` to `1.1.1`
|
||||
|
||||
Version 2.2.1 (September 28, 2024)
|
||||
----------------------------------
|
||||
|
||||
* refactor: split processor package into smaller files
|
||||
* fix(mediaproxy): forward client user-agent to origin to bypass bot protection
|
||||
* fix: use root URL to generate absolute proxy URL
|
||||
* fix: remove progression save on shared entry
|
||||
* fix: add datasource variable and upgrade depecrated panels on the Grafana dashboard
|
||||
* feat(locale): update zh_CN translations
|
||||
* feat(locale): update Ukrainian translations
|
||||
* feat(locale): update Spanish translations
|
||||
* feat(locale): update Dutch translations
|
||||
* feat: use Bilibili API instead of web scraping to get videos watch time
|
||||
* feat: add pagination to shared entries listing
|
||||
* feat: add button to show only starred entries per category
|
||||
* build(deps): bump `golang.org/x/term` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.22.0` to `0.23.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.9.2` to `1.10.0`
|
||||
* build(deps): bump `github.com/prometheus/client_golang` from `1.20.3` to `1.20.4`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.10.2` to `0.11.2`
|
||||
* build: update go.mod to Go 1.23
|
||||
* build: bump devcontainer version to go 1.23
|
||||
|
||||
Version 2.2.0 (August 18, 2024)
|
||||
-------------------------------
|
||||
|
||||
|
||||
@@ -185,6 +185,25 @@ func (c *Client) MarkAllAsRead(userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
body, err := c.request.Get("/v1/integrations/status")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var response struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&response); err != nil {
|
||||
return false, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return response.HasIntegrations, nil
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
|
||||
|
||||
@@ -27,6 +27,7 @@ type User struct {
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
@@ -44,6 +45,7 @@ type User struct {
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
@@ -70,6 +72,7 @@ type UserModificationRequest struct {
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
@@ -86,6 +89,7 @@ type UserModificationRequest struct {
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
|
||||
+1639
-1458
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,36 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.22
|
||||
// +heroku goVersion go1.23
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/PuerkitoBio/goquery v1.10.0
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/andybalholm/brotli v1.1.0
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.20.0
|
||||
github.com/tdewolff/minify/v2 v2.20.37
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/net v0.28.0
|
||||
golang.org/x/oauth2 v0.22.0
|
||||
golang.org/x/term v0.23.0
|
||||
golang.org/x/text v0.17.0
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/tdewolff/minify/v2 v2.21.1
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
golang.org/x/crypto v0.29.0
|
||||
golang.org/x/net v0.31.0
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
golang.org/x/term v0.26.0
|
||||
golang.org/x/text v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
@@ -40,10 +39,10 @@ require (
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.18 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
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=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -14,20 +14,20 @@ github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
@@ -44,8 +44,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI=
|
||||
github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
@@ -54,22 +54,24 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.20.37 h1:Q97cx4STXCh1dlWDlNHZniE8BJ2EBL0+2b0n92BJQhw=
|
||||
github.com/tdewolff/minify/v2 v2.20.37/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
|
||||
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
|
||||
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/minify/v2 v2.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
|
||||
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
|
||||
github.com/tdewolff/parse/v2 v2.7.18 h1:uSqjEMT2lwCj5oifBHDcWU2kN1pbLrRENgFWDJa57eI=
|
||||
github.com/tdewolff/parse/v2 v2.7.18/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -77,10 +79,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
||||
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -91,21 +93,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
||||
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
||||
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
|
||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -115,5 +117,3 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||
|
||||
@@ -74,6 +74,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
|
||||
@@ -592,6 +592,88 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedUser.ExternalFontHosts != "fonts.example.org" {
|
||||
t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
|
||||
t.Fatal(`Updating the user with an invalid external font host should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedUser.CustomJS != "alert('Hello, World!');" {
|
||||
t.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2401,6 +2483,32 @@ func TestSaveEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIntegrationsStatusEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
hasIntegrations, err := regularUserClient.IntegrationsStatus()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch integrations status: %v", err)
|
||||
}
|
||||
|
||||
if hasIntegrations {
|
||||
t.Fatalf("New user should not have integrations configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchContentEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !h.store.HasIcon(feedID) {
|
||||
if !h.store.HasFeedIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,6 +130,25 @@ func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response := struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}{
|
||||
HasIntegrations: hasIntegrations,
|
||||
}
|
||||
|
||||
json.OK(w, r, response)
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
|
||||
@@ -942,4 +942,22 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Cubox API documentation: https://help.cubox.cc/save/api/
|
||||
|
||||
package cubox // import "miniflux.app/v2/internal/integration/cubox"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
apiLink string
|
||||
}
|
||||
|
||||
func NewClient(apiLink string) *Client {
|
||||
return &Client{apiLink: apiLink}
|
||||
}
|
||||
|
||||
func (c *Client) SaveLink(entryURL string) error {
|
||||
if c.apiLink == "" {
|
||||
return errors.New("cubox: missing API link")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&card{
|
||||
Type: "url",
|
||||
Content: entryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to encode request body: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to send request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return fmt.Errorf("cubox: unable to save link: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/cubox"
|
||||
"miniflux.app/v2/internal/integration/espial"
|
||||
"miniflux.app/v2/internal/integration/instapaper"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
@@ -322,6 +323,25 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.CuboxEnabled {
|
||||
slog.Debug("Sending entry to Cubox",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := cubox.NewClient(userIntegrations.CuboxAPILink)
|
||||
|
||||
if err := client.SaveLink(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Cubox",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.ShioriEnabled {
|
||||
slog.Debug("Sending entry to Shiori",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
|
||||
@@ -31,7 +31,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("shiori: missing base URL, username or password")
|
||||
}
|
||||
|
||||
sessionID, err := c.authenticate()
|
||||
token, err := c.authenticate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("shiori: unable to authenticate: %v", err)
|
||||
}
|
||||
@@ -44,7 +44,11 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
requestBody, err := json.Marshal(&addBookmarkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Excerpt: "",
|
||||
CreateArchive: true,
|
||||
CreateEbook: false,
|
||||
Public: 0,
|
||||
Tags: make([]string, 0),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -58,7 +62,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("X-Session-Id", sessionID)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
|
||||
@@ -75,13 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authenticate() (sessionID string, err error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
|
||||
func (c *Client) authenticate() (token string, err error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
|
||||
}
|
||||
@@ -111,21 +115,31 @@ func (c *Client) authenticate() (sessionID string, err error) {
|
||||
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
return authResponse.SessionID, nil
|
||||
return authResponse.Message.Token, nil
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RememberMe bool `json:"remember_me"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message authResponseMessage `json:"message"`
|
||||
}
|
||||
|
||||
type authResponseMessage struct {
|
||||
SessionID string `json:"session"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type addBookmarkRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
CreateArchive bool `json:"createArchive"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
CreateArchive bool `json:"create_archive"`
|
||||
CreateEbook bool `json:"create_ebook"`
|
||||
Public int `json:"public"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Alle als gelesen markieren",
|
||||
"menu.show_all_entries": "Zeige alle Artikel",
|
||||
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
|
||||
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
|
||||
"menu.refresh_feed": "Aktualisieren",
|
||||
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
|
||||
"menu.edit_feed": "Bearbeiten",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
|
||||
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
|
||||
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Entfernen Sie %d Hauptschlüssel",
|
||||
"%d Hauptschlüssel entfernen"
|
||||
@@ -354,6 +356,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.rules": "Regeln",
|
||||
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
|
||||
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
|
||||
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
|
||||
"form.prefs.label.custom_js": "Benutzerdefiniertes JavaScript",
|
||||
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
|
||||
"form.prefs.label.default_home_page": "Standard-Startseite",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML Datei",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
|
||||
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
|
||||
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
|
||||
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
|
||||
"menu.refresh_feed": "Ανανέωση",
|
||||
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
|
||||
"menu.edit_feed": "Επεξεργασία",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
|
||||
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Αφαιρέστε %d κωδικό πρόσβασης",
|
||||
"Καταργήστε %d κωδικούς πρόσβασης"
|
||||
@@ -358,6 +360,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.category.label.title": "Τίτλος",
|
||||
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.user.label.username": "Χρήστης",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
|
||||
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
|
||||
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
|
||||
"form.prefs.label.custom_js": "Προσαρμοσμένο JavaScript",
|
||||
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
|
||||
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
|
||||
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Αρχείο OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"menu.mark_page_as_read": "Mark this page as read",
|
||||
"menu.mark_all_as_read": "Mark all as read",
|
||||
"menu.show_all_entries": "Show all entries",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.show_only_unread_entries": "Show only unread entries",
|
||||
"menu.refresh_feed": "Refresh",
|
||||
"menu.refresh_all_feeds": "Refresh all feeds in the background",
|
||||
@@ -228,6 +229,7 @@
|
||||
"page.login.oidc_signin": "Sign in with %s",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.integrations.title": "Integrations",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API Endpoint",
|
||||
@@ -390,6 +392,7 @@
|
||||
"form.prefs.label.gesture_nav": "Gesture to navigate between entries",
|
||||
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
|
||||
"form.prefs.label.custom_css": "Custom CSS",
|
||||
"form.prefs.label.custom_js": "Custom JavaScript",
|
||||
"form.prefs.label.entry_order": "Entry sorting column",
|
||||
"form.prefs.label.default_home_page": "Default home page",
|
||||
"form.prefs.label.categories_sorting_order": "Categories sorting",
|
||||
@@ -401,6 +404,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML file",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
@@ -505,6 +511,8 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Loading…",
|
||||
"form.submit.saving": "Saving…",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Saltar al contenido",
|
||||
"confirm.question": "¿Estás seguro?",
|
||||
"confirm.question.refresh": "¿Quieres forzar la actualización?",
|
||||
"confirm.yes": "sí",
|
||||
@@ -9,8 +9,8 @@
|
||||
"action.save": "Guardar",
|
||||
"action.or": "o",
|
||||
"action.cancel": "Cancelar",
|
||||
"action.remove": "Quitar",
|
||||
"action.remove_feed": "Quitar esta fuente",
|
||||
"action.remove": "Eliminar",
|
||||
"action.remove_feed": "Eliminar esta fuente",
|
||||
"action.update": "Actualizar",
|
||||
"action.edit": "Editar",
|
||||
"action.download": "Descargar",
|
||||
@@ -19,8 +19,8 @@
|
||||
"action.home_screen": "Añadir a la pantalla principal",
|
||||
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
|
||||
"tooltip.logged_user": "Registrado como %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "Menú",
|
||||
"menu.home_page": "Página de inicio",
|
||||
"menu.unread": "No leídos",
|
||||
"menu.starred": "Marcadores",
|
||||
"menu.history": "Historial",
|
||||
@@ -41,8 +41,9 @@
|
||||
"menu.mark_all_as_read": "Marcar todos como leídos",
|
||||
"menu.show_all_entries": "Mostrar todos los artículos",
|
||||
"menu.show_only_unread_entries": "Mostrar solo los artículos no leídos",
|
||||
"menu.show_only_starred_entries": "Mostrar solo los artículos marcados con una estrella",
|
||||
"menu.refresh_feed": "Refrescar",
|
||||
"menu.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
|
||||
"menu.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
|
||||
"menu.edit_feed": "Editar",
|
||||
"menu.edit_category": "Editar",
|
||||
"menu.add_feed": "Agregar fuente",
|
||||
@@ -54,8 +55,8 @@
|
||||
"menu.shared_entries": "Artículos compartidos",
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Búsqueda...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Ultimo",
|
||||
"search.submit": "Buscar",
|
||||
"pagination.last": "Último",
|
||||
"pagination.next": "Siguiente",
|
||||
"pagination.first": "Primero",
|
||||
"pagination.previous": "Anterior",
|
||||
@@ -92,22 +93,22 @@
|
||||
"entry.tags.label": "Etiquetas:",
|
||||
"page.shared_entries.title": "Artículos compartidos",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d artículo compartido",
|
||||
"%d artículos compartidos"
|
||||
],
|
||||
"page.unread.title": "No leídos",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d artículo no leído",
|
||||
"%d artículos no leídos"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d artículo en total",
|
||||
"%d artículos en total"
|
||||
],
|
||||
"page.starred.title": "Marcadores",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d artículo marcado",
|
||||
"%d artículos marcados"
|
||||
],
|
||||
"page.categories.title": "Categorías",
|
||||
"page.categories.no_feed": "Sin fuente.",
|
||||
@@ -118,17 +119,17 @@
|
||||
"Hay %d fuentes."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d categoría",
|
||||
"%d categorías"
|
||||
],
|
||||
"page.new_category.title": "Nueva categoría",
|
||||
"page.new_user.title": "Nuevo usuario",
|
||||
"page.edit_category.title": "Editar categoría: %s",
|
||||
"page.edit_user.title": "Editar usuario: %s",
|
||||
"page.feeds.title": "Fuentes",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Categoría: %s",
|
||||
"page.feeds.last_check": "Última verificación:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Próxima verificación:",
|
||||
"page.feeds.read_counter": "Número de artículos leídos",
|
||||
"page.feeds.error_count": [
|
||||
"%d error",
|
||||
@@ -136,15 +137,15 @@
|
||||
],
|
||||
"page.history.title": "Historial",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d artículo leído",
|
||||
"%d artículos leídos"
|
||||
],
|
||||
"page.import.title": "Importar",
|
||||
"page.search.title": "Resultados de la búsqueda",
|
||||
"page.about.title": "Acerca de",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.version": "Versión:",
|
||||
"page.about.build_date": "Fecha de construcción:",
|
||||
"page.about.build_date": "Fecha de compilación:",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.license": "Licencia:",
|
||||
"page.about.global_config_options": "Opciones de configuración global",
|
||||
@@ -189,7 +190,7 @@
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Abrir enlace de comentarios en la pestaña actual",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
|
||||
@@ -212,22 +213,23 @@
|
||||
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
|
||||
"page.settings.link_oidc_account": "Vincular mi cuenta de %s",
|
||||
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.passkeys": "Claves de acceso",
|
||||
"page.settings.webauthn.actions": "Accioness",
|
||||
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
|
||||
"page.settings.webauthn.added_on": "Añadido",
|
||||
"page.settings.webauthn.last_seen_on": "Usado por última vez",
|
||||
"page.settings.webauthn.register": "Registrar clave de acceso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de paso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Eliminar %d clave de paso",
|
||||
"Eliminar %d claves de paso"
|
||||
"Eliminar %d clave de acceso",
|
||||
"Eliminar %d claves de acceso"
|
||||
],
|
||||
"page.login.title": "Iniciar sesión",
|
||||
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
|
||||
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de %s",
|
||||
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
|
||||
"page.integrations.title": "Integraciones",
|
||||
"page.integration.miniflux_api": "API de Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Extremo de API",
|
||||
@@ -255,7 +257,7 @@
|
||||
"page.offline.title": "Modo offline",
|
||||
"page.offline.message": "Estas desconectado",
|
||||
"page.offline.refresh_page": "Intenta actualizar la página",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"page.webauthn_rename.title": "Renombrar clave de acceso",
|
||||
"alert.no_shared_entry": "No hay artículos compartidos.",
|
||||
"alert.no_bookmark": "No hay marcador en este momento.",
|
||||
"alert.no_category": "No hay categoría.",
|
||||
@@ -295,14 +297,14 @@
|
||||
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
|
||||
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
|
||||
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_block_rule_fieldname_invalid": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
|
||||
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Regla de mantenimiento no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
|
||||
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.entries_per_page_invalid": "El número de artículos por página no es válido.",
|
||||
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
|
||||
"error.feed_already_exists": "Este feed ya existe.",
|
||||
@@ -336,28 +338,30 @@
|
||||
"form.feed.label.cookie": "Configurar las cookies",
|
||||
"form.feed.label.scraper_rules": "Reglas de extracción de información",
|
||||
"form.feed.label.rewrite_rules": "Reglas de reescribir",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
|
||||
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
|
||||
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
|
||||
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
|
||||
"form.feed.label.disabled": "No actualice este feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
|
||||
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioridad máxima a Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Prioridad alta a Ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Prioridad baja a Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.fieldset.rules": "Reglas",
|
||||
"form.feed.fieldset.network_settings": "Ajustes de red",
|
||||
"form.feed.fieldset.integration": "Servicios de terceros",
|
||||
"form.category.label.title": "Título",
|
||||
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.user.label.username": "Nombre de usuario",
|
||||
@@ -390,22 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
|
||||
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
|
||||
"form.prefs.label.custom_css": "CSS personalizado",
|
||||
"form.prefs.label.custom_js": "JavaScript personalizado",
|
||||
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
|
||||
"form.prefs.label.default_home_page": "Página de inicio por defecto",
|
||||
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Marcar como leído solo cuando la reproducción de audio/video alcance el 90%% de finalización",
|
||||
"form.prefs.label.mark_read_manually": "Marcar entradas como leídas manualmente",
|
||||
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
|
||||
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
|
||||
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
|
||||
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Archivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.betula_activate": "Guardar artículos en Betula",
|
||||
"form.integration.betula_url": "URL del servidor Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
"form.integration.fever_activate": "Activar API de Fever",
|
||||
"form.integration.fever_username": "Nombre de usuario de Fever",
|
||||
"form.integration.fever_password": "Contraseña de Fever",
|
||||
@@ -432,12 +440,12 @@
|
||||
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
"form.integration.wallabag_password": "Contraseña de Wallabag",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"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": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.notion_activate": "Guardar entradas en Notion",
|
||||
"form.integration.notion_page_id": "ID de página de Notion",
|
||||
"form.integration.notion_token": "Token secreto de Notion",
|
||||
"form.integration.apprise_activate": "Enviar artículos a Apprise",
|
||||
"form.integration.apprise_url": "URL de la API de Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"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",
|
||||
@@ -448,26 +456,26 @@
|
||||
"form.integration.espial_endpoint": "Acceso API de Espial",
|
||||
"form.integration.espial_api_key": "Clave de API de Espial",
|
||||
"form.integration.espial_tags": "Etiquetas de Espial",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
|
||||
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram",
|
||||
"form.integration.telegram_bot_token": "Token de bot",
|
||||
"form.integration.telegram_chat_id": "ID de chat",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
|
||||
"form.integration.telegram_bot_disable_notification": "Deshabilitar notificación",
|
||||
"form.integration.telegram_bot_disable_buttons": "Deshabilitar botones",
|
||||
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_api_key": "Clave API de LinkAce",
|
||||
"form.integration.linkace_tags": "Etiquetas de LinkAce",
|
||||
"form.integration.linkace_is_private": "Marcar enlace como privado",
|
||||
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
|
||||
"form.integration.linkding_activate": "Enviar artículos a Linkding",
|
||||
"form.integration.linkding_endpoint": "Acceso API de Linkding",
|
||||
"form.integration.linkding_api_key": "Clave de API de Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
|
||||
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
|
||||
@@ -477,34 +485,34 @@
|
||||
"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_activate": "Guardar artículos en Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.raindrop_collection_id": "Colección ID",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"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",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Etiquetas de Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.shiori_activate": "Guardar artículos a Shiori",
|
||||
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
|
||||
"form.integration.shiori_username": "Nombre de usuario de Shiori",
|
||||
"form.integration.shiori_password": "Contraseña de Shiori",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
|
||||
"form.integration.shaarli_endpoint": "URL de Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
|
||||
"form.integration.webhook_activate": "Habilitar Webhook",
|
||||
"form.integration.webhook_url": "URL de Webhook",
|
||||
"form.integration.webhook_secret": "Secreto de Webhook",
|
||||
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
|
||||
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
|
||||
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
|
||||
"form.integration.ntfy_topic": "Tema Ntfy",
|
||||
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
|
||||
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
|
||||
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
|
||||
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.submit.loading": "Cargando...",
|
||||
"form.submit.saving": "Guardando...",
|
||||
@@ -536,43 +544,43 @@
|
||||
"hace %d años"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
|
||||
"error.http_response_too_large": "La respuesta HTTP es demasiado grande. Puede aumentar el límite de tamaño de respuesta HTTP en la configuración global (requiere reiniciar el servidor).",
|
||||
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
|
||||
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
|
||||
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.tls_error": "Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.",
|
||||
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
|
||||
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
|
||||
"error.http_client_error": "Error cliente HTTP: %v.",
|
||||
"error.http_not_authorized": "El acceso a este sitio web no está autorizado. Podría ser un nombre de usuario o contraseña incorrectos.",
|
||||
"error.http_too_many_requests": "Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.",
|
||||
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.http_resource_not_found": "No se encuentra el recurso solicitado. Por favor, verifique la URL.",
|
||||
"error.http_internal_server_error": "El sitio web no está disponible en estos momentos debido a un error del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_service_unavailable": "El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_gateway_timeout": "El sitio web no está disponible en este momento debido a un error de tiempo de espera de la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_unexpected_status_code": "El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.database_error": "Error en la base de datos: %v.",
|
||||
"error.category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
|
||||
"error.duplicated_feed": "Este feed ya existe.",
|
||||
"error.unable_to_parse_feed": "No se puede analizar este feed: %v.",
|
||||
"error.feed_not_found": "Este feed no existe o no pertenece a este usuario.",
|
||||
"error.unable_to_detect_rssbridge": "No se puede detectar la fuente usando RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "No se puede detectar el formato del feed: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
|
||||
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
"enclosure_media_controls.seek" : "Buscar:",
|
||||
"enclosure_media_controls.seek.title" : "Buscar %s segundos",
|
||||
"enclosure_media_controls.speed" : "Velocidad:",
|
||||
"enclosure_media_controls.speed.faster" : "Más rápido",
|
||||
"enclosure_media_controls.speed.faster.title" : "Más rápido a %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Despacio",
|
||||
"enclosure_media_controls.speed.slower.title" : "Más despacio a %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Restablecer",
|
||||
"enclosure_media_controls.speed.reset.title" : "Restablecer la velocidad a 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
|
||||
"menu.show_all_entries": "Näytä kaikki artikkelit",
|
||||
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "Päivitä",
|
||||
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
|
||||
"menu.edit_feed": "Muokkaa",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Rekisteröi salasana",
|
||||
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Poista %d salasana",
|
||||
"Poista %d salasanaa"
|
||||
@@ -354,6 +356,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
|
||||
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
|
||||
"form.prefs.label.custom_css": "Mukautettu CSS",
|
||||
"form.prefs.label.custom_js": "Mukautettu JavaScript",
|
||||
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
|
||||
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML-tiedosto",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Tout marquer comme lu",
|
||||
"menu.show_all_entries": "Afficher tous les articles",
|
||||
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus",
|
||||
"menu.show_only_starred_entries": "Afficher uniquement les favoris",
|
||||
"menu.refresh_feed": "Actualiser",
|
||||
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
|
||||
"menu.edit_feed": "Modifier",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
|
||||
"page.settings.webauthn.register": "Enregister une nouvelle clé d’accès",
|
||||
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
|
||||
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Supprimer %d clé d’accès",
|
||||
"Supprimer %d clés d’accès"
|
||||
@@ -354,6 +356,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
|
||||
"form.feed.label.ntfy_low_priority": "Priorité basse de notification",
|
||||
"form.feed.label.ntfy_min_priority": "Priorité minimale de notification",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "Général",
|
||||
"form.feed.fieldset.rules": "Règles",
|
||||
"form.feed.fieldset.network_settings": "Paramètres réseau",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
|
||||
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
|
||||
"form.prefs.label.custom_css": "Feuille de style personnalisée",
|
||||
"form.prefs.label.custom_js": "Code JavaScript personnalisé",
|
||||
"form.prefs.label.entry_order": "Colonne de tri des entrées",
|
||||
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
|
||||
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
|
||||
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
|
||||
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
|
||||
"form.prefs.label.external_font_hosts": "Polices externes autorisées",
|
||||
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
|
||||
"error.settings_invalid_domain_list": "Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.",
|
||||
"form.import.label.file": "Fichier OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Sauvegarder les entrées vers Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
|
||||
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "ताज़ा करें",
|
||||
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
|
||||
"menu.edit_feed": "फ़ीड संपाद करे",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "रजिस्टर पासकी",
|
||||
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"%d पासकुंजी निकालें",
|
||||
"%d पासकी हटाएं"
|
||||
@@ -354,6 +356,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
|
||||
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
|
||||
"form.prefs.label.custom_css": "कस्टम सीएसएस",
|
||||
"form.prefs.label.custom_js": "कस्टम जेएस",
|
||||
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
|
||||
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
|
||||
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "ओपीएमएल फ़ाइल",
|
||||
"form.import.label.url": "यूआरएल",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
|
||||
"menu.show_all_entries": "Tampilkan semua entri",
|
||||
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "Muat ulang",
|
||||
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
|
||||
"menu.edit_feed": "Sunting",
|
||||
@@ -210,6 +211,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Register passkey",
|
||||
"page.settings.webauthn.register.error": "Unable to register passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Remove %d passkey"
|
||||
],
|
||||
@@ -344,6 +346,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -380,6 +384,7 @@
|
||||
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
|
||||
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
|
||||
"form.prefs.label.custom_css": "Modifikasi CSS",
|
||||
"form.prefs.label.custom_js": "Modifikasi JavaScript",
|
||||
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
|
||||
"form.prefs.label.default_home_page": "Beranda Baku",
|
||||
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
|
||||
@@ -391,6 +396,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Berkas OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
|
||||
"menu.show_all_entries": "Mostra tutte le voci",
|
||||
"menu.show_only_unread_entries": "Mostra solo voci non lette",
|
||||
"menu.show_only_starred_entries": "Mostra solo voci preferiti",
|
||||
"menu.refresh_feed": "Aggiorna",
|
||||
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
|
||||
"menu.edit_feed": "Modifica",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Registra la chiave di accesso",
|
||||
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Rimuovi %d passkey",
|
||||
"Rimuovi %d passkey"
|
||||
@@ -354,6 +356,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
|
||||
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
|
||||
"form.prefs.label.custom_css": "CSS personalizzati",
|
||||
"form.prefs.label.custom_js": "JavaScript personalizzati",
|
||||
"form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
|
||||
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
|
||||
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "File OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "すべて既読にする",
|
||||
"menu.show_all_entries": "すべての記事を表示",
|
||||
"menu.show_only_unread_entries": "未読の記事だけを表示",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
|
||||
"menu.edit_feed": "編集",
|
||||
@@ -210,6 +211,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "パスキーを登録する",
|
||||
"page.settings.webauthn.register.error": "パスキーを登録できません",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"%d 個のパスキーを削除"
|
||||
],
|
||||
@@ -344,6 +346,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -380,6 +384,7 @@
|
||||
"form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
|
||||
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
|
||||
"form.prefs.label.custom_css": "カスタム CSS",
|
||||
"form.prefs.label.custom_js": "カスタム JavaScript",
|
||||
"form.prefs.label.entry_order": "記事の表示順の基準",
|
||||
"form.prefs.label.default_home_page": "デフォルトのトップページ",
|
||||
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
|
||||
@@ -391,6 +396,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML ファイル",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Ga naar inhoud",
|
||||
"confirm.question": "Weet je het zeker?",
|
||||
"confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
|
||||
"confirm.question.refresh": "Wil je vernieuwen forceren?",
|
||||
"confirm.yes": "ja",
|
||||
"confirm.no": "nee",
|
||||
"confirm.loading": "Bezig...",
|
||||
"action.subscribe": "Abboneren",
|
||||
"action.subscribe": "Abonneren",
|
||||
"action.save": "Opslaan",
|
||||
"action.or": "of",
|
||||
"action.cancel": "annuleren",
|
||||
"action.remove": "Verwijderen",
|
||||
"action.remove_feed": "Verwijder deze feed",
|
||||
"action.update": "Updaten",
|
||||
"action.update": "Bijwerken",
|
||||
"action.edit": "Bewerken",
|
||||
"action.download": "Download",
|
||||
"action.import": "Importeren",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
|
||||
"tooltip.logged_user": "Ingelogd als %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Startpagina",
|
||||
"menu.unread": "Ongelezen",
|
||||
"menu.starred": "Favorieten",
|
||||
"menu.history": "Geschiedenis",
|
||||
@@ -31,16 +31,17 @@
|
||||
"menu.preferences": "Voorkeuren",
|
||||
"menu.integrations": "Integraties",
|
||||
"menu.sessions": "Sessies",
|
||||
"menu.users": "Users",
|
||||
"menu.users": "Gebruikers",
|
||||
"menu.about": "Over",
|
||||
"menu.export": "Exporteren",
|
||||
"menu.import": "Importeren",
|
||||
"menu.search": "Zoeken",
|
||||
"menu.create_category": "Categorie toevoegen",
|
||||
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alle items als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alles als gelezen",
|
||||
"menu.show_all_entries": "Toon alle artikelen",
|
||||
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
|
||||
"menu.show_only_starred_entries": "Toon alleen favorieten",
|
||||
"menu.refresh_feed": "Vernieuwen",
|
||||
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
|
||||
"menu.edit_feed": "Bewerken",
|
||||
@@ -48,39 +49,39 @@
|
||||
"menu.add_feed": "Feed toevoegen",
|
||||
"menu.add_user": "Gebruiker toevoegen",
|
||||
"menu.flush_history": "Verwijder geschiedenis",
|
||||
"menu.feed_entries": "Lidwoord",
|
||||
"menu.feed_entries": "Artikelen",
|
||||
"menu.api_keys": "API-sleutels",
|
||||
"menu.create_api_key": "Maak een nieuwe API-sleutel",
|
||||
"menu.shared_entries": "Gedeelde vermeldingen",
|
||||
"menu.shared_entries": "Gedeelde artikelen",
|
||||
"search.label": "Zoeken",
|
||||
"search.placeholder": "Zoeken...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"search.submit": "Zoeken",
|
||||
"pagination.last": "Laatste",
|
||||
"pagination.next": "Volgende",
|
||||
"pagination.first": "First",
|
||||
"pagination.first": "Eerste",
|
||||
"pagination.previous": "Vorige",
|
||||
"entry.status.unread": "Ongelezen",
|
||||
"entry.status.read": "Gelezen",
|
||||
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
|
||||
"entry.status.toast.read": "Gemarkeerd als gelezen",
|
||||
"entry.status.title": "Verander status van item",
|
||||
"entry.bookmark.toggle.on": "Ster toevoegen",
|
||||
"entry.bookmark.toggle.off": "Ster weghalen",
|
||||
"entry.bookmark.toast.on": "Met ster",
|
||||
"entry.bookmark.toast.off": "Ster verwijderd",
|
||||
"entry.state.saving": "Opslaag...",
|
||||
"entry.status.title": "Verander artikelstatus",
|
||||
"entry.bookmark.toggle.on": "Favoriet",
|
||||
"entry.bookmark.toggle.off": "Favoriet verwijderen",
|
||||
"entry.bookmark.toast.on": "Favoriet toegevoegd",
|
||||
"entry.bookmark.toast.off": "Favoriet verwijderd",
|
||||
"entry.state.saving": "Opslaan...",
|
||||
"entry.state.loading": "Laden...",
|
||||
"entry.save.label": "Opslaan",
|
||||
"entry.save.title": "Artikel opslaan",
|
||||
"entry.save.completed": "Done!",
|
||||
"entry.save.completed": "Klaar!",
|
||||
"entry.save.toast.completed": "Artikel opgeslagen",
|
||||
"entry.scraper.label": "Downloaden",
|
||||
"entry.scraper.title": "Fetch original content",
|
||||
"entry.scraper.title": "Originele inhoud ophalen",
|
||||
"entry.scraper.completed": "Klaar!",
|
||||
"entry.external_link.label": "Externe link",
|
||||
"entry.comments.label": "Comments",
|
||||
"entry.comments.title": "Bekijk de reacties",
|
||||
"entry.share.label": "Deel",
|
||||
"entry.comments.label": "Reacties",
|
||||
"entry.comments.title": "Bekijk reacties",
|
||||
"entry.share.label": "Delen",
|
||||
"entry.share.title": "Deel dit artikel",
|
||||
"entry.unshare.label": "Delen ongedaan maken",
|
||||
"entry.shared_entry.title": "Open de openbare link",
|
||||
@@ -89,117 +90,117 @@
|
||||
"%d minuut leestijd",
|
||||
"%d minuten leestijd"
|
||||
],
|
||||
"entry.tags.label": "Labels:",
|
||||
"page.shared_entries.title": "Gedeelde vermeldingen",
|
||||
"entry.tags.label": "Tags:",
|
||||
"page.shared_entries.title": "Gedeelde artikelen",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d gedeeld artikel",
|
||||
"%d gedeelde artikelen"
|
||||
],
|
||||
"page.unread.title": "Ongelezen",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d ongelezen artikel",
|
||||
"%d ongelezen artikelen"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d artikel totaal",
|
||||
"%d artikelen totaal"
|
||||
],
|
||||
"page.starred.title": "Favorieten",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d favoriet artikel",
|
||||
"%d favoriete artikelen"
|
||||
],
|
||||
"page.categories.title": "Categorieën",
|
||||
"page.categories.no_feed": "Geen feeds.",
|
||||
"page.categories.entries": "Lidwoord",
|
||||
"page.categories.feeds": "Abonnementen",
|
||||
"page.categories.no_feed": "Geen feed.",
|
||||
"page.categories.entries": "Artikelen",
|
||||
"page.categories.feeds": "Feeds",
|
||||
"page.categories.feed_count": [
|
||||
"Er is %d feed.",
|
||||
"Er zijn %d feeds."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d categorie",
|
||||
"%d categorieën"
|
||||
],
|
||||
"page.new_category.title": "Nieuwe categorie",
|
||||
"page.new_user.title": "Nieuwe gebruiker",
|
||||
"page.edit_category.title": "Bewerken van categorie: %s",
|
||||
"page.edit_category.title": "Bewerk categorie: %s",
|
||||
"page.edit_user.title": "Bewerk gebruiker: %s",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.feeds.last_check": "Laatste update:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Aantal gelezen vermeldingen",
|
||||
"page.category_label": "Categorie: %s",
|
||||
"page.feeds.last_check": "Laatste controle:",
|
||||
"page.feeds.next_check": "Volgende controle:",
|
||||
"page.feeds.read_counter": "Aantal gelezen artikelen",
|
||||
"page.feeds.error_count": [
|
||||
"%d error",
|
||||
"%d errors"
|
||||
"%d fout",
|
||||
"%d fouten"
|
||||
],
|
||||
"page.history.title": "Geschiedenis",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d gelezen artikel",
|
||||
"%d gelezen artikelen"
|
||||
],
|
||||
"page.import.title": "Importeren",
|
||||
"page.login.title": "Inloggen",
|
||||
"page.search.title": "Zoekresultaten",
|
||||
"page.about.title": "Over",
|
||||
"page.about.credits": "Copyrights",
|
||||
"page.about.credits": "Credits",
|
||||
"page.about.version": "Versie:",
|
||||
"page.about.build_date": "Datum build:",
|
||||
"page.about.build_date": "Compilatiedatum:",
|
||||
"page.about.author": "Auteur:",
|
||||
"page.about.license": "Licentie:",
|
||||
"page.about.global_config_options": "globale configuratie-opties",
|
||||
"page.about.global_config_options": "Globale Configuratie Opties",
|
||||
"page.about.postgres_version": "Postgres versie:",
|
||||
"page.about.go_version": "Go versie:",
|
||||
"page.add_feed.title": "Nieuwe feed",
|
||||
"page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
|
||||
"page.add_feed.no_category": "Er is geen categorie. Je moet minstens één categorie hebben.",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.submit": "Feed zoeken",
|
||||
"page.add_feed.legend.advanced_options": "Geavanceerde mogelijkheden",
|
||||
"page.add_feed.legend.advanced_options": "Geavanceerde opties",
|
||||
"page.add_feed.choose_feed": "Feed kiezen",
|
||||
"page.edit_feed.title": "Bewerken van feed: %s",
|
||||
"page.edit_feed.last_check": "Laatste update:",
|
||||
"page.edit_feed.last_modified_header": "LastModified-header:",
|
||||
"page.edit_feed.etag_header": "ETAG-header:",
|
||||
"page.edit_feed.title": "Bewerk feed: %s",
|
||||
"page.edit_feed.last_check": "Laatste controle:",
|
||||
"page.edit_feed.last_modified_header": "LastModified header:",
|
||||
"page.edit_feed.etag_header": "ETAG header:",
|
||||
"page.edit_feed.no_header": "Geen",
|
||||
"page.edit_feed.last_parsing_error": "Laatste parse error",
|
||||
"page.edit_feed.last_parsing_error": "Laatste analysefout",
|
||||
"page.entry.attachments": "Bijlagen",
|
||||
"page.keyboard_shortcuts.title": "Sneltoetsen",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Naviguatie tussen menu's",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigatie tussen items",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Naviguatie tussen pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Actions",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigeren door artikelen",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Acties",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
|
||||
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Laat sneltoetsen zien",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Vorige item",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Volgende item",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Sneltoetsen tonen",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Vorig artikel",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Volgend artikel",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
|
||||
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste artikel",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste artikel",
|
||||
"page.keyboard_shortcuts.open_item": "Open geselecteerd artikel",
|
||||
"page.keyboard_shortcuts.open_original": "Open originele link",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
|
||||
"page.keyboard_shortcuts.open_comments": "Open opmerkingen link",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Open de reactiekoppeling op het huidige tabblad",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Open originele link in huidig tabblad",
|
||||
"page.keyboard_shortcuts.open_comments": "Open reacties",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Open reacties in huidig tabblad",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"page.keyboard_shortcuts.download_content": "Download originele content",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ster toevoegen/weghalen",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Markeer huidige pagina als gelezen",
|
||||
"page.keyboard_shortcuts.download_content": "Download originele inhoud",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.save_article": "Artikel opslaan",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
|
||||
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
|
||||
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.close_modal": "Sluit dialoogscherm",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
|
||||
"page.keyboard_shortcuts.close_modal": "Dialoogvenster sluiten",
|
||||
"page.users.title": "Gebruikers",
|
||||
"page.users.username": "Gebruikersnaam",
|
||||
"page.users.never_logged": "Nooit",
|
||||
@@ -207,27 +208,28 @@
|
||||
"page.users.admin.no": "Nee",
|
||||
"page.users.actions": "Acties",
|
||||
"page.users.last_login": "Laatste login",
|
||||
"page.users.is_admin": "Administrator",
|
||||
"page.users.is_admin": "Beheerder",
|
||||
"page.settings.title": "Instellingen",
|
||||
"page.settings.link_google_account": "Koppel mijn Google-account",
|
||||
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
|
||||
"page.settings.link_oidc_account": "Koppel mijn %s-account",
|
||||
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s-account",
|
||||
"page.settings.link_oidc_account": "Koppel mijn %s account",
|
||||
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s account",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Wachtwoord registreren",
|
||||
"page.settings.webauthn.register.error": "Kan wachtwoord niet registreren",
|
||||
"page.settings.webauthn.actions": "Acties",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Naam",
|
||||
"page.settings.webauthn.added_on": "Toegevoegd op",
|
||||
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
|
||||
"page.settings.webauthn.register": "Passkey registreren",
|
||||
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Verwijder %d wachtwoord",
|
||||
"Verwijder %d wachtwoordsleutels"
|
||||
"Verwijder %d passkey",
|
||||
"Verwijder %d passkeys"
|
||||
],
|
||||
"page.login.oidc_signin": "Inloggen via %s",
|
||||
"page.login.webauthn_login": "Inloggen met wachtwoord",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord",
|
||||
"page.login.google_signin": "Inloggen via Google",
|
||||
"page.login.oidc_signin": "Inloggen met %s",
|
||||
"page.login.webauthn_login": "Inloggen met passkey",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
|
||||
"page.login.google_signin": "Inloggen met Google",
|
||||
"page.integrations.title": "Integraties",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API-URL",
|
||||
@@ -237,7 +239,7 @@
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
|
||||
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.",
|
||||
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
|
||||
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abonneren op een website.",
|
||||
"page.sessions.title": "Sessies",
|
||||
"page.sessions.table.date": "Datum",
|
||||
"page.sessions.table.ip": "IP-adres",
|
||||
@@ -245,8 +247,8 @@
|
||||
"page.sessions.table.actions": "Acties",
|
||||
"page.sessions.table.current_session": "Huidige sessie",
|
||||
"page.api_keys.title": "API-sleutels",
|
||||
"page.api_keys.table.description": "Beschrijving",
|
||||
"page.api_keys.table.token": "Blijk",
|
||||
"page.api_keys.table.description": "Omschrijving",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.table.last_used_at": "Laatst gebruikt",
|
||||
"page.api_keys.table.created_at": "Aanmaakdatum",
|
||||
"page.api_keys.table.actions": "Acties",
|
||||
@@ -255,155 +257,161 @@
|
||||
"page.offline.title": "Offline modus",
|
||||
"page.offline.message": "Je bent offline",
|
||||
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"alert.no_shared_entry": "Er is geen gedeelde toegang.",
|
||||
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
|
||||
"page.webauthn_rename.title": "Hernoem Passkey",
|
||||
"alert.no_shared_entry": "Er is geen gedeeld artikel.",
|
||||
"alert.no_bookmark": "Er zijn geen favorieten.",
|
||||
"alert.no_category": "Er zijn geen categorieën.",
|
||||
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
|
||||
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
|
||||
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
|
||||
"alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.",
|
||||
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
|
||||
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
|
||||
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
|
||||
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
|
||||
"alert.no_feed_in_category": "Er is geen feed voor deze categorie.",
|
||||
"alert.no_history": "Geschiedenis is op dit moment leeg.",
|
||||
"alert.feed_error": "Er is een probleem met deze feed",
|
||||
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
|
||||
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
|
||||
"alert.no_user": "Je bent de enige gebruiker.",
|
||||
"alert.account_unlinked": "Uw externe account is nu gedissocieerd!",
|
||||
"alert.account_linked": "Uw externe account is nu gekoppeld!",
|
||||
"alert.pocket_linked": "Uw Pocket-account is nu gekoppeld!",
|
||||
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
|
||||
"alert.account_linked": "Jouw externe account is nu gekoppeld!",
|
||||
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!",
|
||||
"alert.prefs_saved": "Instellingen opgeslagen!",
|
||||
"error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
|
||||
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
|
||||
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
|
||||
"error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
|
||||
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
|
||||
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
|
||||
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
|
||||
"error.category_already_exists": "Deze categorie bestaat al.",
|
||||
"error.unable_to_create_category": "Kan deze categorie niet maken.",
|
||||
"error.unable_to_update_category": "Kon categorie niet updaten.",
|
||||
"error.unable_to_create_category": "Kan deze categorie niet aanmaken.",
|
||||
"error.unable_to_update_category": "Kan categorie niet bijwerken.",
|
||||
"error.user_already_exists": "Deze gebruiker bestaat al.",
|
||||
"error.unable_to_create_user": "Kan deze gebruiker niet maken.",
|
||||
"error.unable_to_update_user": "Kan deze gebruiker niet updaten.",
|
||||
"error.unable_to_create_user": "Kan deze gebruiker niet aanmaken.",
|
||||
"error.unable_to_update_user": "Kan deze gebruiker niet bijwerken.",
|
||||
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
|
||||
"error.subscription_not_found": "Kon geen feeds vinden.",
|
||||
"error.subscription_not_found": "Kan geen feeds vinden.",
|
||||
"error.empty_file": "Dit bestand is leeg.",
|
||||
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
|
||||
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
|
||||
"error.title_required": "Naam van categorie is verplicht.",
|
||||
"error.title_required": "De titel is verplicht.",
|
||||
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
|
||||
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
|
||||
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.",
|
||||
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
|
||||
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
|
||||
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Ongeldige bewaarregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.entries_per_page_invalid": "Het aantal artikelen per pagina is niet geldig.",
|
||||
"error.feed_mandatory_fields": "De velden URL en categorie zijn verplicht.",
|
||||
"error.feed_already_exists": "Deze feed bestaat al.",
|
||||
"error.invalid_feed_url": "Ongeldige feed-URL.",
|
||||
"error.invalid_site_url": "Ongeldige site-URL.",
|
||||
"error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.",
|
||||
"error.site_url_not_empty": "De site-URL mag niet leeg zijn.",
|
||||
"error.feed_title_not_empty": "De feedtitel mag niet leeg zijn.",
|
||||
"error.invalid_feed_url": "Ongeldige feed URL.",
|
||||
"error.invalid_site_url": "Ongeldige site URL.",
|
||||
"error.feed_url_not_empty": "De feed URL mag niet leeg zijn.",
|
||||
"error.site_url_not_empty": "De site URL mag niet leeg zijn.",
|
||||
"error.feed_title_not_empty": "De feed titel mag niet leeg zijn.",
|
||||
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
|
||||
"error.feed_invalid_blocklist_rule": "De regel voor de blokkeerlijst is ongeldig.",
|
||||
"error.feed_invalid_keeplist_rule": "De regel voor het bewaren van een lijst is ongeldig.",
|
||||
"error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.",
|
||||
"error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.",
|
||||
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
|
||||
"error.api_key_already_exists": "This API Key already exists.",
|
||||
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
|
||||
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
|
||||
"error.unable_to_create_api_key": "Kan deze API-sleutel niet aanmaken.",
|
||||
"error.invalid_theme": "Ongeldig thema.",
|
||||
"error.invalid_language": "Ongeldige taal.",
|
||||
"error.invalid_timezone": "Ongeldige tijdzone.",
|
||||
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
|
||||
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
|
||||
"error.invalid_default_home_page": "Ongeldige standaard homepage!",
|
||||
"form.feed.label.title": "Naam",
|
||||
"error.invalid_default_home_page": "Ongeldige startpagina!",
|
||||
"form.feed.label.title": "Titel",
|
||||
"form.feed.label.site_url": "Website URL",
|
||||
"form.feed.label.feed_url": "Feed URL",
|
||||
"form.feed.label.description": "Beschrijving",
|
||||
"form.feed.label.description": "Omschrijving",
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.crawler": "Download originele content",
|
||||
"form.feed.label.feed_username": "Feed-gebruikersnaam",
|
||||
"form.feed.label.crawler": "Download originele inhoud",
|
||||
"form.feed.label.feed_username": "Feed gebruikersnaam",
|
||||
"form.feed.label.feed_password": "Feed wachtwoord",
|
||||
"form.feed.label.user_agent": "Standaard User Agent overschrijven",
|
||||
"form.feed.label.user_agent": "Standaard User-agent overschrijven",
|
||||
"form.feed.label.cookie": "Cookies instellen",
|
||||
"form.feed.label.scraper_rules": "Scraper regels",
|
||||
"form.feed.label.rewrite_rules": "Rewrite regels",
|
||||
"form.feed.label.blocklist_rules": "Blokkeer regels",
|
||||
"form.feed.label.keeplist_rules": "toestemmingsregels",
|
||||
"form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.scraper_rules": "Extractieregels",
|
||||
"form.feed.label.rewrite_rules": "Herschrijfregels",
|
||||
"form.feed.label.blocklist_rules": "Blokkeerregels",
|
||||
"form.feed.label.keeplist_rules": "Bewaarregels",
|
||||
"form.feed.label.urlrewrite_rules": "Herschrijfregels voor URL's",
|
||||
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
|
||||
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
|
||||
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
|
||||
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
|
||||
"form.feed.label.disabled": "Vernieuw deze feed niet",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.category.label.title": "Naam",
|
||||
"form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
|
||||
"form.feed.label.disabled": "Deze feed niet vernieuwen",
|
||||
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
|
||||
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
|
||||
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy hoge prioriteit",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "Algemeen",
|
||||
"form.feed.fieldset.rules": "Regels",
|
||||
"form.feed.fieldset.network_settings": "Netwerk Instellingen",
|
||||
"form.feed.fieldset.integration": "Diensten van derden",
|
||||
"form.category.label.title": "Titel",
|
||||
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.user.label.username": "Gebruikersnaam",
|
||||
"form.user.label.password": "Wachtwoord",
|
||||
"form.user.label.confirmation": "Bevestig wachtwoord",
|
||||
"form.user.label.admin": "Administrator",
|
||||
"form.user.label.admin": "Beheerder",
|
||||
"form.prefs.label.language": "Taal",
|
||||
"form.prefs.label.timezone": "Tijdzone",
|
||||
"form.prefs.label.theme": "Skin",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van items",
|
||||
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
|
||||
"form.prefs.label.theme": "Thema",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
|
||||
"form.prefs.label.entries_per_page": "Artikelen per pagina",
|
||||
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
|
||||
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
|
||||
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
|
||||
"form.prefs.select.older_first": "Oudere items eerst",
|
||||
"form.prefs.select.recent_first": "Recente items eerst",
|
||||
"form.prefs.select.older_first": "Oudere artikelen eerst",
|
||||
"form.prefs.select.recent_first": "Recente artikelen eerst",
|
||||
"form.prefs.select.fullscreen": "Volledig scherm",
|
||||
"form.prefs.select.standalone": "Standalone",
|
||||
"form.prefs.select.minimal_ui": "Minimaal",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.publish_time": "Tijd van binnenkomst",
|
||||
"form.prefs.select.created_time": "Tijdstip van binnenkomst",
|
||||
"form.prefs.select.publish_time": "Tijdstip van publiceren artikel",
|
||||
"form.prefs.select.created_time": "Tijdstip van aanmaken artikel",
|
||||
"form.prefs.select.alphabetical": "Alfabetisch",
|
||||
"form.prefs.select.unread_count": "Ongelezen tellen",
|
||||
"form.prefs.select.unread_count": "Aantal ongelezen artikelen",
|
||||
"form.prefs.select.none": "Geen",
|
||||
"form.prefs.select.tap": "Dubbeltik",
|
||||
"form.prefs.select.swipe": "Vegen",
|
||||
"form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
|
||||
"form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
|
||||
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
|
||||
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
|
||||
"form.prefs.label.custom_css": "Aangepaste CSS",
|
||||
"form.prefs.label.entry_order": "Ingang Sorteerkolom",
|
||||
"form.prefs.label.default_home_page": "Standaard startpagina",
|
||||
"form.prefs.label.categories_sorting_order": "Categorieën sorteren",
|
||||
"form.prefs.label.mark_read_on_view": "Items automatisch markeren als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.custom_js": "Aangepaste JavaScript",
|
||||
"form.prefs.label.entry_order": "Artikelen sorteren",
|
||||
"form.prefs.label.default_home_page": "Startpagina",
|
||||
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
|
||||
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt",
|
||||
"form.prefs.label.mark_read_manually": "Markeer artikelen handmatig als gelezen",
|
||||
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
|
||||
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML-bestand",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_activate": "Artikelen opslaan in Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Activeer Fever API",
|
||||
@@ -413,102 +421,102 @@
|
||||
"form.integration.googlereader_activate": "Activeer Google Reader API",
|
||||
"form.integration.googlereader_username": "Google Reader gebruikersnaam",
|
||||
"form.integration.googlereader_password": "Google Reader wachtwoord",
|
||||
"form.integration.googlereader_endpoint": "Google Reader URL:",
|
||||
"form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API-endpoint:",
|
||||
"form.integration.pinboard_activate": "Artikelen opslaan in Pinboard",
|
||||
"form.integration.pinboard_token": "Pinboard API token",
|
||||
"form.integration.pinboard_tags": "Pinboard tags",
|
||||
"form.integration.pinboard_bookmark": "Markeer bookmark als gelezen",
|
||||
"form.integration.instapaper_activate": "Artikelen opstaan naar Instapaper",
|
||||
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
|
||||
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
|
||||
"form.integration.instapaper_password": "Instapaper wachtwoord",
|
||||
"form.integration.pocket_activate": "Bewaar artikelen in Pocket",
|
||||
"form.integration.pocket_activate": "Artikelen opslaan in Pocket",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pocket_access_token": "Pocket Access Token",
|
||||
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
|
||||
"form.integration.wallabag_activate": "Opslaan naar Wallabag",
|
||||
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
|
||||
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
|
||||
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
|
||||
"form.integration.wallabag_password": "Wallabag wachtwoord",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_activate": "Artikelen opslaan in Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_activate": "Artikelen opslaan in Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
|
||||
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.integration.nunux_keeper_activate": "Artikelen opslaan in Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
|
||||
"form.integration.omnivore_activate": "Opslaan naar Omnivore",
|
||||
"form.integration.omnivore_activate": "Artikelen opslaan in Omnivore",
|
||||
"form.integration.omnivore_url": "Omnivore URL",
|
||||
"form.integration.omnivore_api_key": "Omnivore API-sleutel",
|
||||
"form.integration.espial_activate": "Opslaan naar Espial",
|
||||
"form.integration.espial_activate": "Artikelen opslaan in Espial",
|
||||
"form.integration.espial_endpoint": "Espial URL",
|
||||
"form.integration.espial_api_key": "Espial API-sleutel",
|
||||
"form.integration.espial_tags": "Espial tags",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_activate": "Artikelen opslaan in Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.telegram_bot_activate": "Push nieuwe artikelen naar Telegram-chat",
|
||||
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen",
|
||||
"form.integration.telegram_bot_activate": "Stuur nieuwe artikelen naar Telegram",
|
||||
"form.integration.telegram_bot_token": "Bot token",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Webpaginavoorbeeld uitschakelen",
|
||||
"form.integration.telegram_bot_disable_notification": "Notificatie uitschakelen",
|
||||
"form.integration.telegram_bot_disable_buttons": "Knoppen uitschakelen",
|
||||
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkding_activate": "Opslaan naar Linkding",
|
||||
"form.integration.linkace_api_key": "LinkAce API-sleutel",
|
||||
"form.integration.linkace_tags": "LinkAce tags",
|
||||
"form.integration.linkace_is_private": "Koppeling als privé markeren",
|
||||
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
|
||||
"form.integration.linkding_activate": "Artikelen opslaan in Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_api_key": "Linkding API-sleutel",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
|
||||
"form.integration.linkwarden_activate": "Opslaan naar Linkwarden",
|
||||
"form.integration.linkding_tags": "Linkding tags",
|
||||
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden URL",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
|
||||
"form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
|
||||
"form.integration.matrix_bot_user": "Matrix gebruikersnaam",
|
||||
"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.raindrop_activate": "Artikelen opslaan in Raindrop",
|
||||
"form.integration.raindrop_token": "Raindrop Token",
|
||||
"form.integration.raindrop_collection_id": "Collectie ID",
|
||||
"form.integration.raindrop_tags": "Tags (commagescheiden)",
|
||||
"form.integration.readeck_activate": "Artikelen opslaan in Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.shiori_activate": "Opslaan naar Shiori",
|
||||
"form.integration.shiori_activate": "Artikelen opslaan in Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.shiori_username": "Shiori gebruikersnaam",
|
||||
"form.integration.shiori_password": "Shiori wachtwoord",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_activate": "Webhook activeren",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API-sleutellabel",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
|
||||
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
|
||||
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
|
||||
"form.api_key.label.description": "API-sleutel omschrijving",
|
||||
"form.submit.loading": "Laden...",
|
||||
"form.submit.saving": "Opslaag...",
|
||||
"time_elapsed.not_yet": "in de toekomst",
|
||||
"form.submit.saving": "Opslaan...",
|
||||
"time_elapsed.not_yet": "nog niet",
|
||||
"time_elapsed.yesterday": "gisteren",
|
||||
"time_elapsed.now": "minder dan een minuut geleden",
|
||||
"time_elapsed.minutes": [
|
||||
@@ -536,43 +544,43 @@
|
||||
"%d jaar geleden"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
|
||||
"error.http_response_too_large": "De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)",
|
||||
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
|
||||
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
|
||||
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
|
||||
"error.tls_error": "TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.",
|
||||
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
|
||||
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
|
||||
"error.http_client_error": "HTTP-client-fout: %v.",
|
||||
"error.http_not_authorized": "Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.",
|
||||
"error.http_too_many_requests": "Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.",
|
||||
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
|
||||
"error.http_resource_not_found": "De gevraagde bron is niet gevonden. Controleer de URL.",
|
||||
"error.http_internal_server_error": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_service_unavailable": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_gateway_timeout": "De website is momenteel niet beschikbaar vanwege een timeout bij de gateway. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_unexpected_status_code": "De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.database_error": "Database fout: %v.",
|
||||
"error.category_not_found": "Deze categorie bestaat niet of hoort niet bij deze gebruiker.",
|
||||
"error.duplicated_feed": "Deze feed bestaat al.",
|
||||
"error.unable_to_parse_feed": "Kan deze feed niet verwerken: %v.",
|
||||
"error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.",
|
||||
"error.unable_to_detect_rssbridge": "Kan feed niet detecteren met RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
|
||||
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.seek" : "Vooruit/terug:",
|
||||
"enclosure_media_controls.seek.title" : " Vooruit/terug met %s seconden",
|
||||
"enclosure_media_controls.speed" : "Snelheid:",
|
||||
"enclosure_media_controls.speed.faster" : "Versnel",
|
||||
"enclosure_media_controls.speed.faster.title" : "Versnel met %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Vertraag",
|
||||
"enclosure_media_controls.speed.slower.title" : "Vertraag met %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset snelheid naar 1x"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"confirm.question": "Czy jesteś pewny?",
|
||||
"confirm.question.refresh": "Czy chcesz wymusić odświeżenie?",
|
||||
"skip_to_content": "Przejdź do treści",
|
||||
"confirm.question": "Czy na pewno?",
|
||||
"confirm.question.refresh": "Czy na pewno chcesz wymusić odświeżenie?",
|
||||
"confirm.yes": "tak",
|
||||
"confirm.no": "nie",
|
||||
"confirm.loading": "W toku...",
|
||||
"confirm.loading": "W toku…",
|
||||
"action.subscribe": "Subskrypcja",
|
||||
"action.save": "Zapisz",
|
||||
"action.or": "lub",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
|
||||
"tooltip.logged_user": "Zalogowany jako %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Strona główna",
|
||||
"menu.unread": "Nieprzeczytane",
|
||||
"menu.starred": "Ulubione",
|
||||
"menu.history": "Historia",
|
||||
@@ -32,138 +32,139 @@
|
||||
"menu.integrations": "Usługi",
|
||||
"menu.sessions": "Sesje",
|
||||
"menu.users": "Użytkownicy",
|
||||
"menu.about": "O stronie",
|
||||
"menu.about": "O czytniku",
|
||||
"menu.export": "Eksportuj",
|
||||
"menu.import": "Importuj",
|
||||
"menu.search": "Szukaj",
|
||||
"menu.create_category": "Utwórz kategorię",
|
||||
"menu.mark_page_as_read": "Oznacz jako przeczytane",
|
||||
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
|
||||
"menu.show_all_entries": "Pokaż wszystkie artykuły",
|
||||
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane artykuły",
|
||||
"menu.mark_all_as_read": "Oznacz wszystkie jako przeczytane",
|
||||
"menu.show_all_entries": "Pokaż wszystkie wpisy",
|
||||
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane wpisy",
|
||||
"menu.show_only_starred_entries": "Pokaż tylko ulubione wpisy",
|
||||
"menu.refresh_feed": "Odśwież",
|
||||
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
|
||||
"menu.refresh_all_feeds": "Odśwież w tle wszystkie subskrypcje",
|
||||
"menu.edit_feed": "Edytuj",
|
||||
"menu.edit_category": "Edytuj",
|
||||
"menu.add_feed": "Dodaj subskrypcję",
|
||||
"menu.add_feed": "Dodaj kanał",
|
||||
"menu.add_user": "Dodaj użytkownika",
|
||||
"menu.flush_history": "Usuń historię",
|
||||
"menu.feed_entries": "Artykuły",
|
||||
"menu.feed_entries": "Wpisy",
|
||||
"menu.api_keys": "Klucze API",
|
||||
"menu.create_api_key": "Utwórz nowy klucz API",
|
||||
"menu.shared_entries": "Udostępnione wpisy",
|
||||
"search.label": "Szukaj",
|
||||
"search.placeholder": "Szukaj...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Ostatni",
|
||||
"pagination.next": "Następny",
|
||||
"pagination.first": "Pierwszy",
|
||||
"pagination.previous": "Poprzedni",
|
||||
"entry.status.unread": "Nieprzeczytane",
|
||||
"entry.status.read": "Przeczytane",
|
||||
"entry.status.toast.unread": "Oznaczone jako nieprzeczytane",
|
||||
"entry.status.toast.read": "Oznaczone jako przeczytane",
|
||||
"entry.status.title": "Zmień status artykułu",
|
||||
"entry.bookmark.toggle.on": "Oznacz gwiazdką",
|
||||
"entry.bookmark.toggle.off": "Usuń gwiazdkę",
|
||||
"entry.bookmark.toast.on": "Oznaczone gwiazdką",
|
||||
"entry.bookmark.toast.off": "Bez gwiazdek",
|
||||
"entry.state.saving": "Zapisywanie...",
|
||||
"entry.state.loading": "Ładowanie...",
|
||||
"search.placeholder": "Szukaj…",
|
||||
"search.submit": "Szukaj",
|
||||
"pagination.last": "Ostatnia",
|
||||
"pagination.next": "Następna",
|
||||
"pagination.first": "Pierwsza",
|
||||
"pagination.previous": "Poprzednia",
|
||||
"entry.status.unread": "Nieprzeczytany",
|
||||
"entry.status.read": "Przeczytany",
|
||||
"entry.status.toast.unread": "Oznaczono jako nieprzeczytany",
|
||||
"entry.status.toast.read": "Oznaczono jako przeczytany",
|
||||
"entry.status.title": "Zmień status wpisu",
|
||||
"entry.bookmark.toggle.on": "Dodaj do ulubionych",
|
||||
"entry.bookmark.toggle.off": "Usuń z ulubionych",
|
||||
"entry.bookmark.toast.on": "Dodano do ulubionych",
|
||||
"entry.bookmark.toast.off": "Usunięto z ulubionych",
|
||||
"entry.state.saving": "Zapisywanie…",
|
||||
"entry.state.loading": "Ładowanie…",
|
||||
"entry.save.label": "Zapisz",
|
||||
"entry.save.title": "Zapisz ten artykuł",
|
||||
"entry.save.title": "Zapisz ten wpis",
|
||||
"entry.save.completed": "Gotowe!",
|
||||
"entry.save.toast.completed": "Artykuł zapisany",
|
||||
"entry.scraper.label": "Ściągnij",
|
||||
"entry.save.toast.completed": "Zapisano wpis",
|
||||
"entry.scraper.label": "Pobierz treść",
|
||||
"entry.scraper.title": "Pobierz oryginalną treść",
|
||||
"entry.scraper.completed": "Gotowe!",
|
||||
"entry.external_link.label": "Link zewnętrzny",
|
||||
"entry.external_link.label": "Łącze zewnętrzne",
|
||||
"entry.comments.label": "Komentarze",
|
||||
"entry.comments.title": "Zobacz komentarze",
|
||||
"entry.share.label": "Podzielić się",
|
||||
"entry.share.title": "Podzielić się ten artykuł",
|
||||
"entry.unshare.label": "Unshare",
|
||||
"entry.shared_entry.title": "Otwórz publiczny link",
|
||||
"entry.shared_entry.label": "Udostępnianie",
|
||||
"entry.share.label": "Udostępnij",
|
||||
"entry.share.title": "Udostępnij ten wpis",
|
||||
"entry.unshare.label": "Cofnij udostępnianie",
|
||||
"entry.shared_entry.title": "Otwórz publiczne łącze",
|
||||
"entry.shared_entry.label": "Udostępnij",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d minuta czytania",
|
||||
"%d minuty czytania",
|
||||
"%d minut czytania"
|
||||
],
|
||||
"entry.tags.label": "Tagi:",
|
||||
"entry.tags.label": "Znaczniki:",
|
||||
"page.shared_entries.title": "Udostępnione wpisy",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d udostępniony wpis",
|
||||
"%d udostępnione wpisy",
|
||||
"%d udostępnionych wpisów"
|
||||
],
|
||||
"page.unread.title": "Nieprzeczytane",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d nieprzeczytany wpis",
|
||||
"%d nieprzeczytane wpisy",
|
||||
"%d nieprzeczytanych wpisów"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d wpis łącznie",
|
||||
"%d wpisy łącznie",
|
||||
"%d wpisów łącznie"
|
||||
],
|
||||
"page.starred.title": "Oznaczone gwiazdką",
|
||||
"page.starred.title": "Ulubione",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d ulubiony wpis",
|
||||
"%d ulubione wpisy",
|
||||
"%d ulubionych wpisów"
|
||||
],
|
||||
"page.categories.title": "Kategorie",
|
||||
"page.categories.no_feed": "Brak kanałów.",
|
||||
"page.categories.entries": "Artykuły",
|
||||
"page.categories.feeds": "Subskrypcje",
|
||||
"page.categories.entries": "Wpisy",
|
||||
"page.categories.feeds": "Kanały",
|
||||
"page.categories.feed_count": [
|
||||
"Jest %d kanał.",
|
||||
"Są %d kanały.",
|
||||
"Jest %d kanałów."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d kategoria",
|
||||
"%d kategorie",
|
||||
"%d kategorii"
|
||||
],
|
||||
"page.new_category.title": "Nowa kategoria",
|
||||
"page.new_user.title": "Nowy użytkownik",
|
||||
"page.edit_category.title": "Edycja Kategorii: %s",
|
||||
"page.edit_category.title": "Edytuj kategorię: %s",
|
||||
"page.edit_user.title": "Edytuj użytkownika: %s",
|
||||
"page.feeds.title": "Kanały",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Kategoria: %s",
|
||||
"page.feeds.last_check": "Ostatnia aktualizacja:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Następna aktualizacja:",
|
||||
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
|
||||
"page.feeds.error_count": [
|
||||
"%d błąd",
|
||||
"%d błąd",
|
||||
"%d błędy",
|
||||
"%d błędów"
|
||||
],
|
||||
"page.history.title": "Historia",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d przeczytany wpis",
|
||||
"%d przeczytane wpisy",
|
||||
"%d przeczytanych wpisów"
|
||||
],
|
||||
"page.import.title": "Importuj",
|
||||
"page.search.title": "Wyniki wyszukiwania",
|
||||
"page.about.title": "O",
|
||||
"page.about.title": "O stronie",
|
||||
"page.about.credits": "Prawa autorskie",
|
||||
"page.about.version": "Wersja:",
|
||||
"page.about.build_date": "Data opracowania:",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.license": "Licencja:",
|
||||
"page.about.postgres_version": "Postgres wersja:",
|
||||
"page.about.go_version": "Go wersja:",
|
||||
"page.about.global_config_options": "globalne opcje konfiguracji",
|
||||
"page.about.postgres_version": "Wersja PostgreSQL:",
|
||||
"page.about.go_version": "Wersja Go:",
|
||||
"page.about.global_config_options": "Globalne opcje konfiguracji",
|
||||
"page.add_feed.title": "Nowa subskrypcja",
|
||||
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.label.url": "Adres URL",
|
||||
"page.add_feed.submit": "Znajdź subskrypcję",
|
||||
"page.add_feed.legend.advanced_options": "Zaawansowane opcje",
|
||||
"page.add_feed.legend.advanced_options": "Opcje zaawansowane",
|
||||
"page.add_feed.choose_feed": "Wybierz subskrypcję",
|
||||
"page.edit_feed.title": "Edytuj kanał: %s",
|
||||
"page.edit_feed.last_check": "Ostatnia aktualizacja:",
|
||||
@@ -174,39 +175,39 @@
|
||||
"page.entry.attachments": "Załączniki",
|
||||
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między artykułami",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między elementami",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Działania",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych artykułów",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Przejdź do zakładek",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Przejdź do ulubionych",
|
||||
"page.keyboard_shortcuts.go_to_history": "Przejdź do historii",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Przejdź do kanałów",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Przejdź do kategorii",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Przejdź do ustawień",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Pokaż listę skrótów klawiszowych",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego artykułu",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego punktu artykułu",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego elementu",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego elementu",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do najwyższego elementu",
|
||||
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
|
||||
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.open_comments": "Otwórz link do komentarzy",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz link do komentarzy w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Oznacz jako przeczytane/nieprzeczytane, skup się dalej",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Oznacz jako przeczytane/nieprzeczytane, skup poprzednie",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do górnego elementu",
|
||||
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony element",
|
||||
"page.keyboard_shortcuts.open_original": "Otwórz oryginalne łącze",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalne łącze w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.open_comments": "Otwórz łącze do komentarzy",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz łącze do komentarzy w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Przełącz przeczytane/nieprzeczytane, przejdź dalej",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Przełącz przeczytane/nieprzeczytane, przejdź wstecz",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież w tle wszystkie kanały",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Zaznacz aktualną stronę jako przeczytaną",
|
||||
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną zawartość",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Dodaj/usuń zakładki",
|
||||
"page.keyboard_shortcuts.save_article": "Zapisz artykuł",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń artykuł do góry",
|
||||
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną treść",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Przełącz dodanie do ulubionych",
|
||||
"page.keyboard_shortcuts.save_article": "Zapisz wpis",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń element do góry",
|
||||
"page.keyboard_shortcuts.remove_feed": "Usuń ten kanał",
|
||||
"page.keyboard_shortcuts.go_to_search": "Ustaw fokus na formularzu wyszukiwania",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
|
||||
"page.keyboard_shortcuts.close_modal": "Zamknij listę skrótów klawiszowych",
|
||||
"page.users.title": "Użytkownicy",
|
||||
"page.users.username": "Nazwa użytkownika",
|
||||
@@ -221,33 +222,34 @@
|
||||
"page.settings.unlink_google_account": "Odłącz moje konto Google",
|
||||
"page.settings.link_oidc_account": "Połącz z moim kontem %s",
|
||||
"page.settings.unlink_oidc_account": "Odłącz moje konto %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.passkeys": "Klucze dostępu",
|
||||
"page.settings.webauthn.actions": "Działania",
|
||||
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
|
||||
"page.settings.webauthn.added_on": "Dodano",
|
||||
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
|
||||
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
|
||||
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
|
||||
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Usuń %d klucz dostępu",
|
||||
"Usuń %d klucze dostępu",
|
||||
"Usuń %d klucze dostępu"
|
||||
"Usuń %d kluczy dostępu"
|
||||
],
|
||||
"page.login.title": "Zaloguj się",
|
||||
"page.login.google_signin": "Zaloguj przez Google",
|
||||
"page.login.oidc_signin": "Zaloguj przez %s",
|
||||
"page.login.webauthn_login": "Zaloguj się za pomocą hasła",
|
||||
"page.login.google_signin": "Zaloguj się przez Google",
|
||||
"page.login.oidc_signin": "Zaloguj się przez %s",
|
||||
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
|
||||
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
|
||||
"page.integrations.title": "Usługi",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api": "API Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Punkt końcowy API",
|
||||
"page.integration.miniflux_api_username": "Nazwa Użytkownika",
|
||||
"page.integration.miniflux_api_username": "Nazwa użytkownika",
|
||||
"page.integration.miniflux_api_password": "Hasło",
|
||||
"page.integration.miniflux_api_password_value": "Hasło konta",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.miniflux_api_password_value": "Hasło do konta",
|
||||
"page.integration.bookmarklet": "Skryptozakładka",
|
||||
"page.integration.bookmarklet.name": "Dodaj do Miniflux",
|
||||
"page.integration.bookmarklet.instructions": "Przeciągnij i upuść to łącze do zakładek.",
|
||||
"page.integration.bookmarklet.help": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
|
||||
"page.integration.bookmarklet.help": "To łącze umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
|
||||
"page.sessions.title": "Sesje",
|
||||
"page.sessions.table.date": "Data",
|
||||
"page.sessions.table.ip": "Adres IP",
|
||||
@@ -256,7 +258,7 @@
|
||||
"page.sessions.table.current_session": "Bieżąca sesja",
|
||||
"page.api_keys.title": "Klucze API",
|
||||
"page.api_keys.table.description": "Opis",
|
||||
"page.api_keys.table.token": "Znak",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.table.last_used_at": "Ostatnio używane",
|
||||
"page.api_keys.table.created_at": "Data utworzenia",
|
||||
"page.api_keys.table.actions": "Działania",
|
||||
@@ -265,19 +267,19 @@
|
||||
"page.offline.title": "Tryb offline",
|
||||
"page.offline.message": "Jesteś odłączony od sieci",
|
||||
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"alert.no_shared_entry": "Brak wspólnego wpisu.",
|
||||
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
|
||||
"alert.no_category": "Nie ma żadnej kategorii!",
|
||||
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
|
||||
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
|
||||
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
|
||||
"page.webauthn_rename.title": "Zmień nazwę klucza dostępu",
|
||||
"alert.no_shared_entry": "Brak udostępnionego wpisu.",
|
||||
"alert.no_bookmark": "Brak ulubionych w tej chwili.",
|
||||
"alert.no_category": "Brak kategorii!",
|
||||
"alert.no_category_entry": "Brak wpisów w tej kategorii",
|
||||
"alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.",
|
||||
"alert.no_feed_entry": "Brak wpisów tego kanału.",
|
||||
"alert.no_feed": "Nie masz żadnej subskrypcji.",
|
||||
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
|
||||
"alert.no_feed_in_category": "Nie ma subskrypcji tej kategorii.",
|
||||
"alert.no_history": "Obecnie nie ma żadnej historii.",
|
||||
"alert.feed_error": "Z tym kanałem jest problem",
|
||||
"alert.no_search_result": "Brak wyników dla tego wyszukiwania.",
|
||||
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych artykułów.",
|
||||
"alert.no_search_result": "Brak wyników tego wyszukiwania.",
|
||||
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.",
|
||||
"alert.no_user": "Jesteś jedynym użytkownikiem.",
|
||||
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
|
||||
"alert.account_linked": "Twoje konto zewnętrzne jest teraz połączone!",
|
||||
@@ -286,7 +288,7 @@
|
||||
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
|
||||
"error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
|
||||
"error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
|
||||
"error.duplicate_googlereader_username": "Już ktoś inny używa tej nazwy użytkownika Google Reader!",
|
||||
"error.duplicate_googlereader_username": "Istnieje już ktoś inny z tą samą nazwą użytkownika Google Reader!",
|
||||
"error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
|
||||
"error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
|
||||
"error.category_already_exists": "Ta kategoria już istnieje.",
|
||||
@@ -296,7 +298,7 @@
|
||||
"error.unable_to_create_user": "Nie można utworzyć tego użytkownika.",
|
||||
"error.unable_to_update_user": "Nie można zaktualizować tego użytkownika.",
|
||||
"error.unable_to_update_feed": "Nie można zaktualizować tego kanału.",
|
||||
"error.subscription_not_found": "Nie znaleziono żadnych subskrypcji.",
|
||||
"error.subscription_not_found": "Nie znaleziono żadnych kanałów.",
|
||||
"error.empty_file": "Ten plik jest pusty.",
|
||||
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
|
||||
"error.fields_mandatory": "Wszystkie pola są obowiązkowe.",
|
||||
@@ -304,17 +306,17 @@
|
||||
"error.different_passwords": "Hasła nie są identyczne.",
|
||||
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
|
||||
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
|
||||
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_reading_speed_is_positive": "Szybkości czytania muszą być dodatnimi liczbami całkowitymi.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
|
||||
"error.settings_block_rule_separator_required": "Nieprawidłowa reguła blokowania: wzór reguły #%d musi być oddzielony znakiem '='",
|
||||
"error.settings_block_rule_regex_required": "Nieprawidłowa reguła blokowania: nie podano wzorca reguły #%d",
|
||||
"error.settings_block_rule_invalid_regex": "Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Nieprawidłowa reguła utrzymywania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d musi być oddzielony znakiem '='",
|
||||
"error.settings_keep_rule_regex_required": "Nieprawidłowa reguła utrzymywania nie podano wzorca reguły #%d",
|
||||
"error.settings_keep_rule_invalid_regex": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
|
||||
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
|
||||
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
|
||||
"error.feed_mandatory_fields": "Adres URL i kategoria są obowiązkowe.",
|
||||
"error.feed_already_exists": "Ten kanał już istnieje.",
|
||||
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
|
||||
"error.invalid_site_url": "Nieprawidłowy adres URL witryny.",
|
||||
@@ -325,49 +327,51 @@
|
||||
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
|
||||
"error.feed_invalid_keeplist_rule": "Reguła listy zachowania jest nieprawidłowa.",
|
||||
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
|
||||
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
|
||||
"error.api_key_already_exists": "Ten klucz API już istnieje.",
|
||||
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
|
||||
"error.invalid_theme": "Nieprawidłowy motyw.",
|
||||
"error.invalid_language": "Nieprawidłowy język.",
|
||||
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
|
||||
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
|
||||
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
|
||||
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji sieciowej.",
|
||||
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
|
||||
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
|
||||
"form.feed.label.title": "Tytuł",
|
||||
"form.feed.label.site_url": "URL strony",
|
||||
"form.feed.label.feed_url": "URL kanału",
|
||||
"form.feed.label.site_url": "Adres URL strony",
|
||||
"form.feed.label.feed_url": "Adres URL kanału",
|
||||
"form.feed.label.description": "Opis",
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.crawler": "Pobierz oryginalną treść",
|
||||
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
|
||||
"form.feed.label.feed_password": "Subskrypcję Hasło",
|
||||
"form.feed.label.feed_username": "Nazwa użytkownika subskrypcji",
|
||||
"form.feed.label.feed_password": "Hasło do subskrypcji",
|
||||
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
|
||||
"form.feed.label.cookie": "Ustawianie ciasteczek",
|
||||
"form.feed.label.scraper_rules": "Zasady ekstrakcji",
|
||||
"form.feed.label.cookie": "Ustaw ciasteczka",
|
||||
"form.feed.label.scraper_rules": "Reguły ekstrakcji",
|
||||
"form.feed.label.rewrite_rules": "Reguły zapisu",
|
||||
"form.feed.label.blocklist_rules": "Zasady blokowania",
|
||||
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
|
||||
"form.feed.label.urlrewrite_rules": "Zasady przepisywania adresów URL",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.blocklist_rules": "Reguły blokowania",
|
||||
"form.feed.label.keeplist_rules": "Reguły utrzymywania",
|
||||
"form.feed.label.urlrewrite_rules": "Reguły przepisywania adresów URL",
|
||||
"form.feed.label.apprise_service_urls": "Rozdzielana przecinkami lista adresów URL usług Appprise",
|
||||
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty",
|
||||
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
|
||||
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
|
||||
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
|
||||
"form.feed.label.no_media_player": "Brak odtwarzacza multimedialnego (audio i wideo)",
|
||||
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.label.ntfy_activate": "Prześlij wpisy do ntfy",
|
||||
"form.feed.label.ntfy_priority": "Priorytet ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Maksymalny priorytet ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Wysoki priorytet ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Domyślny priorytet ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Niski priorytet ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Minimalny priorytet ntfy",
|
||||
"form.integration.cubox_activate": "Zapisuj wpisy w Cubox",
|
||||
"form.integration.cubox_api_link": "Łącze API Cubox",
|
||||
"form.feed.fieldset.general": "Ogólne",
|
||||
"form.feed.fieldset.rules": "Reguły",
|
||||
"form.feed.fieldset.network_settings": "Ustawienia sieci",
|
||||
"form.feed.fieldset.integration": "Usługi dostawców zewnętrznych",
|
||||
"form.category.label.title": "Tytuł",
|
||||
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
|
||||
"form.user.label.username": "Nazwa użytkownika",
|
||||
@@ -377,147 +381,151 @@
|
||||
"form.prefs.label.language": "Język",
|
||||
"form.prefs.label.timezone": "Strefa czasowa",
|
||||
"form.prefs.label.theme": "Wygląd",
|
||||
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
|
||||
"form.prefs.label.entries_per_page": "Wpisy na stronie",
|
||||
"form.prefs.label.default_reading_speed": "Tryb wyświetlania Progressive Web App (PWA).",
|
||||
"form.prefs.label.cjk_reading_speed": "Prędkość czytania dla języka chińskiego, koreańskiego i japońskiego (znaki na minutę)",
|
||||
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
|
||||
"form.prefs.label.entry_sorting": "Sortowanie wpisów",
|
||||
"form.prefs.label.entries_per_page": "Wpisy na stronę",
|
||||
"form.prefs.label.default_reading_speed": "Szybkość czytania w innych językach (słowa na minutę)",
|
||||
"form.prefs.label.cjk_reading_speed": "Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)",
|
||||
"form.prefs.label.display_mode": "Tryb wyświetlania progresywnej aplikacji sieciowej (PWA)",
|
||||
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
|
||||
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
|
||||
"form.prefs.label.entry_swipe": "Włącz machnięcie wpisu na ekranach dotykowych",
|
||||
"form.prefs.label.gesture_nav": "Gest, aby poruszać się między wpisami",
|
||||
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
|
||||
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiszowe",
|
||||
"form.prefs.label.entry_swipe": "Włącz przesuwanie wpisów na ekranach dotykowych",
|
||||
"form.prefs.label.gesture_nav": "Gest do poruszania się między wpisami",
|
||||
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
|
||||
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
|
||||
"form.prefs.select.fullscreen": "Pełny ekran",
|
||||
"form.prefs.select.fullscreen": "Pełnoekranowy",
|
||||
"form.prefs.select.standalone": "Samodzielny",
|
||||
"form.prefs.select.minimal_ui": "Minimalny",
|
||||
"form.prefs.select.browser": "Przeglądarka",
|
||||
"form.prefs.select.browser": "Przeglądarkowy",
|
||||
"form.prefs.select.publish_time": "Czas publikacji wpisu",
|
||||
"form.prefs.select.created_time": "Czas utworzenia wpisu",
|
||||
"form.prefs.select.alphabetical": "Alfabetycznie",
|
||||
"form.prefs.select.unread_count": "Liczba nieprzeczytanych",
|
||||
"form.prefs.select.none": "Nic",
|
||||
"form.prefs.select.tap": "Podwójne wciśnięcie",
|
||||
"form.prefs.select.swipe": "Trzepnąć",
|
||||
"form.prefs.select.none": "Brak",
|
||||
"form.prefs.select.tap": "Podwójne stuknięcie",
|
||||
"form.prefs.select.swipe": "Przesuwanie",
|
||||
"form.prefs.label.custom_css": "Niestandardowy CSS",
|
||||
"form.prefs.label.custom_js": "Niestandardowy JavaScript",
|
||||
"form.prefs.label.entry_order": "Kolumna sortowania wpisów",
|
||||
"form.prefs.label.default_home_page": "Domyślna strona główna",
|
||||
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
|
||||
"form.prefs.label.mark_read_on_view": "Automatycznie oznaczaj wpisy jako przeczytane podczas przeglądania",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Oznacz jako przeczytane dopiero wtedy, gdy odtwarzanie audio i wideo osiągnie 90%% ukończenia",
|
||||
"form.prefs.label.mark_read_manually": "Oznacz wpisy jako przeczytane ręcznie",
|
||||
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
|
||||
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
|
||||
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
|
||||
"form.prefs.label.external_font_hosts": "Hosty zewnętrznych czcionek",
|
||||
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
|
||||
"error.settings_invalid_domain_list": "Nieprawidłowa lista domen. Podaj listę domen rozdzielonych spacjami.",
|
||||
"form.import.label.file": "Plik OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Aktywuj Fever API",
|
||||
"form.import.label.url": "Adres URL",
|
||||
"form.integration.betula_activate": "Zapisuj wpisy w Betula",
|
||||
"form.integration.betula_url": "Adres URL serwera Betula",
|
||||
"form.integration.betula_token": "Token do Betula",
|
||||
"form.integration.fever_activate": "Aktywuj API Fever",
|
||||
"form.integration.fever_username": "Login do Fever",
|
||||
"form.integration.fever_password": "Hasło do Fever",
|
||||
"form.integration.fever_endpoint": "Punkt końcowy API gorączka:",
|
||||
"form.integration.googlereader_activate": "Aktywuj Google Reader API",
|
||||
"form.integration.fever_endpoint": "Punkt końcowy API Fever:",
|
||||
"form.integration.googlereader_activate": "Aktywuj API Google Reader",
|
||||
"form.integration.googlereader_username": "Login do Google Reader",
|
||||
"form.integration.googlereader_password": "Hasło do Google Reader",
|
||||
"form.integration.googlereader_endpoint": "Punkt końcowy API gorączka:",
|
||||
"form.integration.pinboard_activate": "Zapisz artykuł w Pinboard",
|
||||
"form.integration.pinboard_token": "Token Pinboard API",
|
||||
"form.integration.pinboard_tags": "Pinboard Tags",
|
||||
"form.integration.googlereader_endpoint": "Punkt końcowy API Google Reader:",
|
||||
"form.integration.pinboard_activate": "Zapisuj wpisy w Pinboard",
|
||||
"form.integration.pinboard_token": "Token API do Pinboard",
|
||||
"form.integration.pinboard_tags": "Znaczniki Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.instapaper_activate": "Zapisz artykuł w Instapaper",
|
||||
"form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
|
||||
"form.integration.instapaper_username": "Login do Instapaper",
|
||||
"form.integration.instapaper_password": "Hasło do Instapaper",
|
||||
"form.integration.pocket_activate": "Zapisz artykuły w Pocket",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pocket_access_token": "Token dostępu kieszeń",
|
||||
"form.integration.pocket_activate": "Zapisuj wpisy w Pocket",
|
||||
"form.integration.pocket_consumer_key": "Klucz klienta do Pocket",
|
||||
"form.integration.pocket_access_token": "Token dostępu do Pocket",
|
||||
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
|
||||
"form.integration.wallabag_activate": "Zapisz artykuły do Wallabag",
|
||||
"form.integration.wallabag_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
|
||||
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.wallabag_endpoint": "Punkt końcowy API Wallabag",
|
||||
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
|
||||
"form.integration.wallabag_username": "Login do Wallabag",
|
||||
"form.integration.wallabag_password": "Hasło do Wallabag",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"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": "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",
|
||||
"form.integration.omnivore_activate": "Zapisz artykuly do Omnivore",
|
||||
"form.integration.omnivore_url": "Omnivore URL",
|
||||
"form.integration.omnivore_api_key": "Omnivore API key",
|
||||
"form.integration.espial_activate": "Zapisz artykuly do Espial",
|
||||
"form.integration.espial_endpoint": "Espial URL",
|
||||
"form.integration.espial_api_key": "Espial API key",
|
||||
"form.integration.espial_tags": "Espial Tags",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.telegram_bot_activate": "Przesyłaj nowe artykuły do czatu Telegram",
|
||||
"form.integration.telegram_bot_token": "Token bota",
|
||||
"form.integration.notion_activate": "Zapisuj wpisy w Notion",
|
||||
"form.integration.notion_page_id": "Identyfikator strony Notion",
|
||||
"form.integration.notion_token": "Tajny token do Notion",
|
||||
"form.integration.apprise_activate": "Przesyłaj wpisy do Apprise",
|
||||
"form.integration.apprise_url": "Adres URL API Apprise",
|
||||
"form.integration.apprise_services_url": "Oddzielona przecinkami lista adresów URL usługi Apprise",
|
||||
"form.integration.nunux_keeper_activate": "Zapisuj wpisy w Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Punkt końcowy API Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Klucz API do Nunux Keeper",
|
||||
"form.integration.omnivore_activate": "Zapisuj wpisy w Omnivore",
|
||||
"form.integration.omnivore_url": "Punkt końcowy API Omnivore",
|
||||
"form.integration.omnivore_api_key": "Klucz API do Omnivore",
|
||||
"form.integration.espial_activate": "Zapisuj wpisy w Espial",
|
||||
"form.integration.espial_endpoint": "Punkt końcowy API Espial",
|
||||
"form.integration.espial_api_key": "Klucz API do Espial",
|
||||
"form.integration.espial_tags": "Znaczniki Espial",
|
||||
"form.integration.readwise_activate": "Zapisuj wpisy w czytniku Readwise",
|
||||
"form.integration.readwise_api_key": "Token dostępu do czytnika Readwise",
|
||||
"form.integration.readwise_api_key_link": "Zdobądź token dostępu Readwise",
|
||||
"form.integration.telegram_bot_activate": "Przesyłaj nowe wpisy do czatu Telegram",
|
||||
"form.integration.telegram_bot_token": "Token do bota",
|
||||
"form.integration.telegram_chat_id": "Identyfikator czatu",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkding_activate": "Zapisz artykuły do Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_api_key": "Linkding API key",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.linkwarden_activate": "Zapisz artykuły do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden URL",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.matrix_bot_activate": "Przenieś nowe artykuły do Matrix",
|
||||
"form.integration.matrix_bot_user": "Nazwa użytkownika dla Matrix",
|
||||
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
||||
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
||||
"form.integration.telegram_topic_id": "Identyfikator tematu",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Wyłącz podgląd strony internetowej",
|
||||
"form.integration.telegram_bot_disable_notification": "Wyłącz powiadomienie",
|
||||
"form.integration.telegram_bot_disable_buttons": "Wyłącz przyciski",
|
||||
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
|
||||
"form.integration.linkace_endpoint": "Punkt końcowy API LinkAce",
|
||||
"form.integration.linkace_api_key": "Klucz API do LinkAce",
|
||||
"form.integration.linkace_tags": "Znaczniki LinkAce",
|
||||
"form.integration.linkace_is_private": "Oznacz łącze jako prywatne",
|
||||
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
|
||||
"form.integration.linkding_activate": "Zapisuj wpisy w Linkding",
|
||||
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
|
||||
"form.integration.linkding_api_key": "Klucz API do Linkding",
|
||||
"form.integration.linkding_tags": "Znaczniki Linkding",
|
||||
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Punkt końcowy API Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
|
||||
"form.integration.matrix_bot_user": "Login do Matrix",
|
||||
"form.integration.matrix_bot_password": "Hasło do Matrix",
|
||||
"form.integration.matrix_bot_url": "Adres 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",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.raindrop_activate": "Zapisuj wpisy do Raindrop",
|
||||
"form.integration.raindrop_token": "Token (testowy)",
|
||||
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
|
||||
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
|
||||
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
|
||||
"form.integration.readeck_endpoint": "Punkt końcowy API Readeck",
|
||||
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
|
||||
"form.integration.readeck_labels": "Etykiety Readeck",
|
||||
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.shiori_activate": "Zapisuj artykuły w Shiori",
|
||||
"form.integration.shiori_endpoint": "Punkt końcowy API Shiori",
|
||||
"form.integration.shiori_username": "Login do Shiori",
|
||||
"form.integration.shiori_password": "Hasło do Shiori",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.shaarli_activate": "Zapisuj artykuły w Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Adres URL Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Tajny klucz API do Shaarli",
|
||||
"form.integration.webhook_activate": "Włącz Webhook",
|
||||
"form.integration.webhook_url": "Adres URL Webhook",
|
||||
"form.integration.webhook_secret": "Tajny klucz do Webhook",
|
||||
"form.integration.rssbridge_activate": "Sprawdź RSS-Bridge podczas dodawania subskrypcji",
|
||||
"form.integration.rssbridge_url": "Adres URL serwera RSS-Bridge",
|
||||
"form.integration.ntfy_activate": "Przesyłaj wpisy do ntfy",
|
||||
"form.integration.ntfy_topic": "Temay ntfy",
|
||||
"form.integration.ntfy_url": "Adres URL ntfy (opcjonalny, domyślny to ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Token API ntfy (opcjonalny)",
|
||||
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
|
||||
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
|
||||
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
|
||||
"form.api_key.label.description": "Etykieta klucza API",
|
||||
"form.submit.loading": "Ładowanie...",
|
||||
"form.submit.saving": "Zapisywanie...",
|
||||
"form.submit.loading": "Ładowanie…",
|
||||
"form.submit.saving": "Zapisywanie…",
|
||||
"time_elapsed.not_yet": "jeszcze nie",
|
||||
"time_elapsed.yesterday": "wczoraj",
|
||||
"time_elapsed.now": "przed chwilą",
|
||||
@@ -538,12 +546,12 @@
|
||||
],
|
||||
"time_elapsed.weeks": [
|
||||
"%d tydzień temu",
|
||||
"%d tygodni temu",
|
||||
"%d tygodnie temu",
|
||||
"%d tygodni temu"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d miesiąc temu",
|
||||
"%d miesięcy temu",
|
||||
"%d miesiące temu",
|
||||
"%d miesięcy temu"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
@@ -552,44 +560,44 @@
|
||||
"%d lat temu"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.",
|
||||
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minuty przed ponowną próbą.",
|
||||
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minut przed ponowną próbą."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
|
||||
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
|
||||
"error.http_response_too_large": "Odpowiedź HTTP jest za duża. Możesz zwiększyć limit rozmiaru odpowiedzi HTTP w ustawieniach globalnych (wymaga ponownego uruchomienia serwera).",
|
||||
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
|
||||
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
|
||||
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
|
||||
"error.tls_error": "Błąd TLS: %q. Jeśli chcesz, możesz wyłączyć weryfikację TLS w ustawieniach kanału.",
|
||||
"error.network_operation": "Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.",
|
||||
"error.network_timeout": "Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v",
|
||||
"error.http_client_error": "Błąd klienta HTTP: %v.",
|
||||
"error.http_not_authorized": "Dostęp do tej witryny nie jest autoryzowany. Może to być błędna nazwa użytkownika lub hasło.",
|
||||
"error.http_too_many_requests": "Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.",
|
||||
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
|
||||
"error.http_resource_not_found": "Nie znaleziono żądanego zasobu. Sprawdź adres URL.",
|
||||
"error.http_internal_server_error": "Strona jest w tej chwili niedostępna z powodu błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_service_unavailable": "Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_gateway_timeout": "Strona internetowa jest w tej chwili niedostępna z powodu błędu przekroczenia limitu czasu bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_unexpected_status_code": "Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.database_error": "Błąd bazy danych: %v.",
|
||||
"error.category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
|
||||
"error.duplicated_feed": "Ten kanał już istnieje.",
|
||||
"error.unable_to_parse_feed": "Nie można przeanalizować tego kanału: %v.",
|
||||
"error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.",
|
||||
"error.unable_to_detect_rssbridge": "Nie można wykryć kanału za pomocą RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
|
||||
"error.settings_media_playback_rate_range": "Szybkość odtwarzania jest poza zakresem",
|
||||
"enclosure_media_controls.seek" : "Przewiń:",
|
||||
"enclosure_media_controls.seek.title" : "Przewiń o %s sek.",
|
||||
"enclosure_media_controls.speed" : "Szybkość:",
|
||||
"enclosure_media_controls.speed.faster" : "Szybciej",
|
||||
"enclosure_media_controls.speed.faster.title" : "Szybciej o %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Wolniej",
|
||||
"enclosure_media_controls.speed.slower.title" : "Wolniej o %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Przywróć",
|
||||
"enclosure_media_controls.speed.reset.title" : "Przywróć szybkość do 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Marcar todos como lido",
|
||||
"menu.show_all_entries": "Mostrar todas os itens",
|
||||
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos",
|
||||
"menu.show_only_starred_entries": "Mostrar apenas os favoritos",
|
||||
"menu.refresh_feed": "Atualizar",
|
||||
"menu.refresh_all_feeds": "Atualizar todas as fontes",
|
||||
"menu.edit_feed": "Editar",
|
||||
@@ -219,6 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Registrar senha",
|
||||
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Remover %d senha",
|
||||
"Remover %d senhas"
|
||||
@@ -354,6 +356,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -390,6 +394,7 @@
|
||||
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
|
||||
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
|
||||
"form.prefs.label.custom_css": "CSS customizado",
|
||||
"form.prefs.label.custom_js": "JavaScript customizado",
|
||||
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
|
||||
"form.prefs.label.default_home_page": "Página inicial predefinida",
|
||||
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
|
||||
@@ -401,6 +406,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Arquivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Отметить всё как прочитанное",
|
||||
"menu.show_all_entries": "Показать все статьи",
|
||||
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
|
||||
"menu.show_only_starred_entries": "Показывать только избранные статьи",
|
||||
"menu.refresh_feed": "Обновить",
|
||||
"menu.refresh_all_feeds": "Обновить все подписки в фоне",
|
||||
"menu.edit_feed": "Изменить",
|
||||
@@ -228,6 +229,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Зарегистрировать пароль",
|
||||
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Удалить %d пароль",
|
||||
"Удалить %d пароля",
|
||||
@@ -364,6 +366,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -400,6 +404,7 @@
|
||||
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
|
||||
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
|
||||
"form.prefs.label.custom_css": "Пользовательский CSS",
|
||||
"form.prefs.label.custom_js": "Пользовательский JavaScript",
|
||||
"form.prefs.label.entry_order": "Столбец сортировки статей",
|
||||
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
|
||||
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
|
||||
@@ -411,6 +416,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML файл",
|
||||
"form.import.label.url": "Ссылка",
|
||||
"form.integration.betula_activate": "Сохранять статьи в Бетулу",
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
|
||||
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
|
||||
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
|
||||
@@ -293,6 +295,7 @@
|
||||
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
|
||||
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
|
||||
"form.prefs.label.custom_css": "Özel CSS",
|
||||
"form.prefs.label.custom_js": "Özel JavaScript",
|
||||
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
|
||||
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
|
||||
"form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
|
||||
@@ -324,6 +327,9 @@
|
||||
"form.prefs.select.swipe": "Kaydırma",
|
||||
"form.prefs.select.tap": "Çift dokunma",
|
||||
"form.prefs.select.unread_count": "Okunmamış sayısı",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.submit.loading": "Yükleniyor...",
|
||||
"form.submit.saving": "Kaydediliyor...",
|
||||
"form.user.label.admin": "Yönetici",
|
||||
@@ -359,6 +365,7 @@
|
||||
"menu.shared_entries": "Paylaşılan makaleler",
|
||||
"menu.show_all_entries": "Tüm makaleleri göster",
|
||||
"menu.show_only_unread_entries": "Sadece okunmamış makaleleri göster",
|
||||
"menu.show_only_starred_entries": "Sadece yıldızlanmış makaleleri göster",
|
||||
"menu.starred": "Yıldız",
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Okunmadı",
|
||||
@@ -459,6 +466,7 @@
|
||||
"page.login.title": "Oturum aç",
|
||||
"page.login.webauthn_login": "Passkey ile giriş yap",
|
||||
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.new_api_key.title": "Yeni API Anahtarı",
|
||||
"page.new_category.title": "Yeni Kategori",
|
||||
"page.new_user.title": "Yeni Kullanıcı",
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"action.home_screen": "Додати до головного екрану",
|
||||
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
|
||||
"tooltip.logged_user": "Здійснено вхід як %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "Меню",
|
||||
"menu.home_page": "Головна сторінка",
|
||||
"menu.unread": "Непрочитане",
|
||||
"menu.starred": "З зірочкою",
|
||||
"menu.history": "Історія",
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Відмітити все як прочитане",
|
||||
"menu.show_all_entries": "Показати всі записи",
|
||||
"menu.show_only_unread_entries": "Показати тільки непрочитані записи",
|
||||
"menu.show_only_starred_entries": "Показати тільки записи з зірочкою",
|
||||
"menu.refresh_feed": "Оновити",
|
||||
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
|
||||
"menu.edit_feed": "Редагувати",
|
||||
@@ -54,11 +55,11 @@
|
||||
"menu.shared_entries": "Спільні записи",
|
||||
"search.label": "Пошук",
|
||||
"search.placeholder": "Шукати...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Вперед",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Назад",
|
||||
"search.submit": "Знайти",
|
||||
"pagination.last": "Остання",
|
||||
"pagination.next": "Наступна",
|
||||
"pagination.first": "Перша",
|
||||
"pagination.previous": "Попередня",
|
||||
"entry.status.unread": "Непрочитане",
|
||||
"entry.status.read": "Прочитане",
|
||||
"entry.status.toast.unread": "Відмічено непрочитаним",
|
||||
@@ -91,7 +92,7 @@
|
||||
"читати %d хвилин"
|
||||
],
|
||||
"entry.tags.label": "Теги:",
|
||||
"page.shared_entries.title": "Спильні записи",
|
||||
"page.shared_entries.title": "Спільні записи",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries",
|
||||
@@ -133,9 +134,9 @@
|
||||
"page.edit_category.title": "Редагування категорії: %s",
|
||||
"page.edit_user.title": "Редагування користувача: %s",
|
||||
"page.feeds.title": "Стрічки",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Категорія: %s",
|
||||
"page.feeds.last_check": "Остання перевірка:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Наступна перевірка:",
|
||||
"page.feeds.read_counter": "Кількість прочитаних записів",
|
||||
"page.feeds.error_count": [
|
||||
"%d помилка",
|
||||
@@ -228,6 +229,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Зареєструвати пароль",
|
||||
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Видалити %d ключ доступу",
|
||||
"Видаліть %d ключа доступу",
|
||||
@@ -364,6 +366,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.category.label.title": "Назва",
|
||||
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
|
||||
"form.feed.fieldset.general": "General",
|
||||
@@ -400,6 +404,7 @@
|
||||
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
|
||||
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
|
||||
"form.prefs.label.custom_css": "Спеціальний CSS",
|
||||
"form.prefs.label.custom_js": "Спеціальний JavaScript",
|
||||
"form.prefs.label.entry_order": "Стовпець сортування записів",
|
||||
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
|
||||
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
|
||||
@@ -411,6 +416,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Файл OPML",
|
||||
"form.import.label.url": "URL-адреса",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "全部标为已读",
|
||||
"menu.show_all_entries": "显示所有文章",
|
||||
"menu.show_only_unread_entries": "仅显示未读文章",
|
||||
"menu.show_only_starred_entries": "仅显示已收藏文章",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "在后台更新全部源",
|
||||
"menu.edit_feed": "编辑",
|
||||
@@ -91,18 +92,18 @@
|
||||
"entry.tags.label": "标签:",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry"
|
||||
"%d 已分享的文章"
|
||||
],
|
||||
"page.unread.title": "未读",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry"
|
||||
"%d 未读的文章"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total"
|
||||
"%d 文章总数"
|
||||
],
|
||||
"page.starred.title": "收藏",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry"
|
||||
"%d 收藏的文章"
|
||||
],
|
||||
"page.categories.title": "分类",
|
||||
"page.categories.no_feed": "没有源",
|
||||
@@ -210,6 +211,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "最后使用时间",
|
||||
"page.settings.webauthn.register": "注册 Passkey",
|
||||
"page.settings.webauthn.register.error": "无法注册 Passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"删除 %d 个 Passkey"
|
||||
],
|
||||
@@ -344,6 +346,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy默认优先级",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy低优先级",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy最低优先级",
|
||||
"form.integration.cubox_activate": "保存文章到 Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API 链接",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.rules": "规则",
|
||||
"form.feed.fieldset.network_settings": "网络设置",
|
||||
@@ -380,6 +384,7 @@
|
||||
"form.prefs.label.gesture_nav": "在条目之间导航的手势",
|
||||
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
|
||||
"form.prefs.label.custom_css": "自定义 CSS",
|
||||
"form.prefs.label.custom_js": "自定义 JavaScript",
|
||||
"form.prefs.label.entry_order": "文章排序依据",
|
||||
"form.prefs.label.default_home_page": "默认主页",
|
||||
"form.prefs.label.categories_sorting_order": "分类排序",
|
||||
@@ -391,6 +396,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "用户认证设置",
|
||||
"form.prefs.fieldset.reader_settings": "阅读器设置",
|
||||
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
|
||||
"form.prefs.label.external_font_hosts": "外部字体托管",
|
||||
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
|
||||
"error.settings_invalid_domain_list": "域名列表无效。请提供一个用空格分隔的域名列表。",
|
||||
"form.import.label.file": "OPML 文件",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "保存文章到 Betula",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "全部標為已讀",
|
||||
"menu.show_all_entries": "顯示所有文章",
|
||||
"menu.show_only_unread_entries": "僅顯示未讀文章",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "背景更新全部Feeds",
|
||||
"menu.edit_feed": "編輯",
|
||||
@@ -210,6 +211,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "最後使用時間",
|
||||
"page.settings.webauthn.register": "註冊 Passkey",
|
||||
"page.settings.webauthn.register.error": "無法註冊 Passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"刪除 %d 個 Passkey"
|
||||
],
|
||||
@@ -344,6 +346,8 @@
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.rules": "規則",
|
||||
"form.feed.fieldset.network_settings": "網路設定",
|
||||
@@ -380,6 +384,7 @@
|
||||
"form.prefs.label.gesture_nav": "在條目之間導航的手勢",
|
||||
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
|
||||
"form.prefs.label.custom_css": "自定義 CSS",
|
||||
"form.prefs.label.custom_js": "自定義 JavaScript",
|
||||
"form.prefs.label.entry_order": "文章排序依據",
|
||||
"form.prefs.label.default_home_page": "預設主頁",
|
||||
"form.prefs.label.categories_sorting_order": "分類排序",
|
||||
@@ -391,6 +396,9 @@
|
||||
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
|
||||
"form.prefs.fieldset.reader_settings": "閱讀器設定",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML 檔案",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteProxyFilterWithCustomPortInBaseURL(t *testing.T) {
|
||||
func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://example.org:88/folder/")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
@@ -198,11 +198,20 @@ func TestAbsoluteProxyFilterWithCustomPortInBaseURL(t *testing.T) {
|
||||
t.Fatalf(`Unexpected base URL, got "%s"`, config.Opts.BaseURL())
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
if config.Opts.RootURL() != "http://example.org:88" {
|
||||
t.Fatalf(`Unexpected root URL, got "%s"`, config.Opts.RootURL())
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
if config.Opts.BasePath() != "" {
|
||||
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
|
||||
}
|
||||
|
||||
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(router, input)
|
||||
expected := `<p><img src="http://example.org:88/folder/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
|
||||
@@ -40,9 +40,9 @@ func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
|
||||
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||
}
|
||||
|
||||
// Note that the proxyified URL is relative to the root URL.
|
||||
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
|
||||
|
||||
absoluteURL, err := url.JoinPath(config.Opts.BaseURL(), proxifiedUrl)
|
||||
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
|
||||
if err != nil {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
@@ -34,9 +35,34 @@ func (e Enclosure) Html5MimeType() string {
|
||||
return e.MimeType
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsAudio() bool {
|
||||
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsVideo() bool {
|
||||
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsImage() bool {
|
||||
mimeType := strings.ToLower(e.MimeType)
|
||||
mediaURL := strings.ToLower(e.URL)
|
||||
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
|
||||
}
|
||||
|
||||
// EnclosureList represents a list of attachments.
|
||||
type EnclosureList []*Enclosure
|
||||
|
||||
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
|
||||
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
|
||||
for _, enclosure := range el {
|
||||
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
return enclosure
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
for _, enclosure := range el {
|
||||
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
|
||||
+13
-11
@@ -56,11 +56,12 @@ type Feed struct {
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
|
||||
// Non persisted attributes
|
||||
// Non-persisted attributes
|
||||
Category *Category `json:"category,omitempty"`
|
||||
Icon *FeedIcon `json:"icon"`
|
||||
Entries Entries `json:"entries,omitempty"`
|
||||
|
||||
// Internal attributes (not exposed in the API and not persisted in the database)
|
||||
TTL int `json:"-"`
|
||||
IconURL string `json:"-"`
|
||||
UnreadCount int `json:"-"`
|
||||
@@ -111,12 +112,13 @@ func (f *Feed) CheckedNow() {
|
||||
}
|
||||
|
||||
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
|
||||
func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) {
|
||||
f.TTL = newTTL
|
||||
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
|
||||
f.TTL = refreshDelayInMinutes
|
||||
|
||||
// Default to the global config Polling Frequency.
|
||||
var intervalMinutes int
|
||||
switch config.Opts.PollingScheduler() {
|
||||
case SchedulerEntryFrequency:
|
||||
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
|
||||
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
|
||||
if weeklyCount <= 0 {
|
||||
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
|
||||
} else {
|
||||
@@ -124,13 +126,13 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) {
|
||||
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
|
||||
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
|
||||
}
|
||||
default:
|
||||
intervalMinutes = config.Opts.SchedulerRoundRobinMinInterval()
|
||||
}
|
||||
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
|
||||
if newTTL > intervalMinutes && newTTL > 0 {
|
||||
intervalMinutes = newTTL
|
||||
|
||||
// If the feed has a TTL or a Retry-After defined, we use it to make sure we don't check it too often.
|
||||
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes {
|
||||
intervalMinutes = refreshDelayInMinutes
|
||||
}
|
||||
|
||||
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
|
||||
}
|
||||
|
||||
|
||||
+57
-13
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
largeWeeklyCount = 10080
|
||||
noNewTTL = 0
|
||||
noRefreshDelay = 0
|
||||
)
|
||||
|
||||
func TestFeedCategorySetter(t *testing.T) {
|
||||
@@ -76,7 +76,7 @@ func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefor
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckDefault(t *testing.T) {
|
||||
func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
@@ -88,15 +88,60 @@ func TestFeedScheduleNextCheckDefault(t *testing.T) {
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
weeklyCount := 10
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(0, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "default SchedulerRoundRobinMinInterval")
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinDefault")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
|
||||
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
|
||||
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
|
||||
@@ -114,15 +159,14 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
weeklyCount := 100
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(0, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := minInterval
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "round robin min interval")
|
||||
expectedInterval := minInterval
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
|
||||
@@ -144,7 +188,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
|
||||
feed := &Feed{}
|
||||
// Use a very small weekly count to trigger the max interval
|
||||
weeklyCount := 1
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
@@ -173,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
|
||||
feed := &Feed{}
|
||||
// Use a very small weekly count to trigger the max interval
|
||||
weeklyCount := 0
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
@@ -202,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
|
||||
feed := &Feed{}
|
||||
// Use a very large weekly count to trigger the min interval
|
||||
weeklyCount := largeWeeklyCount
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
@@ -228,7 +272,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
weeklyCount := 7
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
|
||||
@@ -104,4 +104,6 @@ type Integration struct {
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ type User struct {
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
@@ -60,6 +62,8 @@ type UserModificationRequest struct {
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
@@ -118,6 +122,14 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
user.Stylesheet = *u.Stylesheet
|
||||
}
|
||||
|
||||
if u.CustomJS != nil {
|
||||
user.CustomJS = *u.CustomJS
|
||||
}
|
||||
|
||||
if u.ExternalFontHosts != nil {
|
||||
user.ExternalFontHosts = *u.ExternalFontHosts
|
||||
}
|
||||
|
||||
if u.GoogleID != nil {
|
||||
user.GoogleID = *u.GoogleID
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/locale"
|
||||
)
|
||||
@@ -51,6 +53,26 @@ func (r *ResponseHandler) ETag() string {
|
||||
return r.httpResponse.Header.Get("ETag")
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) ParseRetryDelay() int {
|
||||
retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After")
|
||||
if retryAfterHeaderValue != "" {
|
||||
// First, try to parse as an integer (number of seconds)
|
||||
if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {
|
||||
return seconds
|
||||
}
|
||||
|
||||
// If not an integer, try to parse as an HTTP-date
|
||||
if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {
|
||||
return int(time.Until(t).Seconds())
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) IsRateLimited() bool {
|
||||
return r.httpResponse != nil && r.httpResponse.StatusCode == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bool {
|
||||
if r.httpResponse.StatusCode == http.StatusNotModified {
|
||||
return false
|
||||
|
||||
@@ -6,6 +6,7 @@ package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsModified(t *testing.T) {
|
||||
@@ -30,7 +31,7 @@ func TestIsModified(t *testing.T) {
|
||||
ETag: cachedEtag,
|
||||
IsModified: false,
|
||||
},
|
||||
// This case is invalid per RFC9110 8.8.1, so ETag takes precedence.
|
||||
// ETag takes precedence per RFC9110 8.8.1.
|
||||
"Last-Modified changed only": {
|
||||
Status: 200,
|
||||
LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
|
||||
@@ -67,3 +68,37 @@ func TestIsModified(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelay(t *testing.T) {
|
||||
var testCases = map[string]struct {
|
||||
RetryAfterHeader string
|
||||
ExpectedDelay int
|
||||
}{
|
||||
"Empty header": {
|
||||
RetryAfterHeader: "",
|
||||
ExpectedDelay: 0,
|
||||
},
|
||||
"Integer value": {
|
||||
RetryAfterHeader: "42",
|
||||
ExpectedDelay: 42,
|
||||
},
|
||||
"HTTP-date": {
|
||||
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
|
||||
ExpectedDelay: 41,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(tt *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Add("Retry-After", tc.RetryAfterHeader)
|
||||
rh := ResponseHandler{
|
||||
httpResponse: &http.Response{
|
||||
Header: header,
|
||||
},
|
||||
}
|
||||
if tc.ExpectedDelay != rh.ParseRetryDelay() {
|
||||
tt.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedDelay, rh.ParseRetryDelay(), name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +93,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
requestBuilder,
|
||||
subscription.ID,
|
||||
subscription.SiteURL,
|
||||
subscription.IconURL,
|
||||
)
|
||||
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
|
||||
|
||||
return subscription, nil
|
||||
}
|
||||
@@ -188,13 +182,8 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
slog.String("feed_url", subscription.FeedURL),
|
||||
)
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
requestBuilder,
|
||||
subscription.ID,
|
||||
subscription.SiteURL,
|
||||
subscription.IconURL,
|
||||
)
|
||||
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
|
||||
|
||||
return subscription, nil
|
||||
}
|
||||
|
||||
@@ -221,7 +210,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
weeklyEntryCount := 0
|
||||
newTTL := 0
|
||||
refreshDelayInMinutes := 0
|
||||
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
|
||||
var weeklyCountErr error
|
||||
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
|
||||
@@ -231,7 +220,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
originalFeed.CheckedNow()
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
||||
@@ -252,6 +241,19 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if responseHandler.IsRateLimited() {
|
||||
retryDelayInSeconds := responseHandler.ParseRetryDelay()
|
||||
refreshDelayInMinutes = retryDelayInSeconds / 60
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
|
||||
slog.Warn("Feed is rate limited",
|
||||
slog.String("feed_url", originalFeed.FeedURL),
|
||||
slog.Int("retry_delay_in_seconds", retryDelayInSeconds),
|
||||
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
}
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch feed", slog.String("feed_url", originalFeed.FeedURL), slog.Any("error", localizedError.Error()))
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
@@ -270,6 +272,8 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.Debug("Feed modified",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("etag_header", originalFeed.EtagHeader),
|
||||
slog.String("last_modified_header", originalFeed.LastModifiedHeader),
|
||||
)
|
||||
|
||||
responseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())
|
||||
@@ -292,13 +296,15 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
|
||||
newTTL = updatedFeed.TTL
|
||||
refreshDelayInMinutes = updatedFeed.TTL
|
||||
|
||||
// Set the next check at with updated arguments.
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
|
||||
slog.Debug("Updated next check date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.Int("ttl", newTTL),
|
||||
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
|
||||
@@ -326,23 +332,26 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
go integration.PushEntries(originalFeed, newEntries, userIntegrations)
|
||||
}
|
||||
|
||||
// We update caching headers only if the feed has been modified,
|
||||
// because some websites don't return the same headers when replying with a 304.
|
||||
originalFeed.EtagHeader = responseHandler.ETag()
|
||||
originalFeed.LastModifiedHeader = responseHandler.LastModified()
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
requestBuilder,
|
||||
originalFeed.ID,
|
||||
originalFeed.SiteURL,
|
||||
updatedFeed.IconURL,
|
||||
)
|
||||
iconChecker := icon.NewIconChecker(store, originalFeed)
|
||||
if forceRefresh {
|
||||
iconChecker.UpdateOrCreateFeedIcon()
|
||||
} else {
|
||||
iconChecker.CreateFeedIconIfMissing()
|
||||
}
|
||||
} else {
|
||||
slog.Debug("Feed not modified",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
)
|
||||
|
||||
// Last-Modified may be updated even if ETag is not. In this case, per
|
||||
// RFC9111 sections 3.2 and 4.3.4, the stored response must be updated.
|
||||
if responseHandler.LastModified() != "" {
|
||||
originalFeed.LastModifiedHeader = responseHandler.LastModified()
|
||||
}
|
||||
}
|
||||
|
||||
originalFeed.ResetErrorCounter()
|
||||
@@ -356,32 +365,3 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkFeedIcon(store *storage.Storage, requestBuilder *fetcher.RequestBuilder, feedID int64, websiteURL, feedIconURL string) {
|
||||
if !store.HasIcon(feedID) {
|
||||
iconFinder := icon.NewIconFinder(requestBuilder, websiteURL, feedIconURL)
|
||||
if icon, err := iconFinder.FindIcon(); err != nil {
|
||||
slog.Debug("Unable to find feed icon",
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("feed_icon_url", feedIconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon == nil {
|
||||
slog.Debug("No icon found",
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("feed_icon_url", feedIconURL),
|
||||
)
|
||||
} else {
|
||||
if err := store.CreateFeedIcon(feedID, icon); err != nil {
|
||||
slog.Error("Unable to store feed icon",
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("feed_icon_url", feedIconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
type IconChecker struct {
|
||||
store *storage.Storage
|
||||
feed *model.Feed
|
||||
}
|
||||
|
||||
func NewIconChecker(store *storage.Storage, feed *model.Feed) *IconChecker {
|
||||
return &IconChecker{
|
||||
store: store,
|
||||
feed: feed,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IconChecker) fetchAndStoreIcon() {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(c.feed.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(c.feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
|
||||
|
||||
iconFinder := NewIconFinder(requestBuilder, c.feed.FeedURL, c.feed.IconURL)
|
||||
if icon, err := iconFinder.FindIcon(); err != nil {
|
||||
slog.Debug("Unable to find feed icon",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon == nil {
|
||||
slog.Debug("No icon found",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
)
|
||||
} else {
|
||||
if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
|
||||
slog.Error("Unable to store feed icon",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Feed icon stored",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Int64("icon_id", icon.ID),
|
||||
slog.String("icon_hash", icon.Hash),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IconChecker) CreateFeedIconIfMissing() {
|
||||
if c.store.HasFeedIcon(c.feed.ID) {
|
||||
slog.Debug("Feed icon already exists",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
c.fetchAndStoreIcon()
|
||||
}
|
||||
|
||||
func (c *IconChecker) UpdateOrCreateFeedIcon() {
|
||||
c.fetchAndStoreIcon()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var (
|
||||
bilibiliURLRegex = regexp.MustCompile(`bilibili\.com/video/(.*)$`)
|
||||
bilibiliVideoIdRegex = regexp.MustCompile(`/video/(?:av(\d+)|BV([a-zA-Z0-9]+))`)
|
||||
)
|
||||
|
||||
func shouldFetchBilibiliWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchBilibiliWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := bilibiliURLRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesBilibiliPattern := len(matches) == 2
|
||||
return urlMatchesBilibiliPattern
|
||||
}
|
||||
|
||||
func extractBilibiliVideoID(websiteURL string) (string, string, error) {
|
||||
matches := bilibiliVideoIdRegex.FindStringSubmatch(websiteURL)
|
||||
if matches == nil {
|
||||
return "", "", fmt.Errorf("no video ID found in URL: %s", websiteURL)
|
||||
}
|
||||
if matches[1] != "" {
|
||||
return "aid", matches[1], nil
|
||||
}
|
||||
if matches[2] != "" {
|
||||
return "bvid", matches[2], nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unexpected regex match result for URL: %s", websiteURL)
|
||||
}
|
||||
|
||||
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
idType, videoID, extractErr := extractBilibiliVideoID(websiteURL)
|
||||
if extractErr != nil {
|
||||
return 0, extractErr
|
||||
}
|
||||
bilibiliApiURL := fmt.Sprintf("https://api.bilibili.com/x/web-interface/view?%s=%s", idType, videoID)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(bilibiliApiURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Bilibili API",
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("api_url", bilibiliApiURL),
|
||||
slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
doc := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr := doc.Decode(&result); docErr != nil {
|
||||
return 0, fmt.Errorf("failed to decode API response: %v", docErr)
|
||||
}
|
||||
|
||||
if code, ok := result["code"].(float64); !ok || code != 0 {
|
||||
return 0, fmt.Errorf("API returned error code: %v", result["code"])
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("data field not found or not an object")
|
||||
}
|
||||
|
||||
duration, ok := data["duration"].(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("duration not found or not a number")
|
||||
}
|
||||
intDuration := int(duration)
|
||||
durationMin := intDuration / 60
|
||||
if intDuration%60 != 0 {
|
||||
durationMin++
|
||||
}
|
||||
return durationMin, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
|
||||
|
||||
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchNebulaWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := nebulaRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != 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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := odyseeRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(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 Odysee 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="og: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
|
||||
}
|
||||
@@ -4,12 +4,9 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,20 +21,11 @@ import (
|
||||
"miniflux.app/v2/internal/reader/urlcleaner"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
|
||||
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
bilibiliRegex = regexp.MustCompile(`bilibili\.com/video/(.*)$`)
|
||||
timelengthRegex = regexp.MustCompile(`"timelength":\s*(\d+)`)
|
||||
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\("(.*)"\|"(.*)"\)`)
|
||||
)
|
||||
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
|
||||
// ProcessFeedEntries downloads original web page for entries and apply filters.
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
|
||||
@@ -65,6 +53,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
|
||||
pageBaseURL := ""
|
||||
rewrittenURL := rewriteEntryURL(feed, entry)
|
||||
entry.URL = rewrittenURL
|
||||
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
|
||||
if feed.Crawler && (entryIsNew || forceRefresh) {
|
||||
slog.Debug("Scraping entry",
|
||||
@@ -445,209 +434,6 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
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
|
||||
}
|
||||
matches := odyseeRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func shouldFetchBilibiliWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchBilibiliWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := bilibiliRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesBilibiliPattern := len(matches) == 2
|
||||
return urlMatchesBilibiliPattern
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(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 YouTube page", 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[itemprop="duration"]`).First().Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
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())
|
||||
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 Odysee 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="og: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 fetchBilibiliWatchTime(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 Bilibili page", 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
|
||||
}
|
||||
|
||||
timelengthMatches := timelengthRegex.FindStringSubmatch(doc.Text())
|
||||
if len(timelengthMatches) < 2 {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
durationMs, err := strconv.ParseInt(timelengthMatches[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", timelengthMatches[1], err)
|
||||
}
|
||||
|
||||
durationSec := durationMs / 1000
|
||||
durationMin := durationSec / 60
|
||||
if durationSec%60 != 0 {
|
||||
durationMin++
|
||||
}
|
||||
|
||||
return int(durationMin), nil
|
||||
}
|
||||
|
||||
// parseISO8601 parses an ISO 8601 duration string.
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
part := match[i]
|
||||
if i == 0 || name == "" || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func isRecentEntry(entry *model.Entry) bool {
|
||||
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
|
||||
return true
|
||||
|
||||
@@ -85,35 +85,6 @@ func TestAllowEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseISO8601(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
duration string
|
||||
expected time.Duration
|
||||
}{
|
||||
// Live streams and radio.
|
||||
{"PT0M0S", 0},
|
||||
// https://www.youtube.com/watch?v=HLrqNhgdiC0
|
||||
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=LZa5KKfqHtA
|
||||
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=yIxEEgEuhT4
|
||||
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=bpHf1XcoiFs
|
||||
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result, err := parseISO8601(tc.duration)
|
||||
if err != nil {
|
||||
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
|
||||
}
|
||||
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRecentEntry(t *testing.T) {
|
||||
parser := config.NewParser()
|
||||
var err error
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
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)?)?$`)
|
||||
)
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(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 YouTube page", 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[itemprop="duration"]`).First().Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
part := match[i]
|
||||
if i == 0 || name == "" || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseISO8601(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
duration string
|
||||
expected time.Duration
|
||||
}{
|
||||
// Live streams and radio.
|
||||
{"PT0M0S", 0},
|
||||
// https://www.youtube.com/watch?v=HLrqNhgdiC0
|
||||
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=LZa5KKfqHtA
|
||||
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=yIxEEgEuhT4
|
||||
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=bpHf1XcoiFs
|
||||
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result, err := parseISO8601(tc.duration)
|
||||
if err != nil {
|
||||
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
|
||||
}
|
||||
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
|
||||
|
||||
import "regexp"
|
||||
|
||||
// List of predefined rewrite rules (alphabetically sorted)
|
||||
// Available rules: "add_image_title", "add_youtube_video"
|
||||
// domain => rule name
|
||||
@@ -38,3 +40,42 @@ var predefinedRules = map[string]string{
|
||||
"xkcd.com": "add_image_title",
|
||||
"youtube.com": "add_youtube_video",
|
||||
}
|
||||
|
||||
type RefererRule struct {
|
||||
URLPattern *regexp.Regexp
|
||||
Referer string
|
||||
}
|
||||
|
||||
// List of predefined referer rules
|
||||
var PredefinedRefererRules = []RefererRule{
|
||||
{
|
||||
URLPattern: regexp.MustCompile(`^https://\w+\.sinaimg\.cn`),
|
||||
Referer: "https://weibo.com",
|
||||
},
|
||||
{
|
||||
URLPattern: regexp.MustCompile(`^https://i\.pximg\.net`),
|
||||
Referer: "https://www.pixiv.net",
|
||||
},
|
||||
{
|
||||
URLPattern: regexp.MustCompile(`^https://cdnfile\.sspai\.com`),
|
||||
Referer: "https://sspai.com",
|
||||
},
|
||||
{
|
||||
URLPattern: regexp.MustCompile(`^https://(?:\w|-)+\.cdninstagram\.com`),
|
||||
Referer: "https://www.instagram.com",
|
||||
},
|
||||
{
|
||||
URLPattern: regexp.MustCompile(`^https://sp1\.piokok\.com`),
|
||||
Referer: "https://sp1.piokok.com",
|
||||
},
|
||||
}
|
||||
|
||||
// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.
|
||||
func GetRefererForURL(url string) string {
|
||||
for _, rule := range PredefinedRefererRules {
|
||||
if rule.URLPattern.MatchString(url) {
|
||||
return rule.Referer
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -101,10 +101,13 @@ func (r *RSSAdapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Generate the entry hash.
|
||||
if item.GUID.Data != "" {
|
||||
switch {
|
||||
case item.GUID.Data != "":
|
||||
entry.Hash = crypto.Hash(item.GUID.Data)
|
||||
} else if entryURL != "" {
|
||||
case entryURL != "":
|
||||
entry.Hash = crypto.Hash(entryURL)
|
||||
default:
|
||||
entry.Hash = crypto.Hash(entry.Title + entry.Content)
|
||||
}
|
||||
|
||||
// Find CommentsURL if defined.
|
||||
|
||||
@@ -336,6 +336,39 @@ func TestParseEntryWithoutLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithoutLinkAndWithoutGUID(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<item>
|
||||
<title>Item 1</title>
|
||||
</item>
|
||||
<item>
|
||||
<title>Item 2</title>
|
||||
<pubDate>Wed, 02 Oct 2002 08:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 2 {
|
||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Hash != "c5ddfeffb275254140796b8c080f372d65ebb1b0590e238b191f595d5fcd32ca" {
|
||||
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
|
||||
}
|
||||
|
||||
if feed.Entries[1].Hash != "0a937478f9bdbfca2de5cdeeb5ee7b09678a3330fc7cc5b05169a50d4516c9a3" {
|
||||
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[1].Hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithOnlyGuidPermalink(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
|
||||
+38
-47
@@ -11,8 +11,8 @@ import (
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// HasIcon checks if the given feed has an icon.
|
||||
func (s *Storage) HasIcon(feedID int64) bool {
|
||||
// HasFeedIcon checks if the given feed has an icon.
|
||||
func (s *Storage) HasFeedIcon(feedID int64) bool {
|
||||
var result bool
|
||||
query := `SELECT true FROM feed_icons WHERE feed_id=$1`
|
||||
s.db.QueryRow(query, feedID).Scan(&result)
|
||||
@@ -57,59 +57,50 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
|
||||
return &icon, nil
|
||||
}
|
||||
|
||||
// IconByHash returns an icon by the hash (checksum).
|
||||
func (s *Storage) IconByHash(icon *model.Icon) error {
|
||||
err := s.db.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
// StoreFeedIcon creates or updates a feed icon.
|
||||
func (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to start transaction: %v`, err)
|
||||
}
|
||||
|
||||
if err := tx.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID); err == sql.ErrNoRows {
|
||||
query := `
|
||||
INSERT INTO icons
|
||||
(hash, mime_type, content)
|
||||
VALUES
|
||||
($1, $2, $3)
|
||||
RETURNING
|
||||
id
|
||||
`
|
||||
err := tx.QueryRow(
|
||||
query,
|
||||
icon.Hash,
|
||||
normalizeMimeType(icon.MimeType),
|
||||
icon.Content,
|
||||
).Scan(&icon.ID)
|
||||
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf(`store: unable to create icon: %v`, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf(`store: unable to fetch icon by hash %q: %v`, icon.Hash, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateIcon creates a new icon.
|
||||
func (s *Storage) CreateIcon(icon *model.Icon) error {
|
||||
query := `
|
||||
INSERT INTO icons
|
||||
(hash, mime_type, content)
|
||||
VALUES
|
||||
($1, $2, $3)
|
||||
RETURNING
|
||||
id
|
||||
`
|
||||
err := s.db.QueryRow(
|
||||
query,
|
||||
icon.Hash,
|
||||
normalizeMimeType(icon.MimeType),
|
||||
icon.Content,
|
||||
).Scan(&icon.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to create icon: %v`, err)
|
||||
if _, err := tx.Exec(`DELETE FROM feed_icons WHERE feed_id=$1`, feedID); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf(`store: unable to delete feed icon: %v`, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateFeedIcon creates an icon and associate the icon to the given feed.
|
||||
func (s *Storage) CreateFeedIcon(feedID int64, icon *model.Icon) error {
|
||||
err := s.IconByHash(icon)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf(`store: unable to associate feed and icon: %v`, err)
|
||||
}
|
||||
|
||||
if icon.ID == 0 {
|
||||
err := s.CreateIcon(icon)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to create feed icon: %v`, err)
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf(`store: unable to commit transaction: %v`, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -207,7 +207,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
ntfy_api_token,
|
||||
ntfy_username,
|
||||
ntfy_password,
|
||||
ntfy_icon_url
|
||||
ntfy_icon_url,
|
||||
cubox_enabled,
|
||||
cubox_api_link
|
||||
FROM
|
||||
integrations
|
||||
WHERE
|
||||
@@ -314,6 +316,8 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
&integration.NtfyUsername,
|
||||
&integration.NtfyPassword,
|
||||
&integration.NtfyIconURL,
|
||||
&integration.CuboxEnabled,
|
||||
&integration.CuboxAPILink,
|
||||
)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
@@ -428,9 +432,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
ntfy_api_token=$95,
|
||||
ntfy_username=$96,
|
||||
ntfy_password=$97,
|
||||
ntfy_icon_url=$98
|
||||
ntfy_icon_url=$98,
|
||||
cubox_enabled=$99,
|
||||
cubox_api_link=$100
|
||||
WHERE
|
||||
user_id=$99
|
||||
user_id=$101
|
||||
`
|
||||
_, err := s.db.Exec(
|
||||
query,
|
||||
@@ -532,6 +538,8 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
integration.NtfyUsername,
|
||||
integration.NtfyPassword,
|
||||
integration.NtfyIconURL,
|
||||
integration.CuboxEnabled,
|
||||
integration.CuboxAPILink,
|
||||
integration.UserID,
|
||||
)
|
||||
|
||||
@@ -571,7 +579,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
|
||||
webhook_enabled='t' OR
|
||||
omnivore_enabled='t' OR
|
||||
raindrop_enabled='t' OR
|
||||
betula_enabled='t'
|
||||
betula_enabled='t' OR
|
||||
cubox_enabled='t'
|
||||
)
|
||||
`
|
||||
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
|
||||
|
||||
+56
-28
@@ -83,6 +83,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
|
||||
entry_swipe,
|
||||
gesture_nav,
|
||||
stylesheet,
|
||||
custom_js,
|
||||
external_font_hosts,
|
||||
google_id,
|
||||
openid_connect_id,
|
||||
display_mode,
|
||||
@@ -124,6 +126,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
|
||||
&user.EntrySwipe,
|
||||
&user.GestureNav,
|
||||
&user.Stylesheet,
|
||||
&user.CustomJS,
|
||||
&user.ExternalFontHosts,
|
||||
&user.GoogleID,
|
||||
&user.OpenIDConnectID,
|
||||
&user.DisplayMode,
|
||||
@@ -163,6 +167,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
|
||||
|
||||
// UpdateUser updates a user.
|
||||
func (s *Storage) UpdateUser(user *model.User) error {
|
||||
user.ExternalFontHosts = strings.TrimSpace(user.ExternalFontHosts)
|
||||
|
||||
if user.Password != "" {
|
||||
hashedPassword, err := crypto.HashPassword(user.Password)
|
||||
if err != nil {
|
||||
@@ -184,21 +190,23 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
entry_swipe=$11,
|
||||
gesture_nav=$12,
|
||||
stylesheet=$13,
|
||||
google_id=$14,
|
||||
openid_connect_id=$15,
|
||||
display_mode=$16,
|
||||
entry_order=$17,
|
||||
default_reading_speed=$18,
|
||||
cjk_reading_speed=$19,
|
||||
default_home_page=$20,
|
||||
categories_sorting_order=$21,
|
||||
mark_read_on_view=$22,
|
||||
mark_read_on_media_player_completion=$23,
|
||||
media_playback_rate=$24,
|
||||
block_filter_entry_rules=$25,
|
||||
keep_filter_entry_rules=$26
|
||||
custom_js=$14,
|
||||
external_font_hosts=$15,
|
||||
google_id=$16,
|
||||
openid_connect_id=$17,
|
||||
display_mode=$18,
|
||||
entry_order=$19,
|
||||
default_reading_speed=$20,
|
||||
cjk_reading_speed=$21,
|
||||
default_home_page=$22,
|
||||
categories_sorting_order=$23,
|
||||
mark_read_on_view=$24,
|
||||
mark_read_on_media_player_completion=$25,
|
||||
media_playback_rate=$26,
|
||||
block_filter_entry_rules=$27,
|
||||
keep_filter_entry_rules=$28
|
||||
WHERE
|
||||
id=$27
|
||||
id=$29
|
||||
`
|
||||
|
||||
_, err = s.db.Exec(
|
||||
@@ -216,6 +224,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
user.EntrySwipe,
|
||||
user.GestureNav,
|
||||
user.Stylesheet,
|
||||
user.CustomJS,
|
||||
user.ExternalFontHosts,
|
||||
user.GoogleID,
|
||||
user.OpenIDConnectID,
|
||||
user.DisplayMode,
|
||||
@@ -249,21 +259,23 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
entry_swipe=$10,
|
||||
gesture_nav=$11,
|
||||
stylesheet=$12,
|
||||
google_id=$13,
|
||||
openid_connect_id=$14,
|
||||
display_mode=$15,
|
||||
entry_order=$16,
|
||||
default_reading_speed=$17,
|
||||
cjk_reading_speed=$18,
|
||||
default_home_page=$19,
|
||||
categories_sorting_order=$20,
|
||||
mark_read_on_view=$21,
|
||||
mark_read_on_media_player_completion=$22,
|
||||
media_playback_rate=$23,
|
||||
block_filter_entry_rules=$24,
|
||||
keep_filter_entry_rules=$25
|
||||
custom_js=$13,
|
||||
external_font_hosts=$14,
|
||||
google_id=$15,
|
||||
openid_connect_id=$16,
|
||||
display_mode=$17,
|
||||
entry_order=$18,
|
||||
default_reading_speed=$19,
|
||||
cjk_reading_speed=$20,
|
||||
default_home_page=$21,
|
||||
categories_sorting_order=$22,
|
||||
mark_read_on_view=$23,
|
||||
mark_read_on_media_player_completion=$24,
|
||||
media_playback_rate=$25,
|
||||
block_filter_entry_rules=$26,
|
||||
keep_filter_entry_rules=$27
|
||||
WHERE
|
||||
id=$26
|
||||
id=$28
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(
|
||||
@@ -280,6 +292,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
user.EntrySwipe,
|
||||
user.GestureNav,
|
||||
user.Stylesheet,
|
||||
user.CustomJS,
|
||||
user.ExternalFontHosts,
|
||||
user.GoogleID,
|
||||
user.OpenIDConnectID,
|
||||
user.DisplayMode,
|
||||
@@ -332,6 +346,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
|
||||
gesture_nav,
|
||||
last_login_at,
|
||||
stylesheet,
|
||||
custom_js,
|
||||
external_font_hosts,
|
||||
google_id,
|
||||
openid_connect_id,
|
||||
display_mode,
|
||||
@@ -371,6 +387,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
|
||||
gesture_nav,
|
||||
last_login_at,
|
||||
stylesheet,
|
||||
custom_js,
|
||||
external_font_hosts,
|
||||
google_id,
|
||||
openid_connect_id,
|
||||
display_mode,
|
||||
@@ -410,6 +428,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
|
||||
gesture_nav,
|
||||
last_login_at,
|
||||
stylesheet,
|
||||
custom_js,
|
||||
external_font_hosts,
|
||||
google_id,
|
||||
openid_connect_id,
|
||||
display_mode,
|
||||
@@ -456,6 +476,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
|
||||
u.gesture_nav,
|
||||
u.last_login_at,
|
||||
u.stylesheet,
|
||||
u.custom_js,
|
||||
u.external_font_hosts,
|
||||
u.google_id,
|
||||
u.openid_connect_id,
|
||||
u.display_mode,
|
||||
@@ -496,6 +518,8 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
|
||||
&user.GestureNav,
|
||||
&user.LastLoginAt,
|
||||
&user.Stylesheet,
|
||||
&user.CustomJS,
|
||||
&user.ExternalFontHosts,
|
||||
&user.GoogleID,
|
||||
&user.OpenIDConnectID,
|
||||
&user.DisplayMode,
|
||||
@@ -608,6 +632,8 @@ func (s *Storage) Users() (model.Users, error) {
|
||||
gesture_nav,
|
||||
last_login_at,
|
||||
stylesheet,
|
||||
custom_js,
|
||||
external_font_hosts,
|
||||
google_id,
|
||||
openid_connect_id,
|
||||
display_mode,
|
||||
@@ -649,6 +675,8 @@ func (s *Storage) Users() (model.Users, error) {
|
||||
&user.GestureNav,
|
||||
&user.LastLoginAt,
|
||||
&user.Stylesheet,
|
||||
&user.CustomJS,
|
||||
&user.ExternalFontHosts,
|
||||
&user.GoogleID,
|
||||
&user.OpenIDConnectID,
|
||||
&user.DisplayMode,
|
||||
|
||||
@@ -56,6 +56,9 @@ func (f *funcMap) Map() template.FuncMap {
|
||||
"safeCSS": func(str string) template.CSS {
|
||||
return template.CSS(str)
|
||||
},
|
||||
"safeJS": func(str string) template.JS {
|
||||
return template.JS(str)
|
||||
},
|
||||
"noescape": func(str string) template.HTML {
|
||||
return template.HTML(str)
|
||||
},
|
||||
|
||||
@@ -34,12 +34,19 @@
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
|
||||
|
||||
{{ if and .user .user.Stylesheet }}
|
||||
{{ $stylesheetNonce := nonce }}
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; style-src 'self' 'nonce-{{ $stylesheetNonce }}'; require-trusted-types-for 'script'; trusted-types ttpolicy;">
|
||||
<style nonce="{{ $stylesheetNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
|
||||
{{ if .user }}
|
||||
{{ $cspNonce := nonce }}
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types ttpolicy;">
|
||||
|
||||
{{ if .user.Stylesheet }}
|
||||
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
|
||||
{{ end }}
|
||||
|
||||
{{ if .user.CustomJS }}
|
||||
<script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
|
||||
{{ end }}
|
||||
|
||||
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script>
|
||||
|
||||
@@ -41,10 +41,23 @@
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryEntriesAll" "categoryID" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryEntriesStarred" "categoryID" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
|
||||
</li>
|
||||
{{ else if .showOnlyStarredEntries }}
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryEntriesAll" "categoryID" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
|
||||
</li>
|
||||
{{ else }}
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryEntriesStarred" "categoryID" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li>
|
||||
<a class="page-link" href="{{ route "categoryFeeds" "categoryID" .category.ID }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
|
||||
|
||||
@@ -165,56 +165,55 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<article class="entry-content gesture-nav-{{ $.user.GestureNav }}" dir="auto">
|
||||
{{ if (and .entry.Enclosures (not .entry.Feed.NoMediaPlayer)) }}
|
||||
{{ range .entry.Enclosures }}
|
||||
{{ if ne .URL "" }}
|
||||
{{ if hasPrefix .MimeType "audio/" }}
|
||||
<div class="enclosure-audio" >
|
||||
<audio controls preload="metadata"
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata"
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ if not .entry.Feed.NoMediaPlayer }}
|
||||
{{ $mediaPlayerEnclosure := .entry.Enclosures.FindMediaPlayerEnclosure }}
|
||||
|
||||
{{ if $mediaPlayerEnclosure }}
|
||||
{{ with $mediaPlayerEnclosure }}
|
||||
{{ if .IsAudio }}
|
||||
<div class="enclosure-audio" >
|
||||
<audio controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{ .ID }}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if .IsVideo }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{ .ID }}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{end}}
|
||||
{{ if .user }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .user }}
|
||||
{{ noescape (proxyFilter .entry.Content) }}
|
||||
{{ else }}
|
||||
{{ else }}
|
||||
{{ noescape .entry.Content }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ if .entry.Enclosures }}
|
||||
<details class="entry-enclosures">
|
||||
@@ -222,45 +221,7 @@
|
||||
{{ range .entry.Enclosures }}
|
||||
{{ if ne .URL "" }}
|
||||
<div class="entry-enclosure">
|
||||
{{ if hasPrefix .MimeType "audio/" }}
|
||||
<div class="enclosure-audio">
|
||||
<audio controls preload="metadata"
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata"
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "image/" }}
|
||||
{{ if .IsImage }}
|
||||
<div class="enclosure-image">
|
||||
{{ if (and $.user (mustBeProxyfied "image")) }}
|
||||
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
|
||||
@@ -271,7 +232,7 @@
|
||||
{{ end }}
|
||||
|
||||
<div class="entry-enclosure-download">
|
||||
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
|
||||
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
|
||||
<small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,22 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.CuboxEnabled }}open{{ end }}>
|
||||
<summary>Cubox</summary>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="cubox_enabled" value="1" {{ if .form.CuboxEnabled }}checked{{ end }}> {{ t "form.integration.cubox_activate" }}
|
||||
</label>
|
||||
|
||||
<label for="form-cubox-api-link">{{ t "form.integration.cubox_api_link" }}</label>
|
||||
<input type="url" name="cubox_api_link" id="form-cubox-api-link" value="{{ .form.CuboxAPILink }}" placeholder="https://cubox.pro/c/api/save/xxx" 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.EspialEnabled }}open{{ end }}>
|
||||
<summary>Espial</summary>
|
||||
<div class="form-section">
|
||||
|
||||
@@ -24,16 +24,28 @@
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ if and (not disableLocalAuth) (.webAuthnEnabled) }}
|
||||
<hr>
|
||||
{{ end }}
|
||||
{{ if .webAuthnEnabled }}
|
||||
<div class="webauthn">
|
||||
<div role="alert" class="alert alert-error hidden" id="webauthn-error">
|
||||
{{ t "page.login.webauthn_login.error" }}
|
||||
</div>
|
||||
<template id="webauthn-error">
|
||||
<div role="alert" class="alert alert-error" id="webauthn-error-alert">
|
||||
<h4>{{ t "page.login.webauthn_login.error" }}</h4>
|
||||
<p id="webauthn-error-message"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="buttons">
|
||||
<button class="button button-primary" id="webauthn-login" disabled>{{ t "page.login.webauthn_login" }}</button>
|
||||
</div>
|
||||
<div class="form-help">
|
||||
<p>{{ t "page.login.webauthn_login.help" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if and (.webAuthnEnabled) (or (hasOAuth2Provider "google") (hasOAuth2Provider "oidc")) }}
|
||||
<hr>
|
||||
{{ end }}
|
||||
{{ if hasOAuth2Provider "google" }}
|
||||
<div class="oauth2">
|
||||
<a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "page.login.google_signin" }}</a>
|
||||
|
||||
@@ -210,6 +210,13 @@
|
||||
<label for="form-custom-css">{{t "form.prefs.label.custom_css" }}</label>
|
||||
<textarea id="form-custom-css" name="custom_css" cols="40" rows="10" spellcheck="false">{{ .form.CustomCSS }}</textarea>
|
||||
|
||||
<label for="form-external-font-hosts">{{t "form.prefs.label.external_font_hosts" }}</label>
|
||||
<input type="text" id="form-external-font-hosts" name="external_font_hosts" spellcheck="false" value="{{ .form.ExternalFontHosts }}">
|
||||
<div class="form-help">{{t "form.prefs.help.external_font_hosts" }}</div>
|
||||
|
||||
<label for="form-custom-js">{{t "form.prefs.label.custom_js" }}</label>
|
||||
<textarea id="form-custom-js" name="custom_js" cols="40" rows="10" spellcheck="false">{{ .form.CustomJS }}</textarea>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
{{ if not .entries }}
|
||||
<p role="alert" class="alert alert-info">{{ t "alert.no_shared_entry" }}</p>
|
||||
{{ else }}
|
||||
<div class="pagination-top">
|
||||
{{ template "pagination" .pagination }}
|
||||
</div>
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article
|
||||
@@ -82,6 +85,9 @@
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="pagination-bottom">
|
||||
{{ template "pagination" .pagination }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
categoryID := request.RouteInt64Param(r, "categoryID")
|
||||
category, err := h.store.Category(request.UserID(r), categoryID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if category == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStarred(true)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("category", category)
|
||||
view.Set("total", count)
|
||||
view.Set("entries", entries)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "categoryEntriesStarred", "categoryID", category.ID), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "categories")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("showOnlyStarredEntries", true)
|
||||
|
||||
html.OK(w, r, view.Render("category_entries"))
|
||||
}
|
||||
@@ -110,6 +110,8 @@ type IntegrationForm struct {
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
}
|
||||
|
||||
// Merge copy form values to the model.
|
||||
@@ -209,6 +211,8 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
|
||||
integration.NtfyUsername = i.NtfyUsername
|
||||
integration.NtfyPassword = i.NtfyPassword
|
||||
integration.NtfyIconURL = i.NtfyIconURL
|
||||
integration.CuboxEnabled = i.CuboxEnabled
|
||||
integration.CuboxAPILink = i.CuboxAPILink
|
||||
}
|
||||
|
||||
// NewIntegrationForm returns a new IntegrationForm.
|
||||
@@ -311,6 +315,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
|
||||
NtfyUsername: r.FormValue("ntfy_username"),
|
||||
NtfyPassword: r.FormValue("ntfy_password"),
|
||||
NtfyIconURL: r.FormValue("ntfy_icon_url"),
|
||||
CuboxEnabled: r.FormValue("cubox_enabled") == "1",
|
||||
CuboxAPILink: r.FormValue("cubox_api_link"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
// MarkReadBehavior list all possible behaviors for automatically marking an entry as read
|
||||
@@ -36,6 +37,8 @@ type SettingsForm struct {
|
||||
KeyboardShortcuts bool
|
||||
ShowReadingTime bool
|
||||
CustomCSS string
|
||||
CustomJS string
|
||||
ExternalFontHosts string
|
||||
EntrySwipe bool
|
||||
GestureNav string
|
||||
DisplayMode string
|
||||
@@ -99,6 +102,8 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
|
||||
user.KeyboardShortcuts = s.KeyboardShortcuts
|
||||
user.ShowReadingTime = s.ShowReadingTime
|
||||
user.Stylesheet = s.CustomCSS
|
||||
user.CustomJS = s.CustomJS
|
||||
user.ExternalFontHosts = s.ExternalFontHosts
|
||||
user.EntrySwipe = s.EntrySwipe
|
||||
user.GestureNav = s.GestureNav
|
||||
user.DisplayMode = s.DisplayMode
|
||||
@@ -146,6 +151,12 @@ func (s *SettingsForm) Validate() *locale.LocalizedError {
|
||||
return locale.NewLocalizedError("error.settings_media_playback_rate_range")
|
||||
}
|
||||
|
||||
if s.ExternalFontHosts != "" {
|
||||
if !validator.IsValidDomainList(s.ExternalFontHosts) {
|
||||
return locale.NewLocalizedError("error.settings_invalid_domain_list")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -180,6 +191,8 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
|
||||
KeyboardShortcuts: r.FormValue("keyboard_shortcuts") == "1",
|
||||
ShowReadingTime: r.FormValue("show_reading_time") == "1",
|
||||
CustomCSS: r.FormValue("custom_css"),
|
||||
CustomJS: r.FormValue("custom_js"),
|
||||
ExternalFontHosts: r.FormValue("external_font_hosts"),
|
||||
EntrySwipe: r.FormValue("entry_swipe") == "1",
|
||||
GestureNav: r.FormValue("gesture_nav"),
|
||||
DisplayMode: r.FormValue("display_mode"),
|
||||
|
||||
@@ -124,6 +124,8 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
|
||||
NtfyUsername: integration.NtfyUsername,
|
||||
NtfyPassword: integration.NtfyPassword,
|
||||
NtfyIconURL: integration.NtfyIconURL,
|
||||
CuboxEnabled: integration.CuboxEnabled,
|
||||
CuboxAPILink: integration.CuboxAPILink,
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
+22
-9
@@ -8,9 +8,11 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/reader/rewrite"
|
||||
)
|
||||
|
||||
func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -55,23 +58,23 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(string(decodedURL))
|
||||
parsedMediaURL, err := url.Parse(string(decodedURL))
|
||||
if err != nil {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
if parsedMediaURL.Scheme != "http" && parsedMediaURL.Scheme != "https" {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
if parsedMediaURL.Host == "" {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if !u.IsAbs() {
|
||||
if !parsedMediaURL.IsAbs() {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
@@ -87,13 +90,16 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Note: User-Agent HTTP header is omitted to avoid being blocked by bot protection mechanisms.
|
||||
req.Header.Add("Connection", "close")
|
||||
req.Header.Set("Connection", "close")
|
||||
|
||||
forwardedRequestHeader := []string{"Range", "Accept", "Accept-Encoding"}
|
||||
if referer := rewrite.GetRefererForURL(mediaURL); referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
|
||||
forwardedRequestHeader := []string{"Range", "Accept", "Accept-Encoding", "User-Agent"}
|
||||
for _, requestHeaderName := range forwardedRequestHeader {
|
||||
if r.Header.Get(requestHeaderName) != "" {
|
||||
req.Header.Add(requestHeaderName, r.Header.Get(requestHeaderName))
|
||||
req.Header.Set(requestHeaderName, r.Header.Get(requestHeaderName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +134,9 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
slog.String("media_url", mediaURL),
|
||||
slog.Int("status_code", resp.StatusCode),
|
||||
)
|
||||
html.NotFound(w, r)
|
||||
|
||||
// Forward the status code from the origin.
|
||||
http.Error(w, fmt.Sprintf("Origin status code is %d", resp.StatusCode), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,6 +146,11 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
b.WithStatus(resp.StatusCode)
|
||||
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
b.WithHeader("Content-Type", resp.Header.Get("Content-Type"))
|
||||
|
||||
if filename := path.Base(parsedMediaURL.Path); filename != "" {
|
||||
b.WithHeader("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename))
|
||||
}
|
||||
|
||||
forwardedResponseHeader := []string{"Content-Encoding", "Content-Type", "Content-Length", "Accept-Ranges", "Content-Range"}
|
||||
for _, responseHeaderName := range forwardedResponseHeader {
|
||||
if resp.Header.Get(responseHeaderName) != "" {
|
||||
|
||||
@@ -33,6 +33,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
KeyboardShortcuts: user.KeyboardShortcuts,
|
||||
ShowReadingTime: user.ShowReadingTime,
|
||||
CustomCSS: user.Stylesheet,
|
||||
CustomJS: user.CustomJS,
|
||||
ExternalFontHosts: user.ExternalFontHosts,
|
||||
EntrySwipe: user.EntrySwipe,
|
||||
GestureNav: user.GestureNav,
|
||||
DisplayMode: user.DisplayMode,
|
||||
|
||||
@@ -85,6 +85,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
MediaPlaybackRate: model.OptionalNumber(settingsForm.MediaPlaybackRate),
|
||||
BlockFilterEntryRules: model.OptionalString(settingsForm.BlockFilterEntryRules),
|
||||
KeepFilterEntryRules: model.OptionalString(settingsForm.KeepFilterEntryRules),
|
||||
ExternalFontHosts: model.OptionalString(settingsForm.ExternalFontHosts),
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
@@ -19,9 +20,12 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithShareCodeNotEmpty()
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
@@ -39,6 +43,7 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entries", entries)
|
||||
view.Set("total", count)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "sharedEntries"), count, offset, user.EntriesPerPage))
|
||||
view.Set("menu", "history")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
|
||||
@@ -427,7 +427,6 @@ input[type="number"] {
|
||||
line-height: 20px;
|
||||
width: 250px;
|
||||
font-size: 99%;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
appearance: none;
|
||||
}
|
||||
@@ -448,7 +447,8 @@ input[type="number"]:focus {
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-bottom: 15px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -675,6 +675,10 @@ template {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.webauthn {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Counters */
|
||||
.unread-counter-wrapper,
|
||||
.error-feeds-counter-wrapper {
|
||||
|
||||
@@ -5,10 +5,20 @@ class WebAuthnHandler {
|
||||
|
||||
static showErrorMessage(errorMessage) {
|
||||
console.log("webauthn error: " + errorMessage);
|
||||
const alertElement = document.getElementById("webauthn-error");
|
||||
|
||||
const alertElement = document.getElementById("webauthn-error-alert");
|
||||
if (alertElement) {
|
||||
alertElement.textContent += " (" + errorMessage + ")";
|
||||
alertElement.classList.remove("hidden");
|
||||
alertElement.remove();
|
||||
}
|
||||
|
||||
const alertTemplateElement = document.getElementById("webauthn-error");
|
||||
if (alertTemplateElement) {
|
||||
const clonedElement = alertTemplateElement.content.cloneNode(true);
|
||||
const errorMessageElement = clonedElement.getElementById("webauthn-error-message");
|
||||
if (errorMessageElement) {
|
||||
errorMessageElement.textContent = errorMessage;
|
||||
}
|
||||
alertTemplateElement.parentNode.insertBefore(clonedElement, alertTemplateElement);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
@@ -12,10 +13,11 @@ import (
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
|
||||
"mvdan.cc/xurls/v2"
|
||||
)
|
||||
|
||||
// Best effort url extraction regexp
|
||||
var urlRe = regexp.MustCompile(`(?i)(?:https?://)?[0-9a-z.]+[.][a-z]+(?::[0-9]+)?(?:/[^ ]+|/)?`)
|
||||
|
||||
func (h *handler) bookmarklet(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
@@ -39,7 +41,7 @@ func (h *handler) bookmarklet(w http.ResponseWriter, r *http.Request) {
|
||||
// See https://bugs.chromium.org/p/chromium/issues/detail?id=789379.
|
||||
text := request.QueryStringParam(r, "text", "")
|
||||
if text != "" && bookmarkletURL == "" {
|
||||
bookmarkletURL = xurls.Relaxed().FindString(text)
|
||||
bookmarkletURL = urlRe.FindString(text)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
@@ -88,6 +88,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
uiRouter.HandleFunc("/category/{categoryID}/entries", handler.showCategoryEntriesPage).Name("categoryEntries").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/entries/refresh", handler.refreshCategoryEntriesPage).Name("refreshCategoryEntriesPage").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/entries/all", handler.showCategoryEntriesAllPage).Name("categoryEntriesAll").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/entries/starred", handler.showCategoryEntriesStarredPage).Name("categoryEntriesStarred").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/edit", handler.showEditCategoryPage).Name("editCategory").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/update", handler.updateCategory).Name("updateCategory").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
|
||||
|
||||
+47
-11
@@ -65,7 +65,7 @@ func newWebAuthn() (*webauthn.WebAuthn, error) {
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: "Miniflux",
|
||||
RPID: url.Hostname(),
|
||||
RPOrigin: config.Opts.RootURL(),
|
||||
RPOrigins: []string{config.Opts.RootURL()},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,6 +206,15 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("WebAuthn: parsed response flags",
|
||||
slog.Bool("user_present", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()),
|
||||
slog.Bool("user_verified", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()),
|
||||
slog.Bool("has_attested_credential_data", parsedResponse.Response.AuthenticatorData.Flags.HasAttestedCredentialData()),
|
||||
slog.Bool("has_backup_eligible", parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()),
|
||||
slog.Bool("has_backup_state", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()),
|
||||
)
|
||||
|
||||
sessionData := request.WebAuthnSessionData(r)
|
||||
|
||||
var user *model.User
|
||||
@@ -218,34 +227,54 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
var cred *model.WebAuthnCredential
|
||||
var matchingCredential *model.WebAuthnCredential
|
||||
if user != nil {
|
||||
creds, err := h.store.WebAuthnCredentialsByUserID(user.ID)
|
||||
storedCredentials, err := h.store.WebAuthnCredentialsByUserID(user.ID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sessionData.SessionData.UserID = parsedResponse.Response.UserHandle
|
||||
credCredential, err := web.ValidateLogin(WebAuthnUser{user, parsedResponse.Response.UserHandle, creds}, *sessionData.SessionData, parsedResponse)
|
||||
webAuthUser := WebAuthnUser{user, parsedResponse.Response.UserHandle, storedCredentials}
|
||||
|
||||
// Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.
|
||||
// This workaround set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.
|
||||
// See https://github.com/go-webauthn/webauthn/pull/240
|
||||
for index := range webAuthUser.Credentials {
|
||||
webAuthUser.Credentials[index].Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
|
||||
}
|
||||
|
||||
for _, webAuthCredential := range webAuthUser.WebAuthnCredentials() {
|
||||
slog.Debug("WebAuthn: stored credential flags",
|
||||
slog.Bool("user_present", webAuthCredential.Flags.UserPresent),
|
||||
slog.Bool("user_verified", webAuthCredential.Flags.UserVerified),
|
||||
slog.Bool("backup_eligible", webAuthCredential.Flags.BackupEligible),
|
||||
slog.Bool("backup_state", webAuthCredential.Flags.BackupState),
|
||||
)
|
||||
}
|
||||
|
||||
credCredential, err := web.ValidateLogin(webAuthUser, *sessionData.SessionData, parsedResponse)
|
||||
if err != nil {
|
||||
slog.Warn("WebAuthn: ValidateLogin failed", slog.Any("error", err))
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
for _, credTest := range creds {
|
||||
if bytes.Equal(credCredential.ID, credTest.Credential.ID) {
|
||||
cred = &credTest
|
||||
for _, storedCredential := range storedCredentials {
|
||||
if bytes.Equal(credCredential.ID, storedCredential.Credential.ID) {
|
||||
matchingCredential = &storedCredential
|
||||
}
|
||||
}
|
||||
|
||||
if cred == nil {
|
||||
if matchingCredential == nil {
|
||||
json.ServerError(w, r, fmt.Errorf("no matching credential for %v", credCredential))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
var uid int64
|
||||
uid, cred, err = h.store.WebAuthnCredentialByHandle(userHandle)
|
||||
uid, matchingCredential, err = h.store.WebAuthnCredentialByHandle(userHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -259,11 +288,18 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("no user found for handle %x", userHandle)
|
||||
}
|
||||
return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*cred}}, nil
|
||||
|
||||
// Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.
|
||||
// This workaround set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.
|
||||
// See https://github.com/go-webauthn/webauthn/pull/240
|
||||
matchingCredential.Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
|
||||
|
||||
return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*matchingCredential}}, nil
|
||||
}
|
||||
|
||||
_, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse)
|
||||
if err != nil {
|
||||
slog.Warn("WebAuthn: ValidateDiscoverableLogin failed", slog.Any("error", err))
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
@@ -275,7 +311,7 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.store.WebAuthnSaveLogin(cred.Handle)
|
||||
h.store.WebAuthnSaveLogin(matchingCredential.Handle)
|
||||
|
||||
slog.Info("User authenticated successfully with webauthn",
|
||||
slog.Bool("authentication_successful", true),
|
||||
|
||||
@@ -123,6 +123,12 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
|
||||
}
|
||||
}
|
||||
|
||||
if changes.ExternalFontHosts != nil {
|
||||
if !IsValidDomainList(*changes.ExternalFontHosts) {
|
||||
return locale.NewLocalizedError("error.settings_invalid_domain_list")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
// ValidateRange makes sure the offset/limit values are valid.
|
||||
func ValidateRange(offset, limit int) error {
|
||||
if offset < 0 {
|
||||
@@ -43,3 +46,24 @@ func IsValidURL(absoluteURL string) bool {
|
||||
_, err := url.ParseRequestURI(absoluteURL)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func IsValidDomain(domain string) bool {
|
||||
domain = strings.ToLower(domain)
|
||||
|
||||
if len(domain) < 1 || len(domain) > 253 {
|
||||
return false
|
||||
}
|
||||
|
||||
return domainRegex.MatchString(domain)
|
||||
}
|
||||
|
||||
func IsValidDomainList(value string) bool {
|
||||
domains := strings.Split(strings.TrimSpace(value), " ")
|
||||
for _, domain := range domains {
|
||||
if !IsValidDomain(domain) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -59,3 +59,21 @@ func TestIsValidRegex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidDomain(t *testing.T) {
|
||||
scenarios := map[string]bool{
|
||||
"example.org": true,
|
||||
"example": false,
|
||||
"example.": false,
|
||||
"example..": false,
|
||||
"mail.example.com:443": false,
|
||||
"*.example.com": false,
|
||||
}
|
||||
|
||||
for domain, expected := range scenarios {
|
||||
result := IsValidDomain(domain)
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
.\" Manpage for miniflux.
|
||||
.TH "MINIFLUX" "1" "August 18, 2024" "\ \&" "\ \&"
|
||||
.TH "MINIFLUX" "1" "October 26, 2024" "\ \&" "\ \&"
|
||||
|
||||
.SH NAME
|
||||
miniflux \- Minimalist and opinionated feed reader
|
||||
@@ -546,7 +546,7 @@ Enabled by default\&.
|
||||
.B WEBAUTHN
|
||||
Enable or disable WebAuthn/Passkey authentication\&.
|
||||
.br
|
||||
Note: After activating and setting up your Passkey, just enter your username and click the Passkey login button\&.
|
||||
You must provide a username on the login page if your are using non-residential keys. However, this is not required for discoverable credentials\&.
|
||||
.br
|
||||
Default is disabled\&.
|
||||
.TP
|
||||
|
||||
Reference in New Issue
Block a user