Compare commits

..

62 Commits

Author SHA1 Message Date
Frédéric Guillot d345c87376 docs(changelog): update release notes for version 2.2.4 2024-12-20 13:05:52 -08:00
jvoisin bd91e5f320 Add more referer spoofing
Based on #2261. For moyu.im/jandan.net, see https://github.com/DIYgod/RSSHub/issues/11528
2024-12-20 11:53:38 -08:00
dependabot[bot] 276b2d8b0b build(deps): bump golang.org/x/net from 0.32.0 to 0.33.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/net/compare/v0.32.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-18 17:41:28 -08:00
Sevi.C bca9bea676 feat: add date-based entry filtering rules 2024-12-16 20:38:20 -08:00
dependabot[bot] 7346d751cc build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.20 to 3.21.

---
updated-dependencies:
- dependency-name: library/alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 19:35:31 -08:00
jvoisin 7939b54341 Resize favicons to 32x32 to account of scaling
As suggested by @michaelkuhn in https://github.com/miniflux/v2/pull/2998#issuecomment-2546702212
2024-12-16 19:28:38 -08:00
jvoisin a06657b74d Factorise a line in internal/ui/static/js/app.js 2024-12-15 20:54:17 -08:00
jvoisin 2df59b4865 Refactor internal/reader/readability/testdata
- Use chained strings.Contains instead of a regex for
  blacklistCandidatesRegexp, as this is a bit faster
- Simplify a Find.Each.Remove to Find.Remove
- Don't concatenate id and class for removeUnlikelyCandidates, as it makes no
  sense to match on overlaps. It might also marginally improve performances, as
  regex now have to run on two strings separately, instead of both.
- Add a small benchmark
2024-12-15 20:52:32 -08:00
Julien Voisin 777d0dd248 feat: resize favicons before storing them
Some websites are using images of O(10kB) when not )O(100kB) for their
favicons. As miniflux only displays them with a 16x16 resolution, let's do our
best to resize them before storing them in the database. This should make
miniflux consume less bandwidth when serving pages, for the joy of mobile users
on a small data plan.

Of course, images that already are 16x16 aren't resized.
2024-12-15 20:47:19 -08:00
Julien Voisin cfda948c3a refactor(rewriter): avoid the use of regex in addDynamicImage
See https://dustri.org/b/parsing-noscript-tags-with-goquery.html for the whole
story.
2024-12-15 17:56:39 -08:00
jvoisin 14a6e8ed3a Factorise .pagination-next and .pagination-last together 2024-12-15 17:03:09 -08:00
jvoisin c3e842eba6 Remove -webkit-clip-path
https://caniuse.com/?search=clip-path says that `clip-path`
is supported since Safari 13.1
2024-12-15 17:03:09 -08:00
jvoisin fd9cfd757a Replace -ms-text-size-adjust with text-size-adjust
https://caniuse.com/?search=text-size-adjust says that
`ms-text-size-adjust` is supported in Edge.
2024-12-15 17:03:09 -08:00
Julien Voisin 945d436055 refactor(rewriter): replace regex with URL parsing for referrer override
No need for brittle regex when matching plain strings or domain names.
This should save some negligible amount of heap memory as well as
tremendously speeding up the matching.
2024-12-13 14:50:12 -08:00
Frédéric Guillot c3649bd6b1 refactor(rewrite): remove unused function arguments 2024-12-12 21:10:35 -08:00
Julien Voisin 6ad5ad0bb2 refactor(readability): various improvements and optimizations
- Replace a completely overkill regex
- Use `.Remove()` instead of a hand-rolled loop
- Use a strings.Builder instead of a bytes.NewBufferString
- Replace a call to Fprintf with string concatenation, as the latter are much
  faster
- Remove a superfluous cast
- Delay some computations
- Add some tests
2024-12-12 20:41:56 -08:00
Frédéric Guillot 113abeea59 test(rewrite): add unit test for referer rewrite function 2024-12-12 20:11:47 -08:00
Julien Voisin e6185b1393 refactor: use min/max instead of math.Min/math.Max
This saves a couple of back'n'forth casts.
2024-12-11 19:43:14 -08:00
Julien Voisin 1b0b8b9c42 refactor: use a better construct than doc.Find(…).First()
As mentioned in goquery's documentation (https://pkg.go.dev/github.com/PuerkitoBio/goquery#Single):

