Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d345c87376 | |||
| bd91e5f320 | |||
| 276b2d8b0b | |||
| bca9bea676 | |||
| 7346d751cc | |||
| 7939b54341 | |||
| a06657b74d | |||
| 2df59b4865 | |||
| 777d0dd248 | |||
| cfda948c3a | |||
| 14a6e8ed3a | |||
| c3e842eba6 | |||
| fd9cfd757a | |||
| 945d436055 | |||
| c3649bd6b1 | |||
| 6ad5ad0bb2 | |||
| 113abeea59 | |||
| e6185b1393 | |||
| 1b0b8b9c42 | |||
| 68448b4abb | |||
| 3caa16ac31 | |||
| 637fb85de0 | |||
| 02c6d14659 | |||
| 728423339a | |||
| eed3fcf92a | |||
| d5cfcf8956 | |||
| dea46ac0ea | |||
| 30c44380e0 | |||
| a913f3f75f | |||
| c1ef986cab | |||
| 2671f57edd | |||
| 2f56ebd3a6 | |||
| 059f5c0905 | |||
| 58178d90cb | |||
| cc885bbabb | |||
| 0e185849b4 | |||
| d0984f29da | |||
| 902ca63c45 | |||
| 2314500515 | |||
| 787d373211 | |||
| fefbf2c935 | |||
| bfb429b919 | |||
| 331c831c23 | |||
| 92a49d7e69 | |||
| 8cdf76df69 | |||
| 7bc0bffd85 | |||
| 3a18e5d205 | |||
| c3ca603960 | |||
| 7e2b50efee | |||
| b61ee15c1b | |||
| b7b0ccbf4b | |||
| 9c5228b2ee | |||
| 53beec685b | |||
| b1fb8be185 | |||
| 3a966c6ce5 | |||
| b2e702218d | |||
| 30c2e09a56 | |||
| c6c71c58b8 | |||
| 6eb1f25a53 | |||
| f0fe91172f | |||
| c3016a4c55 | |||
| 3a028a0669 | |||
| 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 |
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
labels: wishlist
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Do you follow the guidelines?
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] There is no breaking changes
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] I really tested my changes and there is no regression
|
||||
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
|
||||
@@ -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,103 @@
|
||||
Version 2.2.4 (December 20, 2024)
|
||||
---------------------------------
|
||||
|
||||
* test(rewrite): add unit test for referer rewrite function
|
||||
* refactor(subscription): use `strings.HasSuffix` instead of a regex in `FindSubscriptionsFromYouTubePlaylistPage`
|
||||
* refactor(sanitizer): use `token.String()` instead of `html.EscapeString(token.Data)`
|
||||
* refactor(sanitizer): simplify `isValidTag`
|
||||
* refactor(sanitizer): simplify `hasRequiredAttributes`
|
||||
* refactor(sanitizer): remove condition because `config.Opts` is guaranteed to never be nil
|
||||
* refactor(sanitizer): remove a now-useless function after refactoring
|
||||
* refactor(sanitizer): refactor conditions to highlight their similitude, enabling further refactoring
|
||||
* refactor(sanitizer): optimize `strip_tags.go`
|
||||
* refactor(sanitizer): micro-optimizations of `srcset.go`
|
||||
* refactor(sanitizer): merge two conditions
|
||||
* refactor(sanitizer): inline a function in `sanitizeAttributes` and fix a bug in it
|
||||
* refactor(sanitizer): inline a condition in `sanitizeSrcsetAttr`
|
||||
* refactor(sanitizer): improve `rewriteIframeURL()`
|
||||
* refactor(sanitizer): Google+ isn't a thing anymore
|
||||
* refactor(sanitizer): change the scope of a variable
|
||||
* refactor(rewriter): replace regex with URL parsing for referrer override
|
||||
* refactor(rewriter): avoid the use of regex in `addDynamicImage`
|
||||
* refactor(rewrite): remove unused function arguments
|
||||
* refactor(readability): various improvements and optimizations
|
||||
* refactor(readability): simplify the regexes in `readability.go`
|
||||
* refactor(processor): use URL parsing instead of a regex
|
||||
* refactor(processor): improve the `rewrite` URL rule regex
|
||||
* refactor(locale): delay parsing of translations until they're used
|
||||
* refactor(js): factorise a line in `app.js`
|
||||
* refactor(handler): delay `store.UserByID()` as much as possible
|
||||
* refactor(css): replace `-ms-text-size-adjust` with `text-size-adjust`
|
||||
* refactor(css): remove `-webkit-clip-path`
|
||||
* refactor(css): factorise `.pagination-next` and `.pagination-last` together
|
||||
* refactor: use a better construct than `doc.Find(…).First()`
|
||||
* refactor: use `min/max` instead of `math.Min/math.Max`
|
||||
* refactor: refactor `internal/reader/readability/testdata`
|
||||
* refactor: optimize `sanitizeAttributes`
|
||||
* refactor: get rid of `numberOfPluralFormsPerLanguage` test-only variable
|
||||
* fix(storage): replace timezone function call with view
|
||||
* fix(consistency): align feed modification behavior between API and UI
|
||||
* fix(ci): fix grammar in pull-request template
|
||||
* fix: load icon from site URL instead of feed URL
|
||||
* fix: feed icon from xml ignored during force refresh
|
||||
* feat(rewrite)!: remove `parse_markdown` rewrite rule
|
||||
* feat(mediaproxy): update predefined referer spoofing rules for restricted media resources
|
||||
* feat(locale): update translations to clarify readeck URL instead of readeck API endpoint
|
||||
* feat(locale): update German translations
|
||||
* feat(locale): update Chinese translations
|
||||
* feat(apprise): update `SendNotification` to handle multiple entries and add logging
|
||||
* feat(apprise): add title in notification request body
|
||||
* feat: resize favicons before storing them in the database
|
||||
* feat: optionally fetch watch time from YouTube API instead of website
|
||||
* feat: only show the commit URL if it's not empty on `/about`
|
||||
* feat: add predefined scraper rules for `arstechnica.com`
|
||||
* feat: add date-based entry filtering rules
|
||||
* chore: remove `blog.laravel.com` rewrite rule
|
||||
* build(deps): bump `library/alpine` in `/packaging/docker/alpine` to `3.21`
|
||||
* build(deps): bump `golang.org/x/term` from `0.26.0` to `0.27.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.31.0` to `0.33.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.30.0` to `0.31.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.1` to `2.21.2`
|
||||
|
||||
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)
|
||||
----------------------------------
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,20 +5,19 @@ module miniflux.app/v2
|
||||
require (
|
||||
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.11.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.20.4
|
||||
github.com/tdewolff/minify/v2 v2.20.37
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/net v0.29.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/term v0.24.0
|
||||
golang.org/x/text v0.18.0
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/tdewolff/minify/v2 v2.21.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -40,9 +39,9 @@ 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.19 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbav
|
||||
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=
|
||||
@@ -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.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/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.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
|
||||
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19/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=
|
||||
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.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.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.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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() {
|
||||
|
||||
@@ -121,6 +121,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
originalFeed.ResetErrorCounter()
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/database"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/static"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -153,10 +152,6 @@ func Parse() {
|
||||
slog.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
if err := locale.LoadCatalogMessages(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to load translations: %v", err))
|
||||
}
|
||||
|
||||
if err := static.CalculateBinaryFileChecksums(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
|
||||
}
|
||||
|
||||
@@ -2116,6 +2116,24 @@ func TestFetchYouTubeWatchTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeApiKey(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
|
||||
result := opts.YouTubeApiKey()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedUrlOverride(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
@@ -60,6 +60,7 @@ const (
|
||||
defaultFetchNebulaWatchTime = false
|
||||
defaultFetchOdyseeWatchTime = false
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultYouTubeApiKey = ""
|
||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||
defaultCreateAdmin = false
|
||||
defaultAdminUsername = ""
|
||||
@@ -149,6 +150,7 @@ type Options struct {
|
||||
fetchOdyseeWatchTime bool
|
||||
fetchYouTubeWatchTime bool
|
||||
filterEntryMaxAgeDays int
|
||||
youTubeApiKey string
|
||||
youTubeEmbedUrlOverride string
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
@@ -228,6 +230,7 @@ func NewOptions() *Options {
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
youTubeApiKey: defaultYouTubeApiKey,
|
||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
|
||||
oauth2ClientID: defaultOAuth2ClientID,
|
||||
@@ -503,6 +506,11 @@ func (o *Options) FetchYouTubeWatchTime() bool {
|
||||
return o.fetchYouTubeWatchTime
|
||||
}
|
||||
|
||||
// YouTubeApiKey returns the YouTube API key if defined.
|
||||
func (o *Options) YouTubeApiKey() string {
|
||||
return o.youTubeApiKey
|
||||
}
|
||||
|
||||
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
|
||||
func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||
return o.youTubeEmbedUrlOverride
|
||||
@@ -733,6 +741,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"SERVER_TIMING_HEADER": o.serverTimingHeader,
|
||||
"WATCHDOG": o.watchdog,
|
||||
"WORKER_POOL_SIZE": o.workerPoolSize,
|
||||
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
|
||||
"YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride,
|
||||
"WEBAUTHN": o.webAuthn,
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -26,42 +27,53 @@ func NewClient(serviceURL, baseURL string) *Client {
|
||||
return &Client{serviceURL, baseURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendNotification(entry *model.Entry) error {
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
if c.baseURL == "" || c.servicesURL == "" {
|
||||
return fmt.Errorf("apprise: missing base URL or services URL")
|
||||
}
|
||||
|
||||
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"urls": c.servicesURL,
|
||||
"body": message,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to encode request body: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"urls": c.servicesURL,
|
||||
"body": message,
|
||||
"title": feed.Title,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to create request: %v", err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
slog.Debug("Sending Apprise notification",
|
||||
slog.String("apprise_url", c.baseURL),
|
||||
slog.String("services_url", c.servicesURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("body", message),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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),
|
||||
@@ -493,8 +513,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.AppriseEnabled {
|
||||
slog.Debug("Sending new entries to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
appriseServiceURLs := userIntegrations.AppriseServicesURL
|
||||
if feed.AppriseServiceURLs != "" {
|
||||
appriseServiceURLs = feed.AppriseServiceURLs
|
||||
}
|
||||
|
||||
client := apprise.NewClient(
|
||||
appriseServiceURLs,
|
||||
userIntegrations.AppriseURL,
|
||||
)
|
||||
|
||||
if err := client.SendNotification(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
@@ -521,35 +563,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.AppriseEnabled {
|
||||
slog.Debug("Sending a new entry to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("apprise_url", userIntegrations.AppriseURL),
|
||||
)
|
||||
|
||||
appriseServiceURLs := userIntegrations.AppriseServicesURL
|
||||
if feed.AppriseServiceURLs != "" {
|
||||
appriseServiceURLs = feed.AppriseServiceURLs
|
||||
}
|
||||
|
||||
client := apprise.NewClient(
|
||||
appriseServiceURLs,
|
||||
userIntegrations.AppriseURL,
|
||||
)
|
||||
|
||||
if err := client.SendNotification(entry); err != nil {
|
||||
slog.Error("Unable to send entry to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("apprise_url", userIntegrations.AppriseURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -12,17 +12,26 @@ import (
|
||||
type translationDict map[string]interface{}
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog catalog
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
|
||||
//go:embed translations/*.json
|
||||
var translationFiles embed.FS
|
||||
|
||||
func GetTranslationDict(language string) (translationDict, error) {
|
||||
if _, ok := defaultCatalog[language]; !ok {
|
||||
var err error
|
||||
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return defaultCatalog[language], nil
|
||||
}
|
||||
|
||||
// LoadCatalogMessages loads and parses all translations encoded in JSON.
|
||||
func LoadCatalogMessages() error {
|
||||
var err error
|
||||
defaultCatalog = make(catalog, len(AvailableLanguages()))
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
defaultCatalog[language], err = loadTranslationFile(language)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestLoadCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKeysHaveValue(t *testing.T) {
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
@@ -71,7 +71,7 @@ func TestMissingTranslations(t *testing.T) {
|
||||
t.Fatal(`Unable to parse reference language`)
|
||||
}
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
if language == refLang {
|
||||
continue
|
||||
}
|
||||
@@ -90,7 +90,27 @@ func TestMissingTranslations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
for language := range AvailableLanguages() {
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
|
||||
+20
-43
@@ -3,47 +3,24 @@
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the list of available languages.
|
||||
func AvailableLanguages() map[string]string {
|
||||
return map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
}
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
import "testing"
|
||||
|
||||
func TestAvailableLanguages(t *testing.T) {
|
||||
results := AvailableLanguages()
|
||||
results := AvailableLanguages
|
||||
for k, v := range results {
|
||||
if k == "" {
|
||||
t.Errorf(`Empty language key detected`)
|
||||
|
||||
+19
-14
@@ -11,9 +11,11 @@ type Printer struct {
|
||||
}
|
||||
|
||||
func (p *Printer) Print(key string) string {
|
||||
if str, ok := defaultCatalog[p.language][key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict[key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
}
|
||||
}
|
||||
}
|
||||
return key
|
||||
@@ -21,16 +23,16 @@ func (p *Printer) Print(key string) string {
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
var translation string
|
||||
translation := key
|
||||
|
||||
str, found := defaultCatalog[p.language][key]
|
||||
if !found {
|
||||
translation = key
|
||||
} else {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
str, found := dict[key]
|
||||
if found {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +41,12 @@ func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
choices, found := defaultCatalog[p.language][key]
|
||||
dict, err := GetTranslationDict(p.language)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
if found {
|
||||
if choices, found := dict[key]; found {
|
||||
var plurals []string
|
||||
|
||||
switch v := choices.(type) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Zum Inhalt springen",
|
||||
"confirm.question": "Sind Sie sicher?",
|
||||
"confirm.question.refresh": "Möchten Sie eine erzwungene Aktualisierung durchführen?",
|
||||
"confirm.yes": "ja",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
|
||||
"tooltip.logged_user": "Angemeldet als %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Startseite",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.starred": "Lesezeichen",
|
||||
"menu.history": "Verlauf",
|
||||
@@ -93,22 +93,22 @@
|
||||
"entry.tags.label": "Stichworte:",
|
||||
"page.shared_entries.title": "Geteilte Artikel",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d geteilter Artikel",
|
||||
"%d geteilte Artikel"
|
||||
],
|
||||
"page.unread.title": "Ungelesen",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d ungelesener Artikel",
|
||||
"%d ungelesene Artikel"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d Artikel insgesamt",
|
||||
"%d Artikel insgesamt"
|
||||
],
|
||||
"page.starred.title": "Lesezeichen",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d Lesezeichen",
|
||||
"%d Lesezeichen"
|
||||
],
|
||||
"page.categories.title": "Kategorien",
|
||||
"page.categories.no_feed": "Kein Abonnement.",
|
||||
@@ -119,15 +119,15 @@
|
||||
"Es gibt %d Abonnements."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d Kategorie",
|
||||
"%d Kategorien"
|
||||
],
|
||||
"page.new_category.title": "Neue Kategorie",
|
||||
"page.new_user.title": "Neuer Benutzer",
|
||||
"page.edit_category.title": "Kategorie bearbeiten: %s",
|
||||
"page.edit_user.title": "Benutzer bearbeiten: %s",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Kategorie: %s",
|
||||
"page.feeds.last_check": "Letzte Aktualisierung:",
|
||||
"page.feeds.next_check": "Nächste Aktualisierung:",
|
||||
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
|
||||
@@ -137,8 +137,8 @@
|
||||
],
|
||||
"page.history.title": "Verlauf",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d gelesener Artikel",
|
||||
"%d gelesene Artikel"
|
||||
],
|
||||
"page.import.title": "Importieren",
|
||||
"page.search.title": "Suchergebnisse",
|
||||
@@ -220,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": "Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Entfernen Sie %d Hauptschlüssel",
|
||||
"%d Hauptschlüssel entfernen"
|
||||
@@ -254,7 +255,7 @@
|
||||
"page.api_keys.never_used": "Nie benutzt",
|
||||
"page.new_api_key.title": "Neuer API-Schlüssel",
|
||||
"page.offline.title": "Offline-Modus",
|
||||
"page.offline.message": "Du bist offline",
|
||||
"page.offline.message": "Sie sind offline",
|
||||
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
|
||||
"page.webauthn_rename.title": "Passkey umbenennen",
|
||||
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
|
||||
@@ -272,12 +273,12 @@
|
||||
"alert.no_user": "Sie sind der einzige Benutzer.",
|
||||
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
|
||||
"alert.account_linked": "Ihr externes Konto wurde verknüpft!",
|
||||
"alert.pocket_linked": "Ihr Pocket Konto ist jetzt verknüpft!",
|
||||
"alert.pocket_linked": "Ihr Pocket-Konto ist jetzt verknüpft!",
|
||||
"alert.prefs_saved": "Einstellungen gespeichert!",
|
||||
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
|
||||
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
|
||||
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google Reader Benutzernamen!",
|
||||
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever-Benutzernamen!",
|
||||
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google-Reader-Benutzernamen!",
|
||||
"error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
|
||||
"error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
|
||||
"error.category_already_exists": "Diese Kategorie existiert bereits.",
|
||||
@@ -292,7 +293,7 @@
|
||||
"error.invalid_language": "Ungültige Sprache.",
|
||||
"error.invalid_timezone": "Ungültige Zeitzone.",
|
||||
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
|
||||
"error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
|
||||
"error.invalid_display_mode": "Progressive-Web-App- (PWA-)Anzeigemodus",
|
||||
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
|
||||
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
|
||||
"error.empty_file": "Diese Datei ist leer.",
|
||||
@@ -303,21 +304,21 @@
|
||||
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
|
||||
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
|
||||
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
|
||||
"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": "Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_block_rule_separator_required": "Ungültige Blockierregel: Das Muster für Regel #%d muss per '=' getrennt werden",
|
||||
"error.settings_block_rule_regex_required": "Ungültige Blockierregel: Regel #%d hat kein Muster",
|
||||
"error.settings_block_rule_invalid_regex": "Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ungültige Erlaubnisregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Ungültige Erlaubnisregel: Das Muster für Regel #%d muss per '=' getrennt werden",
|
||||
"error.settings_keep_rule_regex_required": "Ungültige Erlaubnisregel: Regel #%d hat kein Muster",
|
||||
"error.settings_keep_rule_invalid_regex": "Ungültige Erlaubnisregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
|
||||
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
|
||||
"error.feed_already_exists": "Dieser Feed existiert bereits.",
|
||||
"error.invalid_feed_url": "Ungültige Feed-URL.",
|
||||
"error.invalid_site_url": "Ungültige Site-URL.",
|
||||
"error.feed_url_not_empty": "Die Feed-URL darf nicht leer sein.",
|
||||
"error.site_url_not_empty": "Die Site-URL darf nicht leer sein.",
|
||||
"error.invalid_feed_url": "Ungültiger Feed-URL.",
|
||||
"error.invalid_site_url": "Ungültiger Site-URL.",
|
||||
"error.feed_url_not_empty": "Der Feed-URL darf nicht leer sein.",
|
||||
"error.site_url_not_empty": "Der Site-URL darf nicht leer sein.",
|
||||
"error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.",
|
||||
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
|
||||
"error.feed_invalid_blocklist_rule": "Die Blockierregel ist ungültig.",
|
||||
@@ -337,24 +338,26 @@
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.scraper_rules": "Extraktionsregeln",
|
||||
"form.feed.label.rewrite_rules": "Umschreiberegeln",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise Service-URLs",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
|
||||
"form.feed.label.blocklist_rules": "Blockierregeln",
|
||||
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
|
||||
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
|
||||
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"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": "Einträge zu ntfy pushen",
|
||||
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_max_priority": "Höchste Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_min_priority": "Niedrigste Ntfy-Priorität",
|
||||
"form.integration.cubox_activate": "Einträge in Cubox speichern",
|
||||
"form.integration.cubox_api_link": "Cubox-API-Link",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.rules": "Regeln",
|
||||
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
|
||||
@@ -379,7 +382,7 @@
|
||||
"form.prefs.select.standalone": "Eigenständige",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlichte am",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
|
||||
"form.prefs.select.created_time": "Artikel erstellt am",
|
||||
"form.prefs.select.alphabetical": "Alphabetisch",
|
||||
"form.prefs.select.unread_count": "Ungelesen",
|
||||
@@ -391,67 +394,71 @@
|
||||
"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",
|
||||
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"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.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
|
||||
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
|
||||
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML Datei",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
|
||||
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
|
||||
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Ungültige Domainliste. Bitte geben Sie eine per Leerzeichen getrennte Liste von Domains an.",
|
||||
"form.import.label.file": "OPML-Datei",
|
||||
"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": "Fever API aktivieren",
|
||||
"form.integration.fever_username": "Fever Benutzername",
|
||||
"form.integration.fever_password": "Fever Passwort",
|
||||
"form.integration.fever_endpoint": "Fever API Endpunkt:",
|
||||
"form.integration.googlereader_activate": "Google Reader API aktivieren",
|
||||
"form.integration.googlereader_username": "Google Reader Benutzername",
|
||||
"form.integration.googlereader_password": "Google Reader Passwort",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API-Endpunkt:",
|
||||
"form.integration.betula_activate": "Einträge in Betula speichern",
|
||||
"form.integration.betula_url": "Betula-Server-URL",
|
||||
"form.integration.betula_token": "Betula-Token",
|
||||
"form.integration.fever_activate": "Fever-API aktivieren",
|
||||
"form.integration.fever_username": "Fever-Benutzername",
|
||||
"form.integration.fever_password": "Fever-Passwort",
|
||||
"form.integration.fever_endpoint": "Fever-API-Endpunkt:",
|
||||
"form.integration.googlereader_activate": "Google-Reader-API aktivieren",
|
||||
"form.integration.googlereader_username": "Google-Reader-Benutzername",
|
||||
"form.integration.googlereader_password": "Google-Reader-Passwort",
|
||||
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
|
||||
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
|
||||
"form.integration.pinboard_token": "Pinboard API-Token",
|
||||
"form.integration.pinboard_tags": "Pinboard Tags",
|
||||
"form.integration.pinboard_token": "Pinboard-API-Token",
|
||||
"form.integration.pinboard_tags": "Pinboard-Tags",
|
||||
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
|
||||
"form.integration.instapaper_username": "Instapaper Benutzername",
|
||||
"form.integration.instapaper_password": "Instapaper Passwort",
|
||||
"form.integration.instapaper_username": "Instapaper-Benutzername",
|
||||
"form.integration.instapaper_password": "Instapaper-Passwort",
|
||||
"form.integration.pocket_activate": "Einträge in Pocket speichern",
|
||||
"form.integration.pocket_consumer_key": "Pocket Verbraucher-Schlüssel",
|
||||
"form.integration.pocket_access_token": "Pocket Zugangs-Token",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket Konto",
|
||||
"form.integration.pocket_consumer_key": "Pocket-Verbraucher-Schlüssel",
|
||||
"form.integration.pocket_access_token": "Pocket-Zugangstoken",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
|
||||
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
|
||||
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Geheimnis",
|
||||
"form.integration.wallabag_username": "Wallabag Benutzername",
|
||||
"form.integration.wallabag_password": "Wallabag Passwort",
|
||||
"form.integration.wallabag_endpoint": "Wallabag-URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
|
||||
"form.integration.wallabag_username": "Wallabag-Benutzername",
|
||||
"form.integration.wallabag_password": "Wallabag-Passwort",
|
||||
"form.integration.notion_activate": "Einträge in Notion speichern",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Geheimnis-Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise service URLs",
|
||||
"form.integration.notion_page_id": "Notion-Page-ID",
|
||||
"form.integration.notion_token": "Notion-Geheimnis-Token",
|
||||
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
|
||||
"form.integration.apprise_url": "Apprise-API-URL",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
|
||||
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
|
||||
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
|
||||
"form.integration.omnivore_url": "Omnivore API-Endpunkt",
|
||||
"form.integration.omnivore_api_key": "Omnivore API-Schlüssel",
|
||||
"form.integration.espial_activate": "Einträge in Espial",
|
||||
"form.integration.espial_endpoint": "Espial API-Endpunkt",
|
||||
"form.integration.espial_api_key": "Espial API-Schlüssel",
|
||||
"form.integration.espial_tags": "Espial Tags",
|
||||
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
|
||||
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
|
||||
"form.integration.espial_activate": "Einträge in Espial speichern",
|
||||
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
|
||||
"form.integration.espial_api_key": "Espial-API-Schlüssel",
|
||||
"form.integration.espial_tags": "Espial-Tags",
|
||||
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Zugangs-Token",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise Zugangs-Token",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
|
||||
"form.integration.telegram_bot_token": "Bot-Token",
|
||||
"form.integration.telegram_chat_id": "Chat-ID",
|
||||
@@ -459,53 +466,53 @@
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Webseiten-Vorschau deaktivieren",
|
||||
"form.integration.telegram_bot_disable_notification": "Benachrichtigungen deaktivieren",
|
||||
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
|
||||
"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.linkace_activate": "Einträge in LinkAce speichern",
|
||||
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
|
||||
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
|
||||
"form.integration.linkace_tags": "LinkAce-Tags",
|
||||
"form.integration.linkace_is_private": "Link als privat markieren",
|
||||
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
|
||||
"form.integration.linkding_activate": "Artikel in Linkding speichern",
|
||||
"form.integration.linkding_endpoint": "Linkding API-Endpunkt",
|
||||
"form.integration.linkding_api_key": "Linkding API-Schlüssel",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
|
||||
"form.integration.linkding_api_key": "Linkding-API-Schlüssel",
|
||||
"form.integration.linkding_tags": "Linkding-Tags",
|
||||
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API-Endpunkt",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-Schlüssel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-API-Endpunkt",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
|
||||
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
|
||||
"form.integration.matrix_bot_user": "Benutzername für Matrix",
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.raindrop_activate": "Einträge in Raindrop speichern",
|
||||
"form.integration.raindrop_token": "(Test-)Token",
|
||||
"form.integration.raindrop_collection_id": "Sammlungs-ID",
|
||||
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
|
||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_endpoint": "Readeck-URL",
|
||||
"form.integration.readeck_api_key": "Readeck-API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck-Labels",
|
||||
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.shiori_activate": "Artikel in Shiori speichern",
|
||||
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori Benutzername",
|
||||
"form.integration.shiori_password": "Shiori Passwort",
|
||||
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori-Benutzername",
|
||||
"form.integration.shiori_password": "Shiori-Passwort",
|
||||
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Geheimnis",
|
||||
"form.integration.shaarli_endpoint": "Shaarli-URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli-API-Geheimnis",
|
||||
"form.integration.webhook_activate": "Webhook aktivieren",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Geheimnis",
|
||||
"form.integration.webhook_url": "Webhook-URL",
|
||||
"form.integration.webhook_secret": "Webhook-Geheimnis",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
"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.rssbridge_url": "RSS-Bridge-Server-URL",
|
||||
"form.integration.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.integration.ntfy_topic": "Ntfy-Thema",
|
||||
"form.integration.ntfy_url": "Ntfy-URL (optional, Standard ist ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy-Benutzername (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy-Passwort (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
|
||||
"form.api_key.label.description": "API-Schlüsselbezeichnung",
|
||||
"form.submit.loading": "Lade...",
|
||||
"form.submit.saving": "Speichern...",
|
||||
@@ -549,7 +556,7 @@
|
||||
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
|
||||
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
|
||||
"error.http_client_error": "HTTP-Client-Fehler: %v.",
|
||||
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise sind der Benutzername oder das Passwort falsch.",
|
||||
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise ist der Benutzername oder das Passwort falsch.",
|
||||
"error.http_too_many_requests": "Miniflux hat zu viele Anfragen an diese Webseite gestellt. Bitte versuchen Sie es später erneut oder ändern Sie die Konfiguration der Anwendung.",
|
||||
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
||||
"error.http_resource_not_found": "Die gewünschte Quelle wurde nicht gefunden. Bitte stellen Sie sicher, dass die URL korrekt ist.",
|
||||
@@ -567,13 +574,13 @@
|
||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
|
||||
"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" : "Vorspulen:",
|
||||
"enclosure_media_controls.seek.title" : "%s Sekunden vorspulen",
|
||||
"enclosure_media_controls.speed" : "Geschwindigkeit:",
|
||||
"enclosure_media_controls.speed.faster" : "Schneller",
|
||||
"enclosure_media_controls.speed.faster.title" : "%sx schneller",
|
||||
"enclosure_media_controls.speed.slower" : "Langsamer",
|
||||
"enclosure_media_controls.speed.slower.title" : "%sx langsamer",
|
||||
"enclosure_media_controls.speed.reset" : "Zurücksetzen",
|
||||
"enclosure_media_controls.speed.reset.title" : "Wiedergabegeschwindigkeit auf 1x zurücksetzen"
|
||||
}
|
||||
|
||||
@@ -220,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 κωδικούς πρόσβασης"
|
||||
@@ -359,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": "Χρήστης",
|
||||
@@ -391,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": "Ταξινόμηση κατηγοριών",
|
||||
@@ -402,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",
|
||||
|
||||
@@ -229,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",
|
||||
@@ -391,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",
|
||||
@@ -402,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",
|
||||
@@ -483,7 +488,7 @@
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Save entries to readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
|
||||
@@ -506,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…",
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
"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 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 acceso",
|
||||
"Eliminar %d claves de acceso"
|
||||
@@ -355,6 +356,8 @@
|
||||
"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": "Reglas",
|
||||
"form.feed.fieldset.network_settings": "Ajustes de red",
|
||||
@@ -391,6 +394,7 @@
|
||||
"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",
|
||||
@@ -402,6 +406,9 @@
|
||||
"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": "Guardar artículos en Betula",
|
||||
|
||||
@@ -220,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"
|
||||
@@ -355,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",
|
||||
@@ -391,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",
|
||||
@@ -402,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",
|
||||
|
||||
@@ -220,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"
|
||||
@@ -355,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",
|
||||
@@ -391,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",
|
||||
@@ -402,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",
|
||||
|
||||
@@ -220,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 पासकी हटाएं"
|
||||
@@ -355,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",
|
||||
@@ -391,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": "श्रेणियाँ छँटाई",
|
||||
@@ -402,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",
|
||||
@@ -483,7 +490,7 @@
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
||||
"form.integration.readeck_endpoint": "Readeck यूआरएल",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
|
||||
@@ -211,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"
|
||||
],
|
||||
@@ -345,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",
|
||||
@@ -381,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",
|
||||
@@ -392,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",
|
||||
|
||||
@@ -220,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"
|
||||
@@ -355,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",
|
||||
@@ -391,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",
|
||||
@@ -402,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",
|
||||
|
||||
@@ -211,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 個のパスキーを削除"
|
||||
],
|
||||
@@ -345,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",
|
||||
@@ -381,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": "カテゴリの表示順",
|
||||
@@ -392,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",
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"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 passkey",
|
||||
"Verwijder %d passkeys"
|
||||
@@ -355,6 +356,8 @@
|
||||
"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",
|
||||
@@ -391,6 +394,7 @@
|
||||
"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.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",
|
||||
@@ -402,6 +406,9 @@
|
||||
"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": "Artikelen opslaan in Betula",
|
||||
|
||||
@@ -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,139 +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.show_only_starred_entries": "Pokaż tylko ulubione 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:",
|
||||
@@ -175,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",
|
||||
@@ -222,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",
|
||||
@@ -257,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",
|
||||
@@ -266,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!",
|
||||
@@ -287,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.",
|
||||
@@ -297,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.",
|
||||
@@ -305,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.",
|
||||
@@ -326,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",
|
||||
@@ -378,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ą",
|
||||
@@ -539,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": [
|
||||
@@ -553,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"
|
||||
}
|
||||
|
||||
@@ -220,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"
|
||||
@@ -355,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",
|
||||
@@ -391,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",
|
||||
@@ -402,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",
|
||||
|
||||
@@ -229,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 пароля",
|
||||
@@ -365,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",
|
||||
@@ -401,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": "Сортировка категорий",
|
||||
@@ -412,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",
|
||||
@@ -460,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ı",
|
||||
|
||||
@@ -229,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 ключа доступу",
|
||||
@@ -365,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",
|
||||
@@ -401,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": "Сортування за категоріями",
|
||||
@@ -412,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",
|
||||
@@ -493,7 +500,7 @@
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
|
||||
|
||||
@@ -211,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": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入用户名。",
|
||||
"page.settings.webauthn.delete": [
|
||||
"删除 %d 个 Passkey"
|
||||
],
|
||||
@@ -345,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": "网络设置",
|
||||
@@ -381,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": "分类排序",
|
||||
@@ -392,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",
|
||||
|
||||
@@ -211,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"
|
||||
],
|
||||
@@ -345,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": "網路設定",
|
||||
@@ -381,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": "分類排序",
|
||||
@@ -392,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",
|
||||
|
||||
@@ -87,7 +87,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
}
|
||||
}
|
||||
|
||||
output, err := doc.Find("body").First().Html()
|
||||
output, err := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
if err != nil {
|
||||
return htmlDocument
|
||||
}
|
||||
|
||||
@@ -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/") {
|
||||
|
||||
+15
-13
@@ -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,26 +112,27 @@ 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 {
|
||||
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
|
||||
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
|
||||
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
|
||||
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
intervalMinutes = max(intervalMinutes, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
slog.String("feed_url", feedCreationRequest.FeedURL),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
@@ -71,7 +66,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
processor.ProcessFeedEntries(store, subscription, user, true)
|
||||
processor.ProcessFeedEntries(store, subscription, userID, true)
|
||||
|
||||
if storeErr := store.CreateFeed(subscription); storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -93,13 +88,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
|
||||
}
|
||||
@@ -111,11 +100,6 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
slog.String("feed_url", feedCreationRequest.FeedURL),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
@@ -176,7 +160,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
processor.ProcessFeedEntries(store, subscription, user, true)
|
||||
processor.ProcessFeedEntries(store, subscription, userID, true)
|
||||
|
||||
if storeErr := store.CreateFeed(subscription); storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -188,13 +172,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
|
||||
}
|
||||
|
||||
@@ -206,11 +185,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.Bool("force_refresh", forceRefresh),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed, storeErr := store.FeedByID(userID, feedID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -221,7 +195,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 +205,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,8 +226,25 @@ 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()))
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -261,6 +252,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -270,6 +265,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())
|
||||
@@ -285,6 +282,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
|
||||
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
|
||||
}
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
@@ -292,24 +293,30 @@ 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),
|
||||
)
|
||||
|
||||
originalFeed.Entries = updatedFeed.Entries
|
||||
processor.ProcessFeedEntries(store, originalFeed, user, forceRefresh)
|
||||
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
|
||||
|
||||
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
|
||||
updateExistingEntries := forceRefresh || !originalFeed.Crawler
|
||||
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
|
||||
if storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -326,29 +333,37 @@ 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,
|
||||
)
|
||||
originalFeed.IconURL = 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()
|
||||
|
||||
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -356,32 +371,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.SiteURL, 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.SiteURL),
|
||||
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.SiteURL),
|
||||
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.SiteURL),
|
||||
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.SiteURL),
|
||||
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()
|
||||
}
|
||||
@@ -4,12 +4,18 @@
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -19,6 +25,7 @@ import (
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
@@ -180,9 +187,59 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) {
|
||||
Content: responseBody,
|
||||
}
|
||||
|
||||
icon = resizeIcon(icon)
|
||||
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
r := bytes.NewReader(icon.Content)
|
||||
|
||||
if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif"}, icon.MimeType) {
|
||||
slog.Info("icon isn't a png/gif/jpeg/ico, can't resize", slog.String("mimetype", icon.MimeType))
|
||||
return icon
|
||||
}
|
||||
|
||||
// Don't resize icons that we can't decode, or that already have the right size.
|
||||
config, _, err := image.DecodeConfig(r)
|
||||
if err != nil {
|
||||
slog.Warn("unable to decode the metadata of the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
if config.Height <= 32 && config.Width <= 32 {
|
||||
slog.Debug("icon don't need to be rescaled", slog.Int("height", config.Height), slog.Int("width", config.Width))
|
||||
return icon
|
||||
}
|
||||
|
||||
r.Seek(0, io.SeekStart)
|
||||
|
||||
var src image.Image
|
||||
switch icon.MimeType {
|
||||
case "image/jpeg":
|
||||
src, err = jpeg.Decode(r)
|
||||
case "image/png":
|
||||
src, err = png.Decode(r)
|
||||
case "image/gif":
|
||||
src, err = gif.Decode(r)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("unable to decode the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, 32, 32))
|
||||
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
|
||||
|
||||
var b bytes.Buffer
|
||||
if err = png.Encode(io.Writer(&b), dst); err != nil {
|
||||
slog.Warn("unable to encode the new icon", slog.Any("error", err))
|
||||
}
|
||||
|
||||
icon.Content = b.Bytes()
|
||||
icon.MimeType = "image/png"
|
||||
return icon
|
||||
}
|
||||
|
||||
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
|
||||
queries := []string{
|
||||
"link[rel='icon' i]",
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestParseImageDataURL(t *testing.T) {
|
||||
@@ -125,3 +130,52 @@ func TestParseDocumentWithWhitespaceIconURL(t *testing.T) {
|
||||
t.Errorf(`Invalid icon URL, got %q`, iconURLs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeIconSmallGif(t *testing.T) {
|
||||
data, err := base64.StdEncoding.DecodeString("R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := model.Icon{
|
||||
Content: data,
|
||||
MimeType: "image/gif",
|
||||
}
|
||||
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
|
||||
t.Fatalf("Converted gif smaller than 16x16")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeIconPng(t *testing.T) {
|
||||
data, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAALUlEQVR42u3OMQEAAAgDoJnc6BpjDyRgcrcpGwkJCQkJCQkJCQkJCQkJCYmyB7NfUj/Kk4FkAAAAAElFTkSuQmCC")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := model.Icon{
|
||||
Content: data,
|
||||
MimeType: "image/png",
|
||||
}
|
||||
resizedIcon := resizeIcon(&icon)
|
||||
|
||||
if bytes.Equal(data, resizedIcon.Content) {
|
||||
t.Fatalf("Didn't convert png of 33x33")
|
||||
}
|
||||
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(resizedIcon.Content))
|
||||
if err != nil {
|
||||
t.Fatalf("Couln't decode resulting png: %v", err)
|
||||
}
|
||||
|
||||
if config.Height != 32 || config.Width != 32 {
|
||||
t.Fatalf("Was expecting an image of 16x16, got %dx%d", config.Width, config.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeInvalidImage(t *testing.T) {
|
||||
icon := model.Icon{
|
||||
Content: []byte("invalid data"),
|
||||
MimeType: "image/gif",
|
||||
}
|
||||
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
|
||||
t.Fatalf("Tried to convert an invalid image")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@@ -17,14 +17,17 @@ import (
|
||||
"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
|
||||
|
||||
u, err := url.Parse(entry.URL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Hostname() == "nebula.tv"
|
||||
}
|
||||
|
||||
func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
@@ -45,7 +48,7 @@ func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="video:duration"]`)).Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@@ -17,14 +17,17 @@ import (
|
||||
"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
|
||||
|
||||
u, err := url.Parse(entry.URL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Hostname() == "odysee.com"
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
@@ -45,7 +48,7 @@ func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="og:video:duration"]`).First().Attr("content")
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="og:video:duration"]`)).Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/metric"
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -20,17 +23,20 @@ import (
|
||||
"miniflux.app/v2/internal/reader/scraper"
|
||||
"miniflux.app/v2/internal/reader/urlcleaner"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
)
|
||||
|
||||
var 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) {
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64, forceRefresh bool) {
|
||||
var filteredEntries model.Entries
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
slog.Error("Database error", slog.Any("error", storeErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Process older entries first
|
||||
for i := len(feed.Entries) - 1; i >= 0; i-- {
|
||||
entry := feed.Entries[i]
|
||||
@@ -53,6 +59,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",
|
||||
@@ -134,6 +141,9 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
@@ -204,6 +214,9 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
@@ -455,3 +468,44 @@ func minifyEntryContent(entryContent string) string {
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
|
||||
if pattern == "future" {
|
||||
return entryDate.After(time.Now())
|
||||
}
|
||||
|
||||
parts := strings.SplitN(pattern, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
operator := parts[0]
|
||||
dateStr := parts[1]
|
||||
|
||||
switch operator {
|
||||
case "before":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.Before(targetDate)
|
||||
case "after":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(targetDate)
|
||||
case "between":
|
||||
dates := strings.Split(dateStr, ",")
|
||||
if len(dates) != 2 {
|
||||
return false
|
||||
}
|
||||
startDate, err1 := time.Parse("2006-01-02", dates[0])
|
||||
endDate, err2 := time.Parse("2006-01-02", dates[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(startDate) && entryDate.Before(endDate)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ func TestAllowEntries(t *testing.T) {
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(-24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=before:2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 16, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=after:2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -33,6 +35,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
if config.Opts.YouTubeApiKey() == "" {
|
||||
return fetchYouTubeWatchTimeFromWebsite(websiteURL)
|
||||
} else {
|
||||
return fetchYouTubeWatchTimeFromApi(websiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
@@ -50,7 +60,7 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
@@ -63,6 +73,61 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse URL: %v", err)
|
||||
}
|
||||
|
||||
apiQuery := url.Values{}
|
||||
apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
|
||||
apiQuery.Set("key", config.Opts.YouTubeApiKey())
|
||||
apiQuery.Set("part", "contentDetails")
|
||||
|
||||
apiURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.googleapis.com",
|
||||
Path: "youtube/v3/videos",
|
||||
RawQuery: apiQuery.Encode(),
|
||||
}
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var videos struct {
|
||||
Items []struct {
|
||||
ContentDetails struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"contentDetails"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
|
||||
return 0, fmt.Errorf("unable to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if n := len(videos.Items); n != 1 {
|
||||
return 0, fmt.Errorf("invalid items length: %d", n)
|
||||
}
|
||||
|
||||
durs := videos.Items[0].ContentDetails.Duration
|
||||
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
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
package readability // import "miniflux.app/v2/internal/reader/readability"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -24,14 +22,12 @@ const (
|
||||
|
||||
var (
|
||||
divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
|
||||
sentenceRegexp = regexp.MustCompile(`\.( |$)`)
|
||||
|
||||
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
|
||||
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
|
||||
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
negativeRegexp = regexp.MustCompile(`hid|banner|combx|comment|com-|contact|foot|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby`)
|
||||
positiveRegexp = regexp.MustCompile(`article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
@@ -77,16 +73,14 @@ func ExtractContent(page io.Reader) (baseURL string, extractedContent string, er
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
|
||||
if hrefValue, exists := document.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
document.Find("script,style").Each(func(i int, s *goquery.Selection) {
|
||||
removeNodes(s)
|
||||
})
|
||||
document.Find("script,style").Remove()
|
||||
|
||||
transformMisusedDivsIntoParagraphs(document)
|
||||
removeUnlikelyCandidates(document)
|
||||
@@ -107,8 +101,9 @@ func ExtractContent(page io.Reader) (baseURL string, extractedContent string, er
|
||||
// Now that we have the top candidate, look through its siblings for content that might also be related.
|
||||
// Things like preambles, content split by ads that we removed, etc.
|
||||
func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
output := bytes.NewBufferString("<div>")
|
||||
siblingScoreThreshold := float32(math.Max(10, float64(topCandidate.score*.2)))
|
||||
var output strings.Builder
|
||||
output.WriteString("<div>")
|
||||
siblingScoreThreshold := max(10, topCandidate.score*.2)
|
||||
|
||||
topCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {
|
||||
append := false
|
||||
@@ -125,10 +120,14 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
content := s.Text()
|
||||
contentLength := len(content)
|
||||
|
||||
if contentLength >= 80 && linkDensity < .25 {
|
||||
append = true
|
||||
} else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
|
||||
append = true
|
||||
if contentLength >= 80 {
|
||||
if linkDensity < .25 {
|
||||
append = true
|
||||
}
|
||||
} else {
|
||||
if linkDensity == 0 && containsSentence(content) {
|
||||
append = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +138,7 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
}
|
||||
|
||||
html, _ := s.Html()
|
||||
fmt.Fprintf(output, "<%s>%s</%s>", tag, html, tag)
|
||||
output.WriteString("<" + tag + ">" + html + "</" + tag + ">")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -148,16 +147,29 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
}
|
||||
|
||||
func removeUnlikelyCandidates(document *goquery.Document) {
|
||||
var shouldRemove = func(str string) bool {
|
||||
str = strings.ToLower(str)
|
||||
if strings.Contains(str, "popupbody") || strings.Contains(str, "-ad") || strings.Contains(str, "g-plus") {
|
||||
return true
|
||||
} else if unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
document.Find("*").Each(func(i int, s *goquery.Selection) {
|
||||
if s.Length() == 0 || s.Get(0).Data == "html" || s.Get(0).Data == "body" {
|
||||
return
|
||||
}
|
||||
class, _ := s.Attr("class")
|
||||
id, _ := s.Attr("id")
|
||||
str := class + id
|
||||
|
||||
if blacklistCandidatesRegexp.MatchString(str) || (unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str)) {
|
||||
removeNodes(s)
|
||||
if class, ok := s.Attr("class"); ok {
|
||||
if shouldRemove(class) {
|
||||
s.Remove()
|
||||
}
|
||||
} else if id, ok := s.Attr("id"); ok {
|
||||
if shouldRemove(id) {
|
||||
s.Remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -221,7 +233,7 @@ func getCandidates(document *goquery.Document) candidateList {
|
||||
contentScore += float32(strings.Count(text, ",") + 1)
|
||||
|
||||
// For every 100 characters in this paragraph, add another point. Up to 3 points.
|
||||
contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
|
||||
contentScore += float32(min(len(text)/100.0, 3))
|
||||
|
||||
candidates[parentNode].score += contentScore
|
||||
if grandParentNode != nil {
|
||||
@@ -260,13 +272,14 @@ func scoreNode(s *goquery.Selection) *candidate {
|
||||
// Get the density of links as a percentage of the content
|
||||
// This is the amount of text that is inside a link divided by the total text in the node.
|
||||
func getLinkDensity(s *goquery.Selection) float32 {
|
||||
linkLength := len(s.Find("a").Text())
|
||||
textLength := len(s.Text())
|
||||
|
||||
if textLength == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
linkLength := len(s.Find("a").Text())
|
||||
|
||||
return float32(linkLength) / float32(textLength)
|
||||
}
|
||||
|
||||
@@ -274,25 +287,21 @@ func getLinkDensity(s *goquery.Selection) float32 {
|
||||
// element looks good or bad.
|
||||
func getClassWeight(s *goquery.Selection) float32 {
|
||||
weight := 0
|
||||
class, _ := s.Attr("class")
|
||||
id, _ := s.Attr("id")
|
||||
|
||||
if class != "" {
|
||||
if class, ok := s.Attr("class"); ok {
|
||||
class = strings.ToLower(class)
|
||||
if negativeRegexp.MatchString(class) {
|
||||
weight -= 25
|
||||
}
|
||||
|
||||
if positiveRegexp.MatchString(class) {
|
||||
} else if positiveRegexp.MatchString(class) {
|
||||
weight += 25
|
||||
}
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
if id, ok := s.Attr("id"); ok {
|
||||
id = strings.ToLower(id)
|
||||
if negativeRegexp.MatchString(id) {
|
||||
weight -= 25
|
||||
}
|
||||
|
||||
if positiveRegexp.MatchString(id) {
|
||||
} else if positiveRegexp.MatchString(id) {
|
||||
weight += 25
|
||||
}
|
||||
}
|
||||
@@ -310,11 +319,6 @@ func transformMisusedDivsIntoParagraphs(document *goquery.Document) {
|
||||
})
|
||||
}
|
||||
|
||||
func removeNodes(s *goquery.Selection) {
|
||||
s.Each(func(i int, s *goquery.Selection) {
|
||||
parent := s.Parent()
|
||||
if parent.Length() > 0 {
|
||||
parent.Get(0).RemoveChild(s.Get(0))
|
||||
}
|
||||
})
|
||||
func containsSentence(content string) bool {
|
||||
return strings.HasSuffix(content, ".") || strings.Contains(content, ". ")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package readability // import "miniflux.app/v2/internal/reader/readability"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -100,3 +102,83 @@ func TestWithoutBaseURL(t *testing.T) {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of ""`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStyleScript(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<script src="tololo.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="tololo.js"></script>
|
||||
<style>
|
||||
h1 {color:red;}
|
||||
p {color:blue;}
|
||||
</style>
|
||||
<article>Some content</article>
|
||||
</body>
|
||||
</html>`
|
||||
want := `<div><div><article>Somecontent</article></div></div>`
|
||||
|
||||
_, content, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
content = strings.ReplaceAll(content, " ", "")
|
||||
content = strings.ReplaceAll(content, "\t", "")
|
||||
|
||||
if content != want {
|
||||
t.Errorf(`Invalid content, got %s instead of %s`, content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveBlacklist(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<article class="super-ad">Some content</article>
|
||||
<article class="g-plus-crap">Some other thing</article>
|
||||
<article class="stuff popupbody">And more</article>
|
||||
<article class="legit">Valid!</article>
|
||||
</body>
|
||||
</html>`
|
||||
want := `<div><div><articleclass="legit">Valid!</article></div></div>`
|
||||
|
||||
_, content, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
content = strings.ReplaceAll(content, " ", "")
|
||||
content = strings.ReplaceAll(content, "\t", "")
|
||||
|
||||
if content != want {
|
||||
t.Errorf(`Invalid content, got %s instead of %s`, content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExtractContent(b *testing.B) {
|
||||
var testCases = map[string][]byte{
|
||||
"miniflux_github.html": {},
|
||||
"miniflux_wikipedia.html": {},
|
||||
}
|
||||
for filename := range testCases {
|
||||
data, err := os.ReadFile("testdata/" + filename)
|
||||
if err != nil {
|
||||
b.Fatalf(`Unable to read file %q: %v`, filename, err)
|
||||
}
|
||||
testCases[filename] = data
|
||||
}
|
||||
for range b.N {
|
||||
for _, v := range testCases {
|
||||
ExtractContent(bytes.NewReader(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../reader/sanitizer/testdata/
|
||||
@@ -19,7 +19,7 @@ func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed in
|
||||
sanitizedContent := sanitizer.StripTags(content)
|
||||
|
||||
// Litterature on language detection says that around 100 signes is enough, we're safe here.
|
||||
truncationPoint := int(math.Min(float64(len(sanitizedContent)), 250))
|
||||
truncationPoint := min(len(sanitizedContent), 250)
|
||||
|
||||
// We're only interested in identifying Japanse/Chinese/Korean
|
||||
options := whatlanggo.Options{
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetRefererForURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Weibo Image URL",
|
||||
url: "https://wx1.sinaimg.cn/large/example.jpg",
|
||||
expected: "https://weibo.com",
|
||||
},
|
||||
{
|
||||
name: "Pixiv Image URL",
|
||||
url: "https://i.pximg.net/img-master/example.jpg",
|
||||
expected: "https://www.pixiv.net",
|
||||
},
|
||||
{
|
||||
name: "SSPai CDN URL",
|
||||
url: "https://cdnfile.sspai.com/example.png",
|
||||
expected: "https://sspai.com",
|
||||
},
|
||||
{
|
||||
name: "Instagram CDN URL",
|
||||
url: "https://scontent-sjc3-1.cdninstagram.com/example.jpg",
|
||||
expected: "https://www.instagram.com",
|
||||
},
|
||||
{
|
||||
name: "Piokok URL",
|
||||
url: "https://sp1.piokok.com/example.jpg",
|
||||
expected: "https://sp1.piokok.com",
|
||||
},
|
||||
{
|
||||
name: "Weibo Video URL",
|
||||
url: "https://f.video.weibocdn.com/example.mp4",
|
||||
expected: "https://weibo.com",
|
||||
},
|
||||
{
|
||||
name: "HelloGithub Image URL",
|
||||
url: "https://img.hellogithub.com/example.png",
|
||||
expected: "https://hellogithub.com",
|
||||
},
|
||||
{
|
||||
name: "Non-matching URL",
|
||||
url: "https://example.com/image.jpg",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := GetRefererForURL(tc.url)
|
||||
if result != tc.expected {
|
||||
t.Errorf("GetRefererForURL(%s): expected %s, got %s",
|
||||
tc.url, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,16 @@ import (
|
||||
nethtml "golang.org/x/net/html"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/yuin/goldmark"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
youtubeIdRegex = regexp.MustCompile(`youtube_id"?\s*[:=]\s*"([a-zA-Z0-9_-]{11})"`)
|
||||
invidioRegex = regexp.MustCompile(`https?://(.*)/watch\?v=(.*)`)
|
||||
imgRegex = regexp.MustCompile(`<img [^>]+>`)
|
||||
textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
||||
)
|
||||
|
||||
func addImageTitle(entryURL, entryContent string) string {
|
||||
func addImageTitle(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -46,14 +43,14 @@ func addImageTitle(entryURL, entryContent string) string {
|
||||
img.ReplaceWithHtml(`<figure><img src="` + srcAttr + `" alt="` + altAttr + `"/><figcaption><p>` + html.EscapeString(titleAttr) + `</p></figcaption></figure>`)
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addMailtoSubject(entryURL, entryContent string) string {
|
||||
func addMailtoSubject(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -78,18 +75,19 @@ func addMailtoSubject(entryURL, entryContent string) string {
|
||||
a.AppendHtml(" [" + html.EscapeString(subject) + "]")
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addDynamicImage(entryURL, entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
func addDynamicImage(entryContent string) string {
|
||||
parserHtml, err := nethtml.ParseWithOptions(strings.NewReader(entryContent), nethtml.ParseOptionEnableScripting(false))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
}
|
||||
doc := goquery.NewDocumentFromNode(parserHtml)
|
||||
|
||||
// Ordered most preferred to least preferred.
|
||||
candidateAttrs := []string{
|
||||
@@ -151,25 +149,22 @@ func addDynamicImage(entryURL, entryContent string) string {
|
||||
|
||||
if !changed {
|
||||
doc.Find("noscript").Each(func(i int, noscript *goquery.Selection) {
|
||||
matches := imgRegex.FindAllString(noscript.Text(), 2)
|
||||
|
||||
if len(matches) == 1 {
|
||||
if img := noscript.Find("img"); img.Length() == 1 {
|
||||
img.Unwrap()
|
||||
changed = true
|
||||
|
||||
noscript.ReplaceWithHtml(matches[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if changed {
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addDynamicIframe(entryURL, entryContent string) string {
|
||||
func addDynamicIframe(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -199,14 +194,14 @@ func addDynamicIframe(entryURL, entryContent string) string {
|
||||
})
|
||||
|
||||
if changed {
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func fixMediumImages(entryURL, entryContent string) string {
|
||||
func fixMediumImages(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -219,11 +214,11 @@ func fixMediumImages(entryURL, entryContent string) string {
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
func useNoScriptImages(entryURL, entryContent string) string {
|
||||
func useNoScriptImages(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -241,7 +236,7 @@ func useNoScriptImages(entryURL, entryContent string) string {
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -319,7 +314,7 @@ func removeCustom(entryContent string, selector string) string {
|
||||
|
||||
doc.Find(selector).Remove()
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -346,7 +341,7 @@ func applyFuncOnTextContent(entryContent string, selector string, repl func(stri
|
||||
|
||||
doc.Find(selector).Each(treatChildren)
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -403,28 +398,13 @@ func addHackerNewsLinksUsing(entryContent, app string) string {
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func parseMarkdown(entryContent string) string {
|
||||
var sb strings.Builder
|
||||
md := goldmark.New(
|
||||
goldmark.WithRendererOptions(
|
||||
goldmarkhtml.WithUnsafe(),
|
||||
),
|
||||
)
|
||||
|
||||
if err := md.Convert([]byte(entryContent), &sb); err != nil {
|
||||
return entryContent
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func removeTables(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
@@ -437,7 +417,7 @@ func removeTables(entryContent string) string {
|
||||
|
||||
for _, selector := range selectors {
|
||||
for {
|
||||
loopElement = doc.Find(selector).First()
|
||||
loopElement = doc.FindMatcher(goquery.Single(selector))
|
||||
|
||||
if loopElement.Length() == 0 {
|
||||
break
|
||||
@@ -453,6 +433,6 @@ func removeTables(entryContent string) string {
|
||||
}
|
||||
}
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ type rule struct {
|
||||
func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
switch rule.name {
|
||||
case "add_image_title":
|
||||
entry.Content = addImageTitle(entryURL, entry.Content)
|
||||
entry.Content = addImageTitle(entry.Content)
|
||||
case "add_mailto_subject":
|
||||
entry.Content = addMailtoSubject(entryURL, entry.Content)
|
||||
entry.Content = addMailtoSubject(entry.Content)
|
||||
case "add_dynamic_image":
|
||||
entry.Content = addDynamicImage(entryURL, entry.Content)
|
||||
entry.Content = addDynamicImage(entry.Content)
|
||||
case "add_dynamic_iframe":
|
||||
entry.Content = addDynamicIframe(entryURL, entry.Content)
|
||||
entry.Content = addDynamicIframe(entry.Content)
|
||||
case "add_youtube_video":
|
||||
entry.Content = addYoutubeVideo(entryURL, entry.Content)
|
||||
case "add_invidious_video":
|
||||
@@ -46,9 +46,9 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
case "convert_text_link", "convert_text_links":
|
||||
entry.Content = replaceTextLinks(entry.Content)
|
||||
case "fix_medium_images":
|
||||
entry.Content = fixMediumImages(entryURL, entry.Content)
|
||||
entry.Content = fixMediumImages(entry.Content)
|
||||
case "use_noscript_figure_images":
|
||||
entry.Content = useNoScriptImages(entryURL, entry.Content)
|
||||
entry.Content = useNoScriptImages(entry.Content)
|
||||
case "replace":
|
||||
// Format: replace("search-term"|"replace-term")
|
||||
if len(rule.args) >= 2 {
|
||||
@@ -91,8 +91,6 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
entry.Content = addHackerNewsLinksUsing(entry.Content, "hack")
|
||||
case "add_hn_links_using_opener":
|
||||
entry.Content = addHackerNewsLinksUsing(entry.Content, "opener")
|
||||
case "parse_markdown":
|
||||
entry.Content = parseMarkdown(entry.Content)
|
||||
case "remove_tables":
|
||||
entry.Content = removeTables(entry.Content)
|
||||
case "remove_clickbait":
|
||||
|
||||
@@ -256,7 +256,7 @@ func TestRewriteWithNoLazyImage(t *testing.T) {
|
||||
func TestRewriteWithLazyImage(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<img src="https://example.org/image.jpg" data-url="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"></noscript>`,
|
||||
Content: `<img src="https://example.org/image.jpg" data-url="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"/></noscript>`,
|
||||
}
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
@@ -272,7 +272,7 @@ func TestRewriteWithLazyImage(t *testing.T) {
|
||||
func TestRewriteWithLazyDivImage(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<img src="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"></noscript>`,
|
||||
Content: `<img src="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"/></noscript>`,
|
||||
}
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// List of predefined rewrite rules (alphabetically sorted)
|
||||
// Available rules: "add_image_title", "add_youtube_video"
|
||||
// domain => rule name
|
||||
@@ -10,7 +15,6 @@ var predefinedRules = map[string]string{
|
||||
"abstrusegoose.com": "add_image_title",
|
||||
"amazingsuperpowers.com": "add_image_title",
|
||||
"blog.cloudflare.com": `add_image_title,remove("figure.kg-image-card figure.kg-image + img")`,
|
||||
"blog.laravel.com": "parse_markdown",
|
||||
"cowbirdsinlove.com": "add_image_title",
|
||||
"drawingboardcomic.com": "add_image_title",
|
||||
"exocomics.com": "add_image_title",
|
||||
@@ -34,7 +38,44 @@ var predefinedRules = map[string]string{
|
||||
"treelobsters.com": "add_image_title",
|
||||
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
|
||||
"www.qwantz.com": "add_image_title,add_mailto_subject",
|
||||
"www.recalbox.com": "parse_markdown",
|
||||
"xkcd.com": "add_image_title",
|
||||
"youtube.com": "add_youtube_video",
|
||||
}
|
||||
|
||||
// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.
|
||||
func GetRefererForURL(u string) string {
|
||||
parsedUrl, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch parsedUrl.Hostname() {
|
||||
case "moyu.im":
|
||||
return "https://i.jandan.net"
|
||||
case "i.pximg.net":
|
||||
return "https://www.pixiv.net"
|
||||
case "sp1.piokok.com":
|
||||
return "https://sp1.piokok.com"
|
||||
case "cdnfile.sspai.com":
|
||||
return "https://sspai.com"
|
||||
case "f.video.weibocdn.com":
|
||||
return "https://weibo.com"
|
||||
case "img.hellogithub.com":
|
||||
return "https://hellogithub.com"
|
||||
case "bjp.org.cn":
|
||||
return "https://bjp.org.cn"
|
||||
case "appinn.com":
|
||||
return "https://appinn.com"
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".sinaimg.cn"):
|
||||
return "https://weibo.com"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".cdninstagram.com"):
|
||||
return "https://www.instagram.com"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".moyu.im"):
|
||||
return "https://i.jandan.net"
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,8 +18,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeEmbedRegex = regexp.MustCompile(`//(?:www\.)?youtube\.com/embed/(.+)$`)
|
||||
tagAllowList = map[string][]string{
|
||||
tagAllowList = map[string][]string{
|
||||
"a": {"href", "title", "id"},
|
||||
"abbr": {"title"},
|
||||
"acronym": {"title"},
|
||||
@@ -98,6 +96,7 @@ func Sanitize(baseURL, input string) string {
|
||||
}
|
||||
|
||||
token := tokenizer.Token()
|
||||
tagName := token.DataAtom.String()
|
||||
switch token.Type {
|
||||
case html.TextToken:
|
||||
if len(blockedStack) > 0 {
|
||||
@@ -110,9 +109,8 @@ func Sanitize(baseURL, input string) string {
|
||||
continue
|
||||
}
|
||||
|
||||
buffer.WriteString(html.EscapeString(token.Data))
|
||||
buffer.WriteString(token.String())
|
||||
case html.StartTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
parentTag = tagName
|
||||
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
@@ -121,38 +119,42 @@ func Sanitize(baseURL, input string) string {
|
||||
|
||||
if isBlockedTag(tagName) || slices.ContainsFunc(token.Attr, func(attr html.Attribute) bool { return attr.Key == "hidden" }) {
|
||||
blockedStack = append(blockedStack, tagName)
|
||||
} else if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + ">")
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
|
||||
tagStack = append(tagStack, tagName)
|
||||
}
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if len(blockedStack) > 0 && blockedStack[len(blockedStack)-1] == tagName {
|
||||
blockedStack = blockedStack[:len(blockedStack)-1]
|
||||
} else if len(blockedStack) == 0 && isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString("</" + tagName + ">")
|
||||
if len(blockedStack) == 0 {
|
||||
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
} else {
|
||||
if blockedStack[len(blockedStack)-1] == tagName {
|
||||
blockedStack = blockedStack[:len(blockedStack)-1]
|
||||
}
|
||||
}
|
||||
case html.SelfClosingTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) && len(blockedStack) == 0 {
|
||||
if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + "/>")
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,11 +185,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
if tagName == "img" && (attribute.Key == "width" || attribute.Key == "height") {
|
||||
if !isPositiveInteger(value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isImageLargerThanLayout {
|
||||
if isImageLargerThanLayout || !isPositiveInteger(value) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -201,7 +199,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
value = rewriteIframeURL(attribute.Val)
|
||||
case tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val):
|
||||
value = attribute.Val
|
||||
case isAnchor("a", attribute):
|
||||
case tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#"):
|
||||
value = attribute.Val
|
||||
isAnchorLink = true
|
||||
default:
|
||||
@@ -221,7 +219,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s=%q`, attribute.Key, html.EscapeString(value)))
|
||||
htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`)
|
||||
}
|
||||
|
||||
if !isAnchorLink {
|
||||
@@ -251,10 +249,8 @@ func getExtraAttributes(tagName string) ([]string, []string) {
|
||||
}
|
||||
|
||||
func isValidTag(tagName string) bool {
|
||||
if _, ok := tagAllowList[tagName]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
_, ok := tagAllowList[tagName]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isValidAttribute(tagName, attributeName string) bool {
|
||||
@@ -294,24 +290,16 @@ func isPixelTracker(tagName string, attributes []html.Attribute) bool {
|
||||
}
|
||||
|
||||
func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
elements := map[string][]string{
|
||||
"a": {"href"},
|
||||
"iframe": {"src"},
|
||||
"img": {"src"},
|
||||
"source": {"src", "srcset"},
|
||||
switch tagName {
|
||||
case "a":
|
||||
return slices.Contains(attributes, "href")
|
||||
case "iframe", "img":
|
||||
return slices.Contains(attributes, "src")
|
||||
case "source":
|
||||
return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
if attrs, ok := elements[tagName]; ok {
|
||||
for _, attribute := range attributes {
|
||||
if slices.Contains(attrs, attribute) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
||||
@@ -369,7 +357,6 @@ func isBlockedResource(src string) bool {
|
||||
"feedsportal.com",
|
||||
"api.flattr.com",
|
||||
"stats.wordpress.com",
|
||||
"plus.google.com/share",
|
||||
"twitter.com/share",
|
||||
"feeds.feedburner.com",
|
||||
}
|
||||
@@ -401,7 +388,7 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
}
|
||||
|
||||
// allow iframe from custom invidious instance
|
||||
if config.Opts != nil && config.Opts.InvidiousInstance() == domain {
|
||||
if config.Opts.InvidiousInstance() == domain {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -409,9 +396,27 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
}
|
||||
|
||||
func rewriteIframeURL(link string) string {
|
||||
matches := youtubeEmbedRegex.FindStringSubmatch(link)
|
||||
if len(matches) == 2 {
|
||||
return config.Opts.YouTubeEmbedUrlOverride() + matches[1]
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return link
|
||||
}
|
||||
|
||||
switch strings.TrimPrefix(u.Hostname(), "www.") {
|
||||
case "youtube.com":
|
||||
if strings.HasPrefix(u.Path, "/embed/") {
|
||||
if len(u.RawQuery) > 0 {
|
||||
return config.Opts.YouTubeEmbedUrlOverride() + strings.TrimPrefix(u.Path, "/embed/") + "?" + u.RawQuery
|
||||
}
|
||||
return config.Opts.YouTubeEmbedUrlOverride() + strings.TrimPrefix(u.Path, "/embed/")
|
||||
}
|
||||
case "player.vimeo.com":
|
||||
// See https://help.vimeo.com/hc/en-us/articles/12426260232977-About-Player-parameters
|
||||
if strings.HasPrefix(u.Path, "/video/") {
|
||||
if len(u.RawQuery) > 0 {
|
||||
return link + "&dnt=1"
|
||||
}
|
||||
return link + "?dnt=1"
|
||||
}
|
||||
}
|
||||
|
||||
return link
|
||||
@@ -431,8 +436,7 @@ func sanitizeSrcsetAttr(baseURL, value string) string {
|
||||
imageCandidates := ParseSrcSetAttribute(value)
|
||||
|
||||
for _, imageCandidate := range imageCandidates {
|
||||
absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL)
|
||||
if err == nil {
|
||||
if absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL); err == nil {
|
||||
imageCandidate.ImageURL = absoluteURL
|
||||
}
|
||||
}
|
||||
@@ -457,10 +461,6 @@ func isValidDataAttribute(value string) bool {
|
||||
})
|
||||
}
|
||||
|
||||
func isAnchor(tagName string, attribute html.Attribute) bool {
|
||||
return tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#")
|
||||
}
|
||||
|
||||
func isPositiveInteger(value string) bool {
|
||||
if number, err := strconv.Atoi(value); err == nil {
|
||||
return number > 0
|
||||
@@ -468,16 +468,12 @@ func isPositiveInteger(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func getAttributeValue(name string, attributes []html.Attribute) string {
|
||||
func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
|
||||
for _, attribute := range attributes {
|
||||
if attribute.Key == name {
|
||||
return attribute.Val
|
||||
number, _ := strconv.Atoi(attribute.Val)
|
||||
return number
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
|
||||
number, _ := strconv.Atoi(getAttributeValue(name, attributes))
|
||||
return number
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -611,9 +611,9 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceIframeURL(t *testing.T) {
|
||||
func TestReplaceIframeVimedoDNTURL(t *testing.T) {
|
||||
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0"></iframe>`
|
||||
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
|
||||
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0&dnt=1" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
|
||||
@@ -17,12 +17,12 @@ type ImageCandidate struct {
|
||||
type ImageCandidates []*ImageCandidate
|
||||
|
||||
func (c ImageCandidates) String() string {
|
||||
var htmlCandidates []string
|
||||
htmlCandidates := make([]string, 0, len(c))
|
||||
|
||||
for _, imageCandidate := range c {
|
||||
var htmlCandidate string
|
||||
if imageCandidate.Descriptor != "" {
|
||||
htmlCandidate = fmt.Sprintf(`%s %s`, imageCandidate.ImageURL, imageCandidate.Descriptor)
|
||||
htmlCandidate = imageCandidate.ImageURL + " " + imageCandidate.Descriptor
|
||||
} else {
|
||||
htmlCandidate = imageCandidate.ImageURL
|
||||
}
|
||||
@@ -36,9 +36,7 @@ func (c ImageCandidates) String() string {
|
||||
// ParseSrcSetAttribute returns the list of image candidates from the set.
|
||||
// https://html.spec.whatwg.org/#parse-a-srcset-attribute
|
||||
func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidates) {
|
||||
unparsedCandidates := strings.Split(attributeValue, ", ")
|
||||
|
||||
for _, unparsedCandidate := range unparsedCandidates {
|
||||
for _, unparsedCandidate := range strings.Split(attributeValue, ", ") {
|
||||
if candidate, err := parseImageCandidate(unparsedCandidate); err == nil {
|
||||
imageCandidates = append(imageCandidates, candidate)
|
||||
}
|
||||
@@ -48,22 +46,20 @@ func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidate
|
||||
}
|
||||
|
||||
func parseImageCandidate(input string) (*ImageCandidate, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
parts := strings.Split(strings.TrimSpace(input), " ")
|
||||
nbParts := len(parts)
|
||||
|
||||
if nbParts > 2 || nbParts == 0 {
|
||||
return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
|
||||
}
|
||||
|
||||
if nbParts == 2 {
|
||||
switch {
|
||||
case nbParts == 1:
|
||||
return &ImageCandidate{ImageURL: parts[0]}, nil
|
||||
case nbParts == 2:
|
||||
if !isValidWidthOrDensityDescriptor(parts[1]) {
|
||||
return nil, fmt.Errorf(`srcset: invalid descriptor`)
|
||||
}
|
||||
return &ImageCandidate{ImageURL: parts[0], Descriptor: parts[1]}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
|
||||
}
|
||||
|
||||
return &ImageCandidate{ImageURL: parts[0]}, nil
|
||||
}
|
||||
|
||||
func isValidWidthOrDensityDescriptor(value string) bool {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// StripTags removes all HTML/XML tags from the input string.
|
||||
// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
|
||||
func StripTags(input string) string {
|
||||
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
|
||||
var buffer bytes.Buffer
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(input))
|
||||
var buffer strings.Builder
|
||||
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
|
||||
@@ -6,6 +6,7 @@ package scraper // import "miniflux.app/v2/internal/reader/scraper"
|
||||
// List of predefined scraper rules (alphabetically sorted)
|
||||
// domain => CSS selectors
|
||||
var predefinedRules = map[string]string{
|
||||
"arstechnica.com": "div.post-content",
|
||||
"bbc.co.uk": "div.vxp-column--single, div.story-body__inner, ul.gallery-images__list",
|
||||
"blog.cloudflare.com": "div.post-content",
|
||||
"cbc.ca": ".story-content",
|
||||
|
||||
@@ -75,7 +75,7 @@ func findContentUsingCustomRules(page io.Reader, rules string) (baseURL string,
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
|
||||
if hrefValue, exists := document.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeHostRegex = regexp.MustCompile(`youtube\.com$`)
|
||||
youtubeChannelRegex = regexp.MustCompile(`channel/(.*)$`)
|
||||
)
|
||||
|
||||
@@ -147,7 +146,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
|
||||
if hrefValue, exists := doc.Find("head base").First().Attr("href"); exists {
|
||||
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
websiteURL = hrefValue
|
||||
@@ -284,7 +283,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
if !strings.HasSuffix(decodedUrl.Host, "youtube.com") {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
@@ -303,7 +302,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubePlaylistPage(websiteURL
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
if !strings.HasSuffix(decodedUrl.Host, "youtube.com") {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// Timezones returns all timezones supported by the database.
|
||||
func (s *Storage) Timezones() (map[string]string, error) {
|
||||
timezones := make(map[string]string)
|
||||
rows, err := s.db.Query(`SELECT name FROM pg_timezone_names() ORDER BY name ASC`)
|
||||
rows, err := s.db.Query(`SELECT name FROM pg_timezone_names ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch timezones: %v`, err)
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
<h3>Miniflux</h3>
|
||||
<ul>
|
||||
<li><strong>{{ t "page.about.version" }}</strong> <a href="https://github.com/miniflux/v2/releases/tag/{{ .version }}">{{ .version }}</a></li>
|
||||
{{ if .commit }}
|
||||
<li><strong>Git Commit</strong> <a href="https://github.com/miniflux/v2/commit/{{ .commit }}">{{ .commit }}</a></li>
|
||||
{{ end }}
|
||||
<li><strong>{{ t "page.about.build_date" }}</strong> {{ .build_date }}</li>
|
||||
{{ if .user.IsAdmin }}<li><strong>{{ t "page.about.postgres_version" }}</strong> {{ .postgres_version }}</li>{{ end }}
|
||||
<li><strong>{{t "page.about.go_version" }}</strong> {{ .go_version }}</li>
|
||||
|
||||
@@ -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"
|
||||
{{ 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 hasPrefix .MimeType "video/" }}
|
||||
<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"
|
||||
{{ 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 }}
|
||||
{{ 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 }}
|
||||
{{ 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"
|
||||
{{ 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 hasPrefix .MimeType "video/" }}
|
||||
<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>
|
||||
{{ 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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
+15
-4
@@ -12,6 +12,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -19,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) {
|
||||
@@ -56,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
|
||||
}
|
||||
@@ -90,6 +92,10 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
req.Header.Set("Connection", "close")
|
||||
|
||||
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) != "" {
|
||||
@@ -140,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,
|
||||
@@ -69,7 +71,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
"MarkAsReadOnlyOnPlayerCompletion": form.MarkAsReadOnlyOnPlayerCompletion,
|
||||
})
|
||||
view.Set("themes", model.Themes())
|
||||
view.Set("languages", locale.AvailableLanguages())
|
||||
view.Set("languages", locale.AvailableLanguages)
|
||||
view.Set("timezones", timezones)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", settingsForm)
|
||||
view.Set("themes", model.Themes())
|
||||
view.Set("languages", locale.AvailableLanguages())
|
||||
view.Set("languages", locale.AvailableLanguages)
|
||||
view.Set("timezones", timezones)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", loggedUser)
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -53,7 +53,6 @@ a:hover {
|
||||
.sr-only {
|
||||
border: 0 !important;
|
||||
clip: rect(1px, 1px, 1px, 1px) !important;
|
||||
-webkit-clip-path: inset(50%) !important;
|
||||
clip-path: inset(50%) !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
@@ -427,7 +426,6 @@ input[type="number"] {
|
||||
line-height: 20px;
|
||||
width: 250px;
|
||||
font-size: 99%;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
appearance: none;
|
||||
}
|
||||
@@ -448,7 +446,8 @@ input[type="number"]:focus {
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-bottom: 15px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -675,6 +674,10 @@ template {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.webauthn {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Counters */
|
||||
.unread-counter-wrapper,
|
||||
.error-feeds-counter-wrapper {
|
||||
@@ -745,7 +748,7 @@ template {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.pagination-next {
|
||||
.pagination-next, .pagination-last {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -753,10 +756,6 @@ template {
|
||||
content: " ›";
|
||||
}
|
||||
|
||||
.pagination-last {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pagination-last:after {
|
||||
content: " »";
|
||||
}
|
||||
|
||||
@@ -751,11 +751,10 @@ function checkShareAPI(title, url) {
|
||||
title: title,
|
||||
url: url
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
window.location.reload();
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user