> By default, Selection.Find and other functions that accept a selector string
to select nodes will use all matches corresponding to that selector. By using
the Matcher returned by Single, at most the first match will be selected.
>
> The one using Single is optimized to be potentially much faster on large documents.
2024-12-11 19:40:55 -08:00
dependabot[bot] 68448b4abb build(deps): bump golang.org/x/crypto from 0.30.0 to 0.31.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.30.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-11 19:31:26 -08:00
Julien Voisin 3caa16ac31 refactor(processor): use URL parsing instead of a regex 2024-12-11 19:30:59 -08:00
Julien Voisin 637fb85de0 refactor(handler): delay store.UserByID as much as possible
In internal/reader/handler/handler.go:RefreshFeed, there is a call to
store.UserByID pretty early, which is only used for
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language)
Its only other usage is in processor.ProcessFeedEntries(store, originalFeed,
user, forceRefresh), which is pretty late in RefreshFeed, and only called if
there are new items in the feed. It makes sense to only fetch the user's
language if the error localization function is used.

Calls to `store.UserByID` take around 10% of the CPU time of RefreshFeed in my
profiling.

This commit also makes `processor.ProcessFeedEntries` take a `userID` instead
of a `user`, to make the code a bit more concise.

This should close #2984
2024-12-09 19:32:59 -08:00
Julien Voisin 02c6d14659 refactor(subscription): use strings.HasSuffix instead of a regex in FindSubscriptionsFromYouTubePlaylistPage 2024-12-09 17:19:28 -08:00
Julien Voisin 728423339a refactor(sanitizer): improve rewriteIframeURL()
- Use `url.Parse` instead of a regex, as this is much faster and way more robust
- Add support for Vimeo's Do Not Track parameter
2024-12-09 17:14:54 -08:00
Julien Voisin eed3fcf92a refactor(locale): delay parsing of translations until they're used
While doing some profiling for #2900, I noticed that
`miniflux.app/v2/internal/locale.LoadCatalogMessages` is responsible for more
than 10% of the consumed memory. As most miniflux instances won't have enough
diverse users to use all the available translations at the same time, it
makes sense to load them on demand.

The overhead is a single function call and a check in a map, per call to
translation-related functions.
2024-12-09 17:05:14 -08:00
Qeynos d5cfcf8956 feat(locale): update Chinese translation 2024-12-08 17:06:29 -08:00
Julien Voisin dea46ac0ea refactor: optimize sanitizeAttributes
- Use string concatenation instead of `Sprintf`, as this is much faster, and the
  call to `Sprintf` is responsible for 30% of the CPU time of the function
- Anchor the youtube regex, to allow it to bail early, as this also account for
  another 30% of the CPU time. It might be worth chaining calls to `TrimPrefix`
  and check if the string has been trimmed instead of using a regex, to speed
  things up even more, but this needs to be benchmarked properly.
2024-12-08 14:42:18 -08:00
Julien Voisin 30c44380e0 refactor: get rid of numberOfPluralFormsPerLanguage test-only variable
The `numberOfPluralFormsPerLanguage` variable is only used for tests, so it
should be declared in the test file.
2024-12-08 14:39:11 -08:00
Julien Voisin a913f3f75f feat(rewrite)!: remove parse_markdown rewrite rule
It was added in 2022 by #1513, to support blog.laravel.com, which has
since switched to HTML. The Atom 0.3/1.0, RSS 1.0/2.0, RDF, and JSON formats
don't support markdown in their spec, and any website serving it there should
be considered as buggy and fixed.

This shaves off 2MB from the miniflux binary, which is quite steep for a
feature that nobody is/should be using, and remove a dependency which is always
a good thing.
2024-12-08 14:34:47 -08:00
AiraNadih c1ef986cab fix(consistency): align feed modification behavior between API and UI 2024-12-08 10:49:14 -08:00
Julien Voisin 2671f57edd refactor(readability): simplify the regexes in internal/reader/readability/readability.go
- Use strings.ToLower() instead of having case-insensitive regex
- Remove overlapping words in the regex
- Split a condition to increase readability
2024-12-07 16:56:19 -08:00
jvoisin 2f56ebd3a6 Remove a now-useless function 2024-12-07 16:50:18 -08:00
jvoisin 059f5c0905 Inline a condition 2024-12-07 16:50:18 -08:00
jvoisin 58178d90cb Refactor Sanitize
- Use `token.String()` instead of `html.EscapeString(token.Data)`
- Refactor conditions to highlight their similitude, enabling further
  refactoring

This refactoring brings forth at least one bug: `tagStack` is never emptied.
2024-12-07 16:50:18 -08:00
jvoisin cc885bbabb config.Opts is guaranteed to never be nil 2024-12-07 16:50:18 -08:00
jvoisin 0e185849b4 Google+ isn't a thing anymore 2024-12-07 16:50:18 -08:00
jvoisin d0984f29da Simplify isValidTag 2024-12-07 16:50:18 -08:00
jvoisin 902ca63c45 Inline a function and fix a bug in it
The `isAnchor` function's first parameter was always `a`, instead of being
passed `tagName`. As this function is a single line and was only called in a
single place, it can be inlined.
2024-12-07 16:50:18 -08:00
jvoisin 2314500515 Merge two conditions 2024-12-07 16:50:18 -08:00
jvoisin 787d373211 Change the scope of a variable 2024-12-07 16:50:18 -08:00
Julien Voisin fefbf2c935 refactor(processor): improve the rewrite URL rule regex
- Use `[^"]` instead of `.`, to help the regex engine to determine boundaries,
  instead of having it bruteforce its way to find them
- Use `+` instead of `*`, as empty rules don't make sense
2024-12-07 16:35:51 -08:00
Julien Voisin bfb429b919 refactor(sanitizer): optimize internal/reader/sanitizer/strip_tags.go
- Use strings instead of doing string->bytes->string
- Use a strings.Builder to build the output
2024-12-07 16:31:48 -08:00
Julien Voisin 331c831c23 refactor(sanitizer): simplify hasRequiredAttributes
This function takes around 1.5% of the total CPU time on my instance, and most
of it is spent in `mapassign_faststr` to initialize the `map`. This is replaced
with a switch-case construct, that should be both significantly faster as well
as pretty dull in term of memory consumption.
2024-12-07 16:30:15 -08:00
Julien Voisin 92a49d7e69 refactor(sanitizer): micro-optimizations of internal/reader/sanitizer/srcset.go
- Pre-allocate a slice
- Inline a local variable
- Remove a superfluous call to `strings.TrimSpace`
- Simplify some conditions via a switch-case construct
2024-12-07 16:27:56 -08:00
mrchi 8cdf76df69 fix(linting): remove unnecessary blank line in PushEntries function 2024-12-07 16:19:53 -08:00
mrchi 7bc0bffd85 feat(apprise): update SendNotification to handle multiple entries and add logging 2024-12-07 16:19:53 -08:00
mrchi 3a18e5d205 feat(apprise): add title in notification request body 2024-12-07 16:19:53 -08:00
Gabe Cook c3ca603960 fix: load icon from site URL instead of feed URL 2024-12-07 16:06:26 -08:00
telnet23 7e2b50efee feat: optionally fetch watch time from YouTube API instead of website 2024-12-07 16:00:35 -08:00
Gabe Cook b61ee15c1b fix: feed icon from xml ignored during force refresh 2024-12-07 15:59:49 -08:00
Cthulhux b7b0ccbf4b feat(locale): update German translation to use Readeck URL 2024-12-06 15:13:21 -08:00
dependabot[bot] 9c5228b2ee build(deps): bump golang.org/x/net from 0.31.0 to 0.32.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.31.0 to 0.32.0.
- [Commits](https://github.com/golang/net/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-04 18:29:21 -08:00
dependabot[bot] 53beec685b build(deps): bump golang.org/x/term from 0.26.0 to 0.27.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/term/compare/v0.26.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-04 16:32:39 -08:00
Mohit Raj b1fb8be185 feat(locale): update translations to clarify readeck url instead of readeck api endpoint 2024-12-03 04:50:34 -08:00
Anshul Gupta 3a966c6ce5 fix: replace timezone function call with view
The `pg_timezone_names` view was added in 8.2.
It should be equivalent to the function query.
See: https://pgpedia.info/p/pg_timezone_names.html

This small change allows `miniflux` to run on postgres-compatible
databases like CockroachDB, which don't have this function.
2024-12-03 01:44:48 -08:00
dependabot[bot] b2e702218d build(deps): bump github.com/tdewolff/minify/v2 from 2.21.1 to 2.21.2
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.21.1 to 2.21.2.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.21.1...v2.21.2)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-03 01:33:05 -08:00
Gabe Cook 30c2e09a56 chore: remove blog.laravel.com rewrite rule 2024-12-03 01:21:42 -08:00
3zero2 c6c71c58b8 feat: add predefined scraper rules for arstechnica.com 2024-11-14 17:47:31 -08:00
Julien Voisin 6eb1f25a53 feat: only show the commit URL if it's not empty on /about 2024-11-12 20:09:48 -08:00
AiraNadih f0fe91172f feat(mediaProxy): update predefined referer spoofing rules for restricted media resources 2024-11-12 19:47:23 -08:00
Cthulhux c3016a4c55 fix: fix grammar in pull-request template 2024-11-11 19:55:01 -08:00
Cthulhux 3a028a0669 feat(locale): update German translations 2024-11-11 19:53:58 -08:00
57 changed files with 1041 additions and 567 deletions
+1 -1
View File
@@ -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
+61
View File
@@ -1,3 +1,64 @@
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)
---------------------------------
+8 -8
View File
@@ -11,13 +11,13 @@ require (
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.20.5
github.com/tdewolff/minify/v2 v2.21.1
github.com/yuin/goldmark v1.7.8
golang.org/x/crypto v0.29.0
golang.org/x/net v0.31.0
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.26.0
golang.org/x/text v0.20.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
)
require (
@@ -39,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.18 // indirect
github.com/tdewolff/parse/v2 v2.7.19 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/sys v0.28.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
+16 -16
View File
@@ -54,10 +54,10 @@ 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.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
github.com/tdewolff/parse/v2 v2.7.18 h1:uSqjEMT2lwCj5oifBHDcWU2kN1pbLrRENgFWDJa57eI=
github.com/tdewolff/parse/v2 v2.7.18/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/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=
@@ -66,12 +66,12 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
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.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
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=
@@ -79,8 +79,8 @@ 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.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
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=
@@ -93,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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.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.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
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=
+1
View File
@@ -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
-5
View File
@@ -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))
}
+18
View File
@@ -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/")
+9
View File
@@ -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,
}
+2
View File
@@ -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":
+39 -27
View File
@@ -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
+23 -30
View File
@@ -513,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",
@@ -541,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),
)
}
}
}
}
}
+12 -3
View File
@@ -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
+23 -3
View File
@@ -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
View File
@@ -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",
}
+1 -1
View File
@@ -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
View File
@@ -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) {
+133 -133
View File
@@ -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,7 +220,7 @@
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.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"
@@ -255,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.",
@@ -273,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.",
@@ -293,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.",
@@ -304,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.",
@@ -338,26 +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.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"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",
@@ -382,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",
@@ -399,66 +399,66 @@
"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.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML Datei",
"form.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",
@@ -466,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...",
@@ -556,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.",
@@ -574,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"
}
+1 -1
View File
@@ -488,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)",
+1 -1
View File
@@ -490,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 भेजें (पूर्ण सामग्री के बजाय)",
+1 -1
View File
@@ -500,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 (замість повного вмісту)",
+1 -1
View File
@@ -211,7 +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.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入用户名。",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey"
],
+1 -1
View File
@@ -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 -2
View File
@@ -123,8 +123,8 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
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())
}
}
+24 -18
View File
@@ -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)
@@ -105,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")
}
@@ -170,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)
@@ -195,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)
@@ -256,6 +241,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
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
@@ -263,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
@@ -289,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)
@@ -309,13 +306,17 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
)
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
@@ -335,6 +336,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
originalFeed.EtagHeader = responseHandler.ETag()
originalFeed.LastModifiedHeader = responseHandler.LastModified()
originalFeed.IconURL = updatedFeed.IconURL
iconChecker := icon.NewIconChecker(store, originalFeed)
if forceRefresh {
iconChecker.UpdateOrCreateFeedIcon()
@@ -358,6 +360,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
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
+5 -5
View File
@@ -34,32 +34,32 @@ func (c *IconChecker) fetchAndStoreIcon() {
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
iconFinder := NewIconFinder(requestBuilder, c.feed.FeedURL, c.feed.IconURL)
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.FeedURL),
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.FeedURL),
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.FeedURL),
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.FeedURL),
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),
+57
View File
@@ -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]",
+54
View File
@@ -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")
}
}
+9 -6
View File
@@ -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")
+9 -6
View File
@@ -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")
+58 -5
View File
@@ -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]
@@ -135,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":
@@ -205,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":
@@ -456,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 {
+66 -1
View File
@@ -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
+47 -43
View File
@@ -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
View File
@@ -0,0 +1 @@
../../reader/sanitizer/testdata/
+1 -1
View File
@@ -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)
}
})
}
}
+21 -41
View File
@@ -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
}
+6 -8
View File
@@ -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":
+2 -2
View File
@@ -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`,
+37 -37
View File
@@ -3,7 +3,10 @@
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
import "regexp"
import (
"net/url"
"strings"
)
// List of predefined rewrite rules (alphabetically sorted)
// Available rules: "add_image_title", "add_youtube_video"
@@ -12,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",
@@ -36,46 +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",
}
type RefererRule struct {
URLPattern *regexp.Regexp
Referer string
}
// List of predefined referer rules
var PredefinedRefererRules = []RefererRule{
{
URLPattern: regexp.MustCompile(`^https://\w+\.sinaimg\.cn`),
Referer: "https://weibo.com",
},
{
URLPattern: regexp.MustCompile(`^https://i\.pximg\.net`),
Referer: "https://www.pixiv.net",
},
{
URLPattern: regexp.MustCompile(`^https://cdnfile\.sspai\.com`),
Referer: "https://sspai.com",
},
{
URLPattern: regexp.MustCompile(`^https://(?:\w|-)+\.cdninstagram\.com`),
Referer: "https://www.instagram.com",
},
{
URLPattern: regexp.MustCompile(`^https://sp1\.piokok\.com`),
Referer: "https://sp1.piokok.com",
},
}
// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.
func GetRefererForURL(url string) string {
for _, rule := range PredefinedRefererRules {
if rule.URLPattern.MatchString(url) {
return rule.Referer
}
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 ""
}
+60 -64
View File
@@ -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
}
+2 -2
View File
@@ -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&amp;byline=0"></iframe>`
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&amp;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&amp;byline=0&amp;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 {
+9 -13
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+1
View File
@@ -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",
+1 -1
View File
@@ -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
+3 -4
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
@@ -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>
+1 -1
View File
@@ -71,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)
+1 -1
View File
@@ -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)
+2 -7
View File
@@ -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;
@@ -749,7 +748,7 @@ template {
padding-left: 15px;
}
.pagination-next {
.pagination-next, .pagination-last {
text-align: right;
}
@@ -757,10 +756,6 @@ template {
content: " ";
}
.pagination-last {
text-align: right;
}
.pagination-last:after {
content: " »";
}
+1 -2
View File
@@ -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() {
+2 -2
View File
@@ -155,7 +155,7 @@ func validateTheme(theme string) *locale.LocalizedError {
}
func validateLanguage(language string) *locale.LocalizedError {
languages := locale.AvailableLanguages()
languages := locale.AvailableLanguages
if _, found := languages[language]; !found {
return locale.NewLocalizedError("error.invalid_language")
}
@@ -219,7 +219,7 @@ func validateMediaPlaybackRate(mediaPlaybackRate float64) *locale.LocalizedError
func isValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
// Valid Format: FieldName=RegEx\nFieldName=RegEx...
fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag"}
fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag", "EntryDate"}
rules := strings.Split(filterEntryRules, "\n")
for i, rule := range rules {
+6 -1
View File
@@ -1,5 +1,5 @@
.\" Manpage for miniflux.
.TH "MINIFLUX" "1" "October 26, 2024" "\ \&" "\ \&"
.TH "MINIFLUX" "1" "December 7, 2024" "\ \&" "\ \&"
.SH NAME
miniflux \- Minimalist and opinionated feed reader
@@ -555,6 +555,11 @@ Number of background workers\&.
.br
Default is 16 workers\&.
.TP
.B YOUTUBE_API_KEY
YouTube API key for use with FETCH_YOUTUBE_WATCH_TIME. If nonempty, the duration will be fetched from the YouTube API. Otherwise, the duration will be fetched from the YouTube website\&.
.br
Default is empty\&.
.TP
.B YOUTUBE_EMBED_URL_OVERRIDE
YouTube URL which will be used for embeds\&.
.br
+1 -1
View File
@@ -4,7 +4,7 @@ ADD . /go/src/app
WORKDIR /go/src/app
RUN make miniflux
FROM docker.io/library/alpine:3.20
FROM docker.io/library/alpine:3.21
LABEL org.opencontainers.image.title=Miniflux
LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader"