Compare commits

...

129 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
Frédéric Guillot da951164d5 docs(changelog): update release notes for version 2.2.3 2024-11-10 15:53:03 -08:00
dependabot[bot] a0988f6c16 build(deps): bump golang.org/x/net from 0.30.0 to 0.31.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/net/compare/v0.30.0...v0.31.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-11-09 21:38:00 -08:00
dependabot[bot] fddc861e41 build(deps): bump golang.org/x/crypto from 0.28.0 to 0.29.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/crypto/compare/v0.28.0...v0.29.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-11-08 20:56:26 -08:00
dependabot[bot] 9288001f09 build(deps): bump golang.org/x/text from 0.19.0 to 0.20.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.19.0 to 0.20.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.19.0...v0.20.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 20:55:59 -08:00
dependabot[bot] da261a5fc7 build(deps): bump golang.org/x/term from 0.25.0 to 0.26.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/term/compare/v0.25.0...v0.26.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-11-08 19:47:31 -08:00
dependabot[bot] edeb5f0366 build(deps): bump golang.org/x/oauth2 from 0.23.0 to 0.24.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.23.0...v0.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-07 21:50:20 -08:00
Frédéric Guillot 65ff328804 fix: show only one player when there are several audio/video enclosures 2024-11-03 21:28:26 -08:00
John Tiesselune 7759ea1b43 feat(integration-shiori): updated shiori API to new endpoint for login/bookmark 2024-11-02 20:19:14 -07:00
Frédéric Guillot 7c5d6cf35f feat(mediaproxy): pass original filename in Content-Disposition header
When you download/save proxified media, the original filename is lost. That
information could be retained by passing a header `Content-Disposition: inline;
filename="ORIGNAL_FILENAME.EXT"` when serving the media file. The requested URL
would still be obfuscated, but if the client downloads the file it'll use that
original filename.
2024-10-30 20:44:19 -07:00
Frédéric Guillot 39cc1887ea fix: unable to change password due to a typo in SQL parameter 2024-10-30 19:58:37 -07:00
AiraNadih b0a3b4d5d9 style(mediaProxy): format with gofmt to pass linter checks 2024-10-30 19:50:12 -07:00
AiraNadih 469f23968e feat(mediaProxy): implement referer spoofing for restricted media resources 2024-10-30 19:50:12 -07:00
Frédéric Guillot 051bdecabd docs(changelog): update release notes for version 2.2.2 2024-10-29 20:08:17 -07:00
dependabot[bot] fe10d4302d build(deps): bump github.com/tdewolff/minify/v2 from 2.20.37 to 2.21.1
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.37 to 2.21.1.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.37...v2.21.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-28 17:13:38 -07:00
Matthaiks b9dfd5bf6d feat(locale): update Polish translations 2024-10-28 17:12:48 -07:00
Frédéric Guillot 51030ef1a8 feat(webauthn): show help message regarding username and non-discoverable credentials
The username is required for non-resident keys, but it's not necessary for discoverable credentials like Passkeys.
2024-10-26 21:49:23 -07:00
Frédéric Guillot 2bcc4b8399 fix(webauthn): add backup eligibility flag workaround to avoid a 401
Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.

This workaround to set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.

See https://github.com/go-webauthn/webauthn/pull/240
2024-10-26 18:36:38 -07:00
Matthaiks ea4d0a4f72 feat(locale): update Polish translation 2024-10-22 20:16:58 -07:00
Julien Voisin 5c5ad19c43 feat: replace xurls third-party module with an ad-hoc regexp 2024-10-21 20:48:12 -07:00
Frédéric Guillot 191f3a7ad7 feat(rss): calculate hash based on item title/content for feeds without GUID and link 2024-10-18 18:37:38 -07:00
Shaolong Chen 366928b35d feat(integration): add cubox integration
Signed-off-by: Shaolong Chen <shaolong.chen@outlook.it>
2024-10-18 18:06:09 -07:00
Frédéric Guillot 3b654fefa7 feat(api): rename integrations status endpoint 2024-10-17 21:29:44 -07:00
AiraNadih 0adbcc3a04 feat(api): add endpoint for user integration status 2024-10-17 20:59:05 -07:00
Frédéric Guillot 7fdb450446 Update issue templates 2024-10-17 20:27:10 -07:00
dependabot[bot] 86285f5a05 build(deps): bump github.com/yuin/goldmark from 1.7.7 to 1.7.8
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.7 to 1.7.8.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.7...v1.7.8)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-16 21:09:35 -07:00
dependabot[bot] 8a4b4e459e build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.4 to 1.20.5.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.4...v1.20.5)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-15 16:42:00 -07:00
dependabot[bot] 879446bdb2 build(deps): bump github.com/yuin/goldmark from 1.7.6 to 1.7.7
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.6 to 1.7.7.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.6...v1.7.7)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-15 16:36:56 -07:00
dependabot[bot] ef633dc427 build(deps): bump github.com/yuin/goldmark from 1.7.4 to 1.7.6
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.4 to 1.7.6.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.4...v1.7.6)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-14 18:18:38 -07:00
July 86c0cc61ba feat: set entry URL to rewritten URL if a rewrite rule is defined 2024-10-13 21:21:28 -07:00
dependabot[bot] ffe3ed4b9a build(deps): bump github.com/andybalholm/brotli from 1.1.0 to 1.1.1
Bumps [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) from 1.1.0 to 1.1.1.
- [Commits](https://github.com/andybalholm/brotli/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: github.com/andybalholm/brotli
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-11 20:13:37 -07:00
Frédéric Guillot 5c4df786de fix: avoid panic in IsRateLimited() function 2024-10-06 21:34:23 -07:00
Qeynos ee8c6621e9 feat(locale): update zh_CN translations 2024-10-06 20:12:00 -07:00
Matthaiks f748513df6 feat(locale): update Polish translations 2024-10-06 12:56:49 -07:00
Frédéric Guillot e1050e21b5 feat: take Retry-After header into consideration for rate limited feeds 2024-10-05 22:26:05 -07:00
Frédéric Guillot e555e442fb feat: add new settings option to allow external fonts 2024-10-05 21:21:12 -07:00
Frédéric Guillot 600dea6ce5 feat(client): add custom_js field to Go API client 2024-10-05 18:37:34 -07:00
milhnl e07203ad46 feat: add custom user JavaScript 2024-10-05 16:54:11 -07:00
Frédéric Guillot f16735fd6d feat: update feed icon during force refresh 2024-10-04 20:51:40 -07:00
Scott Leggett 562a7b79a5 fix: update Last-Modified if it changes in a 304 response
When a server returns a 304 response with a strong validator, any other
stored fields must be updated if they are also present in the response.

This behaviour is described in RFC9111, sections 3.2 and 4.3.4.
2024-10-04 17:47:48 -07:00
Scott Leggett cb610230d9 chore: update test case comment
The updated comment reflects a better understanding of the RFCs.
2024-10-04 17:47:48 -07:00
dependabot[bot] 628b2b388d build(deps): bump golang.org/x/net from 0.29.0 to 0.30.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/net/compare/v0.29.0...v0.30.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-10-04 17:19:04 -07:00
Frédéric Guillot faa70f3019 docs(changelog): update release notes for version 2.2.1 2024-09-27 19:20:06 -07:00
Frédéric Guillot cfe410f202 refactor: split processor package into smaller files 2024-09-22 18:54:19 -07:00
Qeynos c2ac2bfb83 feat: use Bilibili API instead of web scraping to get video watch time 2024-09-22 18:05:43 -07:00
Dark Dragon c326d5574b build: Bump devcontainer version to go 1.23 2024-09-21 20:56:56 -07:00
dependabot[bot] f1c8c060c0 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.3 to 1.20.4.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.3...v1.20.4)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-17 16:48:06 -07:00
Victorhck 6944fb1e50 feat(locale): update Spanish translations 2024-09-12 21:26:02 -07:00
Frédéric Guillot 7d21298fab fix(mediaproxy): forward client user-agent to origin to bypass bot protection 2024-09-11 21:01:56 -07:00
Frédéric Guillot 95201fc5cf build(deps): bump github.com/go-webauthn/webauthn from 0.10.2 to 0.11.2 2024-09-07 16:03:05 -07:00
dependabot[bot] 5e335995e1 build(deps): bump github.com/PuerkitoBio/goquery from 1.9.2 to 1.10.0
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.9.2 to 1.10.0.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.9.2...v1.10.0)

---
updated-dependencies:
- dependency-name: github.com/PuerkitoBio/goquery
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-07 15:51:55 -07:00
Frédéric Guillot 70f126fc5a build: update go.mod to Go 1.23 2024-09-07 15:27:20 -07:00
Michiel Janssens 38cdc4d3df feat(locale): update Dutch translations 2024-09-05 20:16:43 -07:00
dependabot[bot] 4ab1cdd2e9 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.2 to 1.20.3.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.20.3/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.2...v1.20.3)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-05 19:41:17 -07:00
dependabot[bot] f3e48505df build(deps): bump golang.org/x/net from 0.28.0 to 0.29.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/net/compare/v0.28.0...v0.29.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-09-05 19:40:53 -07:00
dependabot[bot] 60c75ab3b6 build(deps): bump golang.org/x/term from 0.23.0 to 0.24.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/term/compare/v0.23.0...v0.24.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-09-04 20:54:05 -07:00
dependabot[bot] 349f040921 build(deps): bump golang.org/x/oauth2 from 0.22.0 to 0.23.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.22.0...v0.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-04 20:28:21 -07:00
Ztec 74376cd33c fix: remove progression save on shared entry
Shared entry does not link to any user and therefore should not display
any saved progression. Curiously, the progression of a user (the one that shared ?)
was still integrated in the page. This does not make sens regarding the sharing
feature itself. It is also a leak of user personal information onto a public page.

I simply removed the data from the template when the user object is not present.
I tested the change on "regular" entry page, ensuring the save progression feature
still works, and on shared page checking if any error happened in the JavaScript console.
Everything seems in order.
2024-09-03 20:50:04 -07:00
Qeynos 2a4d2985c4 feat(locale): update zh_CN translations 2024-09-03 19:45:20 -07:00
Phantop 907941394b feat: add pagination to shared entries listing 2024-09-02 21:27:17 -07:00
Kierán Meinhardt 88ea0ade3e feat(locale): add dummy translations for menu.show_only_starred_entries 2024-09-02 21:23:17 -07:00
Kierán Meinhardt fcf9fde118 feat(locale): add translations for menu.show_only_starred_entries 2024-09-02 21:23:17 -07:00
Kierán Meinhardt 5c38688783 feat: add button to show only starred entries per category
fixes #1468
2024-09-02 21:23:17 -07:00
John e0850fc648 feat(locale): update Ukrainian translations 2024-09-02 19:32:03 -07:00
dependabot[bot] 8708a109b3 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.1 to 1.20.2.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.20.2/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.1...v1.20.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-29 05:09:52 -07:00
dependabot[bot] 0fe787bb93 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.0 to 1.20.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.20.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.0...v1.20.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-22 14:31:21 -07:00
Jonatas Baldin 2dffcfeadc fix: add datasource variable and upgrade depecrated panels on the grafana dashboard 2024-08-22 14:29:50 -07:00
Frédéric Guillot 4bbc12e3b2 fix: use root URL to generate absolute proxy URL
When using `BASE_URL` with a subfolder, the root URL must be used to
avoid base folder appearing twice in the generated URL.
2024-08-19 20:44:46 -07:00
118 changed files with 5081 additions and 3276 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go:1.22
image: mcr.microsoft.com/devcontainers/go:1.23
volumes:
- ..:/workspace:cached
command: sleep infinity
+1 -1
View File
@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
labels: wishlist
assignees: ''
---
+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
+5 -1
View File
@@ -32,7 +32,11 @@ jobs:
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v6
with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
args: >
--timeout 10m
--exclude-dirs=tests
--disable errcheck
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2024.1.1"
+124
View File
@@ -1,3 +1,127 @@
Version 2.2.4 (December 20, 2024)
---------------------------------
* test(rewrite): add unit test for referer rewrite function
* refactor(subscription): use `strings.HasSuffix` instead of a regex in `FindSubscriptionsFromYouTubePlaylistPage`
* refactor(sanitizer): use `token.String()` instead of `html.EscapeString(token.Data)`
* refactor(sanitizer): simplify `isValidTag`
* refactor(sanitizer): simplify `hasRequiredAttributes`
* refactor(sanitizer): remove condition because `config.Opts` is guaranteed to never be nil
* refactor(sanitizer): remove a now-useless function after refactoring
* refactor(sanitizer): refactor conditions to highlight their similitude, enabling further refactoring
* refactor(sanitizer): optimize `strip_tags.go`
* refactor(sanitizer): micro-optimizations of `srcset.go`
* refactor(sanitizer): merge two conditions
* refactor(sanitizer): inline a function in `sanitizeAttributes` and fix a bug in it
* refactor(sanitizer): inline a condition in `sanitizeSrcsetAttr`
* refactor(sanitizer): improve `rewriteIframeURL()`
* refactor(sanitizer): Google+ isn't a thing anymore
* refactor(sanitizer): change the scope of a variable
* refactor(rewriter): replace regex with URL parsing for referrer override
* refactor(rewriter): avoid the use of regex in `addDynamicImage`
* refactor(rewrite): remove unused function arguments
* refactor(readability): various improvements and optimizations
* refactor(readability): simplify the regexes in `readability.go`
* refactor(processor): use URL parsing instead of a regex
* refactor(processor): improve the `rewrite` URL rule regex
* refactor(locale): delay parsing of translations until they're used
* refactor(js): factorise a line in `app.js`
* refactor(handler): delay `store.UserByID()` as much as possible
* refactor(css): replace `-ms-text-size-adjust` with `text-size-adjust`
* refactor(css): remove `-webkit-clip-path`
* refactor(css): factorise `.pagination-next` and `.pagination-last` together
* refactor: use a better construct than `doc.Find(…).First()`
* refactor: use `min/max` instead of `math.Min/math.Max`
* refactor: refactor `internal/reader/readability/testdata`
* refactor: optimize `sanitizeAttributes`
* refactor: get rid of `numberOfPluralFormsPerLanguage` test-only variable
* fix(storage): replace timezone function call with view
* fix(consistency): align feed modification behavior between API and UI
* fix(ci): fix grammar in pull-request template
* fix: load icon from site URL instead of feed URL
* fix: feed icon from xml ignored during force refresh
* feat(rewrite)!: remove `parse_markdown` rewrite rule
* feat(mediaproxy): update predefined referer spoofing rules for restricted media resources
* feat(locale): update translations to clarify readeck URL instead of readeck API endpoint
* feat(locale): update German translations
* feat(locale): update Chinese translations
* feat(apprise): update `SendNotification` to handle multiple entries and add logging
* feat(apprise): add title in notification request body
* feat: resize favicons before storing them in the database
* feat: optionally fetch watch time from YouTube API instead of website
* feat: only show the commit URL if it's not empty on `/about`
* feat: add predefined scraper rules for `arstechnica.com`
* feat: add date-based entry filtering rules
* chore: remove `blog.laravel.com` rewrite rule
* build(deps): bump `library/alpine` in `/packaging/docker/alpine` to `3.21`
* build(deps): bump `golang.org/x/term` from `0.26.0` to `0.27.0`
* build(deps): bump `golang.org/x/net` from `0.31.0` to `0.33.0`
* build(deps): bump `golang.org/x/crypto` from `0.30.0` to `0.31.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.1` to `2.21.2`
Version 2.2.3 (November 10, 2024)
---------------------------------
* fix: unable to change password due to a typo in SQL parameter
* fix: show only one player when there are several audio/video enclosures
* feat(mediaproxy): pass original filename in `Content-Disposition` header
* feat(mediaproxy): implement referer spoofing for restricted media resources
* feat(integration): update Shiori integration to use new API endpoints for login/bookmark
* build(deps): bump `golang.org/x/text` from `0.19.0` to `0.20.0`
* build(deps): bump `golang.org/x/term` from `0.25.0` to `0.26.0`
* build(deps): bump `golang.org/x/oauth2` from `0.23.0` to `0.24.0`
* build(deps): bump `golang.org/x/net` from `0.30.0` to `0.31.0`
* build(deps): bump `golang.org/x/crypto` from `0.28.0` to `0.29.0`
Version 2.2.2 (October 29, 2024)
--------------------------------
* fix(webauthn): add backup eligibility flag workaround to avoid a 401 response
* fix: update `Last-Modified` if it changes in a 304 response
* feat(webauthn): show help message regarding username and non-discoverable credentials
* feat(rss): calculate hash based on item title/content for feeds without GUID and link
* feat(locale): update Chinese translations
* feat(locale): update Polish translations
* feat(integration): add Cubox integration
* feat(client): add `custom_js` field to Go API client
* feat(api): add endpoint for user integration status
* feat: update feed icon during force refresh
* feat: take `Retry-After` header into consideration for rate limited feeds
* feat: set entry URL to rewritten URL if a rewrite rule is defined
* feat: replace `xurls` third-party module with an ad-hoc regexp
* feat: add new settings option to allow external fonts
* feat: add custom user JavaScript similar to custom CSS
* chore: update test case comment
* build(deps): bump `golang.org/x/net` from `0.29.0` to `0.30.0`
* build(deps): bump `github.com/yuin/goldmark` from `1.7.4` to `1.7.8`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.37` to `2.21.1`
* build(deps): bump `github.com/prometheus/client_golang`
* build(deps): bump `github.com/andybalholm/brotli` from `1.1.0` to `1.1.1`
Version 2.2.1 (September 28, 2024)
----------------------------------
* refactor: split processor package into smaller files
* fix(mediaproxy): forward client user-agent to origin to bypass bot protection
* fix: use root URL to generate absolute proxy URL
* fix: remove progression save on shared entry
* fix: add datasource variable and upgrade depecrated panels on the Grafana dashboard
* feat(locale): update zh_CN translations
* feat(locale): update Ukrainian translations
* feat(locale): update Spanish translations
* feat(locale): update Dutch translations
* feat: use Bilibili API instead of web scraping to get videos watch time
* feat: add pagination to shared entries listing
* feat: add button to show only starred entries per category
* build(deps): bump `golang.org/x/term` from `0.23.0` to `0.24.0`
* build(deps): bump `golang.org/x/oauth2` from `0.22.0` to `0.23.0`
* build(deps): bump `golang.org/x/net` from `0.28.0` to `0.29.0`
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.9.2` to `1.10.0`
* build(deps): bump `github.com/prometheus/client_golang` from `1.20.3` to `1.20.4`
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.10.2` to `0.11.2`
* build: update go.mod to Go 1.23
* build: bump devcontainer version to go 1.23
Version 2.2.0 (August 18, 2024)
-------------------------------
+19
View File
@@ -185,6 +185,25 @@ func (c *Client) MarkAllAsRead(userID int64) error {
return err
}
// IntegrationsStatus fetches the integrations status for the logged user.
func (c *Client) IntegrationsStatus() (bool, error) {
body, err := c.request.Get("/v1/integrations/status")
if err != nil {
return false, err
}
defer body.Close()
var response struct {
HasIntegrations bool `json:"has_integrations"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return false, fmt.Errorf("miniflux: response error (%v)", err)
}
return response.HasIntegrations, nil
}
// Discover try to find subscriptions from a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
+4
View File
@@ -27,6 +27,7 @@ type User struct {
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
@@ -44,6 +45,7 @@ type User struct {
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
}
func (u User) String() string {
@@ -70,6 +72,7 @@ type UserModificationRequest struct {
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
@@ -86,6 +89,7 @@ type UserModificationRequest struct {
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
}
// Users represents a list of users.
File diff suppressed because it is too large Load Diff
+18 -19
View File
@@ -1,37 +1,36 @@
module miniflux.app/v2
// +heroku goVersion go1.22
// +heroku goVersion go1.23
require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/PuerkitoBio/goquery v1.10.0
github.com/abadojack/whatlanggo v1.0.1
github.com/andybalholm/brotli v1.1.0
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-webauthn/webauthn v0.10.2
github.com/go-webauthn/webauthn v0.11.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.20.0
github.com/tdewolff/minify/v2 v2.20.37
github.com/yuin/goldmark v1.7.4
golang.org/x/crypto v0.26.0
golang.org/x/net v0.28.0
golang.org/x/oauth2 v0.22.0
golang.org/x/term v0.23.0
golang.org/x/text v0.17.0
mvdan.cc/xurls/v2 v2.5.0
github.com/prometheus/client_golang v1.20.5
github.com/tdewolff/minify/v2 v2.21.2
golang.org/x/crypto v0.31.0
golang.org/x/image v0.23.0
golang.org/x/net v0.33.0
golang.org/x/oauth2 v0.24.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
)
require (
github.com/go-webauthn/x v0.1.9 // indirect
github.com/go-webauthn/x v0.1.14 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tpm v0.9.1 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
@@ -40,10 +39,10 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/tdewolff/parse/v2 v2.7.19 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/sys v0.28.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
go 1.22
go 1.23
+34 -34
View File
@@ -1,9 +1,9 @@
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -14,20 +14,20 @@ github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -44,8 +44,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI=
github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
@@ -54,22 +54,24 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.20.37 h1:Q97cx4STXCh1dlWDlNHZniE8BJ2EBL0+2b0n92BJQhw=
github.com/tdewolff/minify/v2 v2.20.37/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -77,10 +79,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -91,21 +93,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.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.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -115,5 +117,3 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
+1
View File
@@ -74,6 +74,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
}
+108
View File
@@ -592,6 +592,88 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
}
}
func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.ExternalFontHosts != "fonts.example.org" {
t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
}
}
func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
t.Fatal(`Updating the user with an invalid external font host should raise an error`)
}
}
func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.CustomJS != "alert('Hello, World!');" {
t.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)
}
}
func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -2401,6 +2483,32 @@ func TestSaveEntryEndpoint(t *testing.T) {
}
}
func TestFetchIntegrationsStatusEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
hasIntegrations, err := regularUserClient.IntegrationsStatus()
if err != nil {
t.Fatalf("Failed to fetch integrations status: %v", err)
}
if hasIntegrations {
t.Fatalf("New user should not have integrations configured")
}
}
func TestFetchContentEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+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
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if !h.store.HasIcon(feedID) {
if !h.store.HasFeedIcon(feedID) {
json.NotFound(w, r)
return
}
+19
View File
@@ -130,6 +130,25 @@ func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
json.NoContent(w, r)
}
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
json.NotFound(w, r)
return
}
hasIntegrations := h.store.HasSaveEntry(userID)
response := struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: hasIntegrations,
}
json.OK(w, r, response)
}
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
-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":
+18
View File
@@ -942,4 +942,22 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
`
_, err = tx.Exec(sql)
return err
},
}
+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
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Cubox API documentation: https://help.cubox.cc/save/api/
package cubox // import "miniflux.app/v2/internal/integration/cubox"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiLink string
}
func NewClient(apiLink string) *Client {
return &Client{apiLink: apiLink}
}
func (c *Client) SaveLink(entryURL string) error {
if c.apiLink == "" {
return errors.New("cubox: missing API link")
}
requestBody, err := json.Marshal(&card{
Type: "url",
Content: entryURL,
})
if err != nil {
return fmt.Errorf("cubox: unable to encode request body: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("cubox: unable to create request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
defer response.Body.Close()
if response.StatusCode != 200 {
return fmt.Errorf("cubox: unable to save link: status=%d", response.StatusCode)
}
return nil
}
type card struct {
Type string `json:"type"`
Content string `json:"content"`
}
+43 -30
View File
@@ -9,6 +9,7 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkace"
@@ -322,6 +323,25 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.CuboxEnabled {
slog.Debug("Sending entry to Cubox",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := cubox.NewClient(userIntegrations.CuboxAPILink)
if err := client.SaveLink(entry.URL); err != nil {
slog.Error("Unable to send entry to Cubox",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ShioriEnabled {
slog.Debug("Sending entry to Shiori",
slog.Int64("user_id", userIntegrations.UserID),
@@ -493,8 +513,30 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
}
}
if userIntegrations.AppriseEnabled {
slog.Debug("Sending new entries to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int("nb_entries", len(entries)),
slog.Int64("feed_id", feed.ID),
)
appriseServiceURLs := userIntegrations.AppriseServicesURL
if feed.AppriseServiceURLs != "" {
appriseServiceURLs = feed.AppriseServiceURLs
}
client := apprise.NewClient(
appriseServiceURLs,
userIntegrations.AppriseURL,
)
if err := client.SendNotification(feed, entries); err != nil {
slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
}
}
// Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
if userIntegrations.TelegramBotEnabled {
for _, entry := range entries {
if userIntegrations.TelegramBotEnabled {
slog.Debug("Sending a new entry to Telegram",
@@ -521,35 +563,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
)
}
}
if userIntegrations.AppriseEnabled {
slog.Debug("Sending a new entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("apprise_url", userIntegrations.AppriseURL),
)
appriseServiceURLs := userIntegrations.AppriseServicesURL
if feed.AppriseServiceURLs != "" {
appriseServiceURLs = feed.AppriseServiceURLs
}
client := apprise.NewClient(
appriseServiceURLs,
userIntegrations.AppriseURL,
)
if err := client.SendNotification(entry); err != nil {
slog.Error("Unable to send entry to Apprise",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("apprise_url", userIntegrations.AppriseURL),
slog.Any("error", err),
)
}
}
}
}
}
+26 -12
View File
@@ -31,7 +31,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf("shiori: missing base URL, username or password")
}
sessionID, err := c.authenticate()
token, err := c.authenticate()
if err != nil {
return fmt.Errorf("shiori: unable to authenticate: %v", err)
}
@@ -44,7 +44,11 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
requestBody, err := json.Marshal(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
})
if err != nil {
@@ -58,7 +62,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Session-Id", sessionID)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
@@ -75,13 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return nil
}
func (c *Client) authenticate() (sessionID string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
func (c *Client) authenticate() (token string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
if err != nil {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
@@ -111,21 +115,31 @@ func (c *Client) authenticate() (sessionID string, err error) {
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
}
return authResponse.SessionID, nil
return authResponse.Message.Token, nil
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
RememberMe bool `json:"remember_me"`
}
type authResponse struct {
OK bool `json:"ok"`
Message authResponseMessage `json:"message"`
}
type authResponseMessage struct {
SessionID string `json:"session"`
Token string `json:"token"`
}
type addBookmarkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"createArchive"`
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"create_archive"`
CreateEbook bool `json:"create_ebook"`
Public int `json:"public"`
Excerpt string `json:"excerpt"`
Tags []string `json:"tags"`
}
+12 -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) {
+136 -128
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",
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Alle als gelesen markieren",
"menu.show_all_entries": "Zeige alle Artikel",
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
"menu.refresh_feed": "Aktualisieren",
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
"menu.edit_feed": "Bearbeiten",
@@ -92,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.",
@@ -118,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",
@@ -136,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",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.login.webauthn_login.help": "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"
@@ -253,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.",
@@ -271,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.",
@@ -291,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.",
@@ -302,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.",
@@ -336,24 +338,26 @@
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.scraper_rules": "Extraktionsregeln",
"form.feed.label.rewrite_rules": "Umschreiberegeln",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise Service-URLs",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
"form.feed.label.blocklist_rules": "Blockierregeln",
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
"form.feed.label.ntfy_max_priority": "Höchste Ntfy-Priorität",
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
"form.feed.label.ntfy_min_priority": "Niedrigste Ntfy-Priorität",
"form.integration.cubox_activate": "Einträge in Cubox speichern",
"form.integration.cubox_api_link": "Cubox-API-Link",
"form.feed.fieldset.general": "Allgemein",
"form.feed.fieldset.rules": "Regeln",
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
@@ -378,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",
@@ -390,67 +394,71 @@
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
"form.prefs.label.custom_js": "Benutzerdefiniertes JavaScript",
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
"form.prefs.label.default_home_page": "Standard-Startseite",
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.import.label.file": "OPML Datei",
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Ungültige Domainliste. Bitte geben Sie eine per Leerzeichen getrennte Liste von Domains an.",
"form.import.label.file": "OPML-Datei",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_url": "Betula server URL",
"form.integration.betula_token": "Betula Token",
"form.integration.fever_activate": "Fever API aktivieren",
"form.integration.fever_username": "Fever Benutzername",
"form.integration.fever_password": "Fever Passwort",
"form.integration.fever_endpoint": "Fever API Endpunkt:",
"form.integration.googlereader_activate": "Google Reader API aktivieren",
"form.integration.googlereader_username": "Google Reader Benutzername",
"form.integration.googlereader_password": "Google Reader Passwort",
"form.integration.googlereader_endpoint": "Google Reader API-Endpunkt:",
"form.integration.betula_activate": "Einträge in Betula speichern",
"form.integration.betula_url": "Betula-Server-URL",
"form.integration.betula_token": "Betula-Token",
"form.integration.fever_activate": "Fever-API aktivieren",
"form.integration.fever_username": "Fever-Benutzername",
"form.integration.fever_password": "Fever-Passwort",
"form.integration.fever_endpoint": "Fever-API-Endpunkt:",
"form.integration.googlereader_activate": "Google-Reader-API aktivieren",
"form.integration.googlereader_username": "Google-Reader-Benutzername",
"form.integration.googlereader_password": "Google-Reader-Passwort",
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
"form.integration.pinboard_token": "Pinboard API-Token",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_token": "Pinboard-API-Token",
"form.integration.pinboard_tags": "Pinboard-Tags",
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
"form.integration.instapaper_username": "Instapaper Benutzername",
"form.integration.instapaper_password": "Instapaper Passwort",
"form.integration.instapaper_username": "Instapaper-Benutzername",
"form.integration.instapaper_password": "Instapaper-Passwort",
"form.integration.pocket_activate": "Einträge in Pocket speichern",
"form.integration.pocket_consumer_key": "Pocket Verbraucher-Schlüssel",
"form.integration.pocket_access_token": "Pocket Zugangs-Token",
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket Konto",
"form.integration.pocket_consumer_key": "Pocket-Verbraucher-Schlüssel",
"form.integration.pocket_access_token": "Pocket-Zugangstoken",
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Geheimnis",
"form.integration.wallabag_username": "Wallabag Benutzername",
"form.integration.wallabag_password": "Wallabag Passwort",
"form.integration.wallabag_endpoint": "Wallabag-URL",
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
"form.integration.wallabag_username": "Wallabag-Benutzername",
"form.integration.wallabag_password": "Wallabag-Passwort",
"form.integration.notion_activate": "Einträge in Notion speichern",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Geheimnis-Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise service URLs",
"form.integration.notion_page_id": "Notion-Page-ID",
"form.integration.notion_token": "Notion-Geheimnis-Token",
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
"form.integration.apprise_url": "Apprise-API-URL",
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
"form.integration.omnivore_url": "Omnivore API-Endpunkt",
"form.integration.omnivore_api_key": "Omnivore API-Schlüssel",
"form.integration.espial_activate": "Einträge in Espial",
"form.integration.espial_endpoint": "Espial API-Endpunkt",
"form.integration.espial_api_key": "Espial API-Schlüssel",
"form.integration.espial_tags": "Espial Tags",
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
"form.integration.espial_activate": "Einträge in Espial speichern",
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
"form.integration.espial_api_key": "Espial-API-Schlüssel",
"form.integration.espial_tags": "Espial-Tags",
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
"form.integration.readwise_api_key": "Readwise Reader Zugangs-Token",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise Zugangs-Token",
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
"form.integration.telegram_bot_token": "Bot-Token",
"form.integration.telegram_chat_id": "Chat-ID",
@@ -458,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...",
@@ -548,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.",
@@ -566,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"
}
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
"menu.refresh_feed": "Ανανέωση",
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
"menu.edit_feed": "Επεξεργασία",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Αφαιρέστε %d κωδικό πρόσβασης",
"Καταργήστε %d κωδικούς πρόσβασης"
@@ -358,6 +360,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.category.label.title": "Τίτλος",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.user.label.username": "Χρήστης",
@@ -390,6 +394,7 @@
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
"form.prefs.label.custom_js": "Προσαρμοσμένο JavaScript",
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
@@ -401,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Αρχείο OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+9 -1
View File
@@ -40,6 +40,7 @@
"menu.mark_page_as_read": "Mark this page as read",
"menu.mark_all_as_read": "Mark all as read",
"menu.show_all_entries": "Show all entries",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.show_only_unread_entries": "Show only unread entries",
"menu.refresh_feed": "Refresh",
"menu.refresh_all_feeds": "Refresh all feeds in the background",
@@ -228,6 +229,7 @@
"page.login.oidc_signin": "Sign in with %s",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.integrations.title": "Integrations",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpoint",
@@ -390,6 +392,7 @@
"form.prefs.label.gesture_nav": "Gesture to navigate between entries",
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
"form.prefs.label.custom_css": "Custom CSS",
"form.prefs.label.custom_js": "Custom JavaScript",
"form.prefs.label.entry_order": "Entry sorting column",
"form.prefs.label.default_home_page": "Default home page",
"form.prefs.label.categories_sorting_order": "Categories sorting",
@@ -401,6 +404,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML file",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
@@ -482,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)",
@@ -505,6 +511,8 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading…",
"form.submit.saving": "Saving…",
+146 -138
View File
@@ -1,5 +1,5 @@
{
"skip_to_content": "Skip to content",
"skip_to_content": "Saltar al contenido",
"confirm.question": "¿Estás seguro?",
"confirm.question.refresh": "¿Quieres forzar la actualización?",
"confirm.yes": "sí",
@@ -9,8 +9,8 @@
"action.save": "Guardar",
"action.or": "o",
"action.cancel": "Cancelar",
"action.remove": "Quitar",
"action.remove_feed": "Quitar esta fuente",
"action.remove": "Eliminar",
"action.remove_feed": "Eliminar esta fuente",
"action.update": "Actualizar",
"action.edit": "Editar",
"action.download": "Descargar",
@@ -19,8 +19,8 @@
"action.home_screen": "Añadir a la pantalla principal",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.title": "Menú",
"menu.home_page": "Página de inicio",
"menu.unread": "No leídos",
"menu.starred": "Marcadores",
"menu.history": "Historial",
@@ -41,8 +41,9 @@
"menu.mark_all_as_read": "Marcar todos como leídos",
"menu.show_all_entries": "Mostrar todos los artículos",
"menu.show_only_unread_entries": "Mostrar solo los artículos no leídos",
"menu.show_only_starred_entries": "Mostrar solo los artículos marcados con una estrella",
"menu.refresh_feed": "Refrescar",
"menu.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
"menu.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
"menu.edit_feed": "Editar",
"menu.edit_category": "Editar",
"menu.add_feed": "Agregar fuente",
@@ -54,8 +55,8 @@
"menu.shared_entries": "Artículos compartidos",
"search.label": "Buscar",
"search.placeholder": "Búsqueda...",
"search.submit": "Search",
"pagination.last": "Ultimo",
"search.submit": "Buscar",
"pagination.last": "Último",
"pagination.next": "Siguiente",
"pagination.first": "Primero",
"pagination.previous": "Anterior",
@@ -92,22 +93,22 @@
"entry.tags.label": "Etiquetas:",
"page.shared_entries.title": "Artículos compartidos",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d artículo compartido",
"%d artículos compartidos"
],
"page.unread.title": "No leídos",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d artículo no leído",
"%d artículos no leídos"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"%d artículo en total",
"%d artículos en total"
],
"page.starred.title": "Marcadores",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d artículo marcado",
"%d artículos marcados"
],
"page.categories.title": "Categorías",
"page.categories.no_feed": "Sin fuente.",
@@ -118,17 +119,17 @@
"Hay %d fuentes."
],
"page.categories_count": [
"%d category",
"%d categories"
"%d categoría",
"%d categorías"
],
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_user.title": "Editar usuario: %s",
"page.feeds.title": "Fuentes",
"page.category_label": "Category: %s",
"page.category_label": "Categoría: %s",
"page.feeds.last_check": "Última verificación:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Próxima verificación:",
"page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.error_count": [
"%d error",
@@ -136,15 +137,15 @@
],
"page.history.title": "Historial",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d artículo leído",
"%d artículos leídos"
],
"page.import.title": "Importar",
"page.search.title": "Resultados de la búsqueda",
"page.about.title": "Acerca de",
"page.about.credits": "Créditos",
"page.about.version": "Versión:",
"page.about.build_date": "Fecha de construcción:",
"page.about.build_date": "Fecha de compilación:",
"page.about.author": "Autor:",
"page.about.license": "Licencia:",
"page.about.global_config_options": "Opciones de configuración global",
@@ -189,7 +190,7 @@
"page.keyboard_shortcuts.open_comments_same_window": "Abrir enlace de comentarios en la pestaña actual",
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída",
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
@@ -212,22 +213,23 @@
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
"page.settings.link_oidc_account": "Vincular mi cuenta de %s",
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkeys": "Claves de acceso",
"page.settings.webauthn.actions": "Accioness",
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
"page.settings.webauthn.added_on": "Añadido",
"page.settings.webauthn.last_seen_on": "Usado por última vez",
"page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de paso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Eliminar %d clave de paso",
"Eliminar %d claves de paso"
"Eliminar %d clave de acceso",
"Eliminar %d claves de acceso"
],
"page.login.title": "Iniciar sesión",
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de %s",
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso",
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
"page.integrations.title": "Integraciones",
"page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Extremo de API",
@@ -255,7 +257,7 @@
"page.offline.title": "Modo offline",
"page.offline.message": "Estas desconectado",
"page.offline.refresh_page": "Intenta actualizar la página",
"page.webauthn_rename.title": "Rename Passkey",
"page.webauthn_rename.title": "Renombrar clave de acceso",
"alert.no_shared_entry": "No hay artículos compartidos.",
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
@@ -295,14 +297,14 @@
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_fieldname_invalid": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
"error.settings_keep_rule_separator_required": "Regla de mantenimiento no válida: el patrón de la regla #%d debe estar separado por un '='",
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
"error.entries_per_page_invalid": "El número de artículos por página no es válido.",
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.feed_already_exists": "Este feed ya existe.",
@@ -336,28 +338,30 @@
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.scraper_rules": "Reglas de extracción de información",
"form.feed.label.rewrite_rules": "Reglas de reescribir",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
"form.feed.label.disabled": "No actualice este feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
"form.feed.label.ntfy_max_priority": "Prioridad máxima a Ntfy",
"form.feed.label.ntfy_high_priority": "Prioridad alta a Ntfy",
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
"form.feed.label.ntfy_low_priority": "Prioridad baja a Ntfy",
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.rules": "Reglas",
"form.feed.fieldset.network_settings": "Ajustes de red",
"form.feed.fieldset.integration": "Servicios de terceros",
"form.category.label.title": "Título",
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.user.label.username": "Nombre de usuario",
@@ -390,22 +394,26 @@
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
"form.prefs.label.custom_css": "CSS personalizado",
"form.prefs.label.custom_js": "JavaScript personalizado",
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
"form.prefs.label.default_home_page": "Página de inicio por defecto",
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
"form.prefs.label.mark_read_on_media_completion": "Marcar como leído solo cuando la reproducción de audio/video alcance el 90%% de finalización",
"form.prefs.label.mark_read_manually": "Marcar entradas como leídas manualmente",
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Archivo OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_url": "Betula server URL",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_activate": "Guardar artículos en Betula",
"form.integration.betula_url": "URL del servidor Betula",
"form.integration.betula_token": "Token de Betula",
"form.integration.fever_activate": "Activar API de Fever",
"form.integration.fever_username": "Nombre de usuario de Fever",
"form.integration.fever_password": "Contraseña de Fever",
@@ -432,12 +440,12 @@
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
"form.integration.wallabag_password": "Contraseña de Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.notion_activate": "Guardar entradas en Notion",
"form.integration.notion_page_id": "ID de página de Notion",
"form.integration.notion_token": "Token secreto de Notion",
"form.integration.apprise_activate": "Enviar artículos a Apprise",
"form.integration.apprise_url": "URL de la API de Apprise",
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
@@ -448,26 +456,26 @@
"form.integration.espial_endpoint": "Acceso API de Espial",
"form.integration.espial_api_key": "Clave de API de Espial",
"form.integration.espial_tags": "Etiquetas de Espial",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram",
"form.integration.telegram_bot_token": "Token de bot",
"form.integration.telegram_chat_id": "ID de chat",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
"form.integration.telegram_bot_disable_notification": "Deshabilitar notificación",
"form.integration.telegram_bot_disable_buttons": "Deshabilitar botones",
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_api_key": "Clave API de LinkAce",
"form.integration.linkace_tags": "Etiquetas de LinkAce",
"form.integration.linkace_is_private": "Marcar enlace como privado",
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
"form.integration.linkding_activate": "Enviar artículos a Linkding",
"form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_api_key": "Clave de API de Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_tags": "Etiquetas de Linkding",
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
@@ -477,34 +485,34 @@
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_collection_id": "Colección ID",
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
"form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_labels": "Etiquetas de Readeck",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.shiori_activate": "Guardar artículos a Shiori",
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
"form.integration.shiori_username": "Nombre de usuario de Shiori",
"form.integration.shiori_password": "Contraseña de Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_topic": "Ntfy topic",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
"form.integration.shaarli_endpoint": "URL de Shaarli",
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
"form.integration.webhook_activate": "Habilitar Webhook",
"form.integration.webhook_url": "URL de Webhook",
"form.integration.webhook_secret": "Secreto de Webhook",
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
"form.integration.ntfy_topic": "Tema Ntfy",
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
"form.api_key.label.description": "Etiqueta de clave API",
"form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...",
@@ -536,43 +544,43 @@
"hace %d años"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
"error.http_response_too_large": "La respuesta HTTP es demasiado grande. Puede aumentar el límite de tamaño de respuesta HTTP en la configuración global (requiere reiniciar el servidor).",
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
"error.tls_error": "Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.",
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
"error.http_client_error": "Error cliente HTTP: %v.",
"error.http_not_authorized": "El acceso a este sitio web no está autorizado. Podría ser un nombre de usuario o contraseña incorrectos.",
"error.http_too_many_requests": "Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.",
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
"error.http_resource_not_found": "No se encuentra el recurso solicitado. Por favor, verifique la URL.",
"error.http_internal_server_error": "El sitio web no está disponible en estos momentos debido a un error del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_service_unavailable": "El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_gateway_timeout": "El sitio web no está disponible en este momento debido a un error de tiempo de espera de la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_unexpected_status_code": "El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.database_error": "Error en la base de datos: %v.",
"error.category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
"error.duplicated_feed": "Este feed ya existe.",
"error.unable_to_parse_feed": "No se puede analizar este feed: %v.",
"error.feed_not_found": "Este feed no existe o no pertenece a este usuario.",
"error.unable_to_detect_rssbridge": "No se puede detectar la fuente usando RSS-Bridge: %v.",
"error.feed_format_not_detected": "No se puede detectar el formato del feed: %v.",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
"enclosure_media_controls.seek" : "Buscar:",
"enclosure_media_controls.seek.title" : "Buscar %s segundos",
"enclosure_media_controls.speed" : "Velocidad:",
"enclosure_media_controls.speed.faster" : "Más rápido",
"enclosure_media_controls.speed.faster.title" : "Más rápido a %sx",
"enclosure_media_controls.speed.slower" : "Despacio",
"enclosure_media_controls.speed.slower.title" : "Más despacio a %sx",
"enclosure_media_controls.speed.reset" : "Restablecer",
"enclosure_media_controls.speed.reset.title" : "Restablecer la velocidad a 1x"
}
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
"menu.show_all_entries": "Näytä kaikki artikkelit",
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "Päivitä",
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
"menu.edit_feed": "Muokkaa",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Poista %d salasana",
"Poista %d salasanaa"
@@ -354,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -390,6 +394,7 @@
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
"form.prefs.label.custom_css": "Mukautettu CSS",
"form.prefs.label.custom_js": "Mukautettu JavaScript",
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
@@ -401,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML-tiedosto",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Tout marquer comme lu",
"menu.show_all_entries": "Afficher tous les articles",
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus",
"menu.show_only_starred_entries": "Afficher uniquement les favoris",
"menu.refresh_feed": "Actualiser",
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
"menu.edit_feed": "Modifier",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.register": "Enregister une nouvelle clé daccès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès",
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
"page.settings.webauthn.delete": [
"Supprimer %d clé daccès",
"Supprimer %d clés daccès"
@@ -354,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
"form.feed.label.ntfy_low_priority": "Priorité basse de notification",
"form.feed.label.ntfy_min_priority": "Priorité minimale de notification",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "Général",
"form.feed.fieldset.rules": "Règles",
"form.feed.fieldset.network_settings": "Paramètres réseau",
@@ -390,6 +394,7 @@
"form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
"form.prefs.label.custom_css": "Feuille de style personnalisée",
"form.prefs.label.custom_js": "Code JavaScript personnalisé",
"form.prefs.label.entry_order": "Colonne de tri des entrées",
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
@@ -401,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
"form.prefs.label.external_font_hosts": "Polices externes autorisées",
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
"error.settings_invalid_domain_list": "Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.",
"form.import.label.file": "Fichier OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Sauvegarder les entrées vers Betula",
+9 -1
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "ताज़ा करें",
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
"menu.edit_feed": "फ़ीड संपाद करे",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "रजिस्टर पासकी",
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"%d पासकुंजी निकालें",
"%d पासकी हटाएं"
@@ -354,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -390,6 +394,7 @@
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.custom_css": "कस्टम सीएसएस",
"form.prefs.label.custom_js": "कस्टम जेएस",
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
@@ -401,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.betula_activate": "Save entries to Betula",
@@ -482,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 भेजें (पूर्ण सामग्री के बजाय)",
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
"menu.show_all_entries": "Tampilkan semua entri",
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "Muat ulang",
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
"menu.edit_feed": "Sunting",
@@ -210,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Remove %d passkey"
],
@@ -344,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -380,6 +384,7 @@
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
"form.prefs.label.custom_css": "Modifikasi CSS",
"form.prefs.label.custom_js": "Modifikasi JavaScript",
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
"form.prefs.label.default_home_page": "Beranda Baku",
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
@@ -391,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Berkas OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
"menu.show_all_entries": "Mostra tutte le voci",
"menu.show_only_unread_entries": "Mostra solo voci non lette",
"menu.show_only_starred_entries": "Mostra solo voci preferiti",
"menu.refresh_feed": "Aggiorna",
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
"menu.edit_feed": "Modifica",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registra la chiave di accesso",
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Rimuovi %d passkey",
"Rimuovi %d passkey"
@@ -354,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -390,6 +394,7 @@
"form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
"form.prefs.label.custom_css": "CSS personalizzati",
"form.prefs.label.custom_js": "JavaScript personalizzati",
"form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
@@ -401,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "File OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "すべて既読にする",
"menu.show_all_entries": "すべての記事を表示",
"menu.show_only_unread_entries": "未読の記事だけを表示",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
"menu.edit_feed": "編集",
@@ -210,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"%d 個のパスキーを削除"
],
@@ -344,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -380,6 +384,7 @@
"form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
"form.prefs.label.custom_css": "カスタム CSS",
"form.prefs.label.custom_js": "カスタム JavaScript",
"form.prefs.label.entry_order": "記事の表示順の基準",
"form.prefs.label.default_home_page": "デフォルトのトップページ",
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
@@ -391,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML ファイル",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+277 -269
View File
@@ -1,17 +1,17 @@
{
"skip_to_content": "Skip to content",
"skip_to_content": "Ga naar inhoud",
"confirm.question": "Weet je het zeker?",
"confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
"confirm.question.refresh": "Wil je vernieuwen forceren?",
"confirm.yes": "ja",
"confirm.no": "nee",
"confirm.loading": "Bezig...",
"action.subscribe": "Abboneren",
"action.subscribe": "Abonneren",
"action.save": "Opslaan",
"action.or": "of",
"action.cancel": "annuleren",
"action.remove": "Verwijderen",
"action.remove_feed": "Verwijder deze feed",
"action.update": "Updaten",
"action.update": "Bijwerken",
"action.edit": "Bewerken",
"action.download": "Download",
"action.import": "Importeren",
@@ -20,7 +20,7 @@
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
"tooltip.logged_user": "Ingelogd als %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.home_page": "Startpagina",
"menu.unread": "Ongelezen",
"menu.starred": "Favorieten",
"menu.history": "Geschiedenis",
@@ -31,16 +31,17 @@
"menu.preferences": "Voorkeuren",
"menu.integrations": "Integraties",
"menu.sessions": "Sessies",
"menu.users": "Users",
"menu.users": "Gebruikers",
"menu.about": "Over",
"menu.export": "Exporteren",
"menu.import": "Importeren",
"menu.search": "Zoeken",
"menu.create_category": "Categorie toevoegen",
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
"menu.mark_all_as_read": "Markeer alle items als gelezen",
"menu.mark_all_as_read": "Markeer alles als gelezen",
"menu.show_all_entries": "Toon alle artikelen",
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
"menu.show_only_starred_entries": "Toon alleen favorieten",
"menu.refresh_feed": "Vernieuwen",
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"menu.edit_feed": "Bewerken",
@@ -48,39 +49,39 @@
"menu.add_feed": "Feed toevoegen",
"menu.add_user": "Gebruiker toevoegen",
"menu.flush_history": "Verwijder geschiedenis",
"menu.feed_entries": "Lidwoord",
"menu.feed_entries": "Artikelen",
"menu.api_keys": "API-sleutels",
"menu.create_api_key": "Maak een nieuwe API-sleutel",
"menu.shared_entries": "Gedeelde vermeldingen",
"menu.shared_entries": "Gedeelde artikelen",
"search.label": "Zoeken",
"search.placeholder": "Zoeken...",
"search.submit": "Search",
"pagination.last": "Last",
"search.submit": "Zoeken",
"pagination.last": "Laatste",
"pagination.next": "Volgende",
"pagination.first": "First",
"pagination.first": "Eerste",
"pagination.previous": "Vorige",
"entry.status.unread": "Ongelezen",
"entry.status.read": "Gelezen",
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
"entry.status.toast.read": "Gemarkeerd als gelezen",
"entry.status.title": "Verander status van item",
"entry.bookmark.toggle.on": "Ster toevoegen",
"entry.bookmark.toggle.off": "Ster weghalen",
"entry.bookmark.toast.on": "Met ster",
"entry.bookmark.toast.off": "Ster verwijderd",
"entry.state.saving": "Opslaag...",
"entry.status.title": "Verander artikelstatus",
"entry.bookmark.toggle.on": "Favoriet",
"entry.bookmark.toggle.off": "Favoriet verwijderen",
"entry.bookmark.toast.on": "Favoriet toegevoegd",
"entry.bookmark.toast.off": "Favoriet verwijderd",
"entry.state.saving": "Opslaan...",
"entry.state.loading": "Laden...",
"entry.save.label": "Opslaan",
"entry.save.title": "Artikel opslaan",
"entry.save.completed": "Done!",
"entry.save.completed": "Klaar!",
"entry.save.toast.completed": "Artikel opgeslagen",
"entry.scraper.label": "Downloaden",
"entry.scraper.title": "Fetch original content",
"entry.scraper.title": "Originele inhoud ophalen",
"entry.scraper.completed": "Klaar!",
"entry.external_link.label": "Externe link",
"entry.comments.label": "Comments",
"entry.comments.title": "Bekijk de reacties",
"entry.share.label": "Deel",
"entry.comments.label": "Reacties",
"entry.comments.title": "Bekijk reacties",
"entry.share.label": "Delen",
"entry.share.title": "Deel dit artikel",
"entry.unshare.label": "Delen ongedaan maken",
"entry.shared_entry.title": "Open de openbare link",
@@ -89,117 +90,117 @@
"%d minuut leestijd",
"%d minuten leestijd"
],
"entry.tags.label": "Labels:",
"page.shared_entries.title": "Gedeelde vermeldingen",
"entry.tags.label": "Tags:",
"page.shared_entries.title": "Gedeelde artikelen",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
"%d gedeeld artikel",
"%d gedeelde artikelen"
],
"page.unread.title": "Ongelezen",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"%d ongelezen artikel",
"%d ongelezen artikelen"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"%d artikel totaal",
"%d artikelen totaal"
],
"page.starred.title": "Favorieten",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"%d favoriet artikel",
"%d favoriete artikelen"
],
"page.categories.title": "Categorieën",
"page.categories.no_feed": "Geen feeds.",
"page.categories.entries": "Lidwoord",
"page.categories.feeds": "Abonnementen",
"page.categories.no_feed": "Geen feed.",
"page.categories.entries": "Artikelen",
"page.categories.feeds": "Feeds",
"page.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"page.categories_count": [
"%d category",
"%d categories"
"%d categorie",
"%d categorieën"
],
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
"page.edit_category.title": "Bewerken van categorie: %s",
"page.edit_category.title": "Bewerk categorie: %s",
"page.edit_user.title": "Bewerk gebruiker: %s",
"page.feeds.title": "Feeds",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Laatste update:",
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Aantal gelezen vermeldingen",
"page.category_label": "Categorie: %s",
"page.feeds.last_check": "Laatste controle:",
"page.feeds.next_check": "Volgende controle:",
"page.feeds.read_counter": "Aantal gelezen artikelen",
"page.feeds.error_count": [
"%d error",
"%d errors"
"%d fout",
"%d fouten"
],
"page.history.title": "Geschiedenis",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d gelezen artikel",
"%d gelezen artikelen"
],
"page.import.title": "Importeren",
"page.login.title": "Inloggen",
"page.search.title": "Zoekresultaten",
"page.about.title": "Over",
"page.about.credits": "Copyrights",
"page.about.credits": "Credits",
"page.about.version": "Versie:",
"page.about.build_date": "Datum build:",
"page.about.build_date": "Compilatiedatum:",
"page.about.author": "Auteur:",
"page.about.license": "Licentie:",
"page.about.global_config_options": "globale configuratie-opties",
"page.about.global_config_options": "Globale Configuratie Opties",
"page.about.postgres_version": "Postgres versie:",
"page.about.go_version": "Go versie:",
"page.add_feed.title": "Nieuwe feed",
"page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"page.add_feed.no_category": "Er is geen categorie. Je moet minstens één categorie hebben.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Feed zoeken",
"page.add_feed.legend.advanced_options": "Geavanceerde mogelijkheden",
"page.add_feed.legend.advanced_options": "Geavanceerde opties",
"page.add_feed.choose_feed": "Feed kiezen",
"page.edit_feed.title": "Bewerken van feed: %s",
"page.edit_feed.last_check": "Laatste update:",
"page.edit_feed.last_modified_header": "LastModified-header:",
"page.edit_feed.etag_header": "ETAG-header:",
"page.edit_feed.title": "Bewerk feed: %s",
"page.edit_feed.last_check": "Laatste controle:",
"page.edit_feed.last_modified_header": "LastModified header:",
"page.edit_feed.etag_header": "ETAG header:",
"page.edit_feed.no_header": "Geen",
"page.edit_feed.last_parsing_error": "Laatste parse error",
"page.edit_feed.last_parsing_error": "Laatste analysefout",
"page.entry.attachments": "Bijlagen",
"page.keyboard_shortcuts.title": "Sneltoetsen",
"page.keyboard_shortcuts.subtitle.sections": "Naviguatie tussen menu's",
"page.keyboard_shortcuts.subtitle.items": "Navigatie tussen items",
"page.keyboard_shortcuts.subtitle.pages": "Naviguatie tussen pagina's",
"page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
"page.keyboard_shortcuts.subtitle.items": "Navigeren door artikelen",
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
"page.keyboard_shortcuts.subtitle.actions": "Acties",
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Laat sneltoetsen zien",
"page.keyboard_shortcuts.go_to_previous_item": "Vorige item",
"page.keyboard_shortcuts.go_to_next_item": "Volgende item",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Sneltoetsen tonen",
"page.keyboard_shortcuts.go_to_previous_item": "Vorig artikel",
"page.keyboard_shortcuts.go_to_next_item": "Volgend artikel",
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste artikel",
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste artikel",
"page.keyboard_shortcuts.open_item": "Open geselecteerd artikel",
"page.keyboard_shortcuts.open_original": "Open originele link",
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
"page.keyboard_shortcuts.open_comments": "Open opmerkingen link",
"page.keyboard_shortcuts.open_comments_same_window": "Open de reactiekoppeling op het huidige tabblad",
"page.keyboard_shortcuts.open_original_same_window": "Open originele link in huidig tabblad",
"page.keyboard_shortcuts.open_comments": "Open reacties",
"page.keyboard_shortcuts.open_comments_same_window": "Open reacties in huidig tabblad",
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"page.keyboard_shortcuts.mark_page_as_read": "Markeer deze pagina als gelezen",
"page.keyboard_shortcuts.download_content": "Download originele content",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ster toevoegen/weghalen",
"page.keyboard_shortcuts.mark_page_as_read": "Markeer huidige pagina als gelezen",
"page.keyboard_shortcuts.download_content": "Download originele inhoud",
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
"page.keyboard_shortcuts.save_article": "Artikel opslaan",
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Sluit dialoogscherm",
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
"page.keyboard_shortcuts.close_modal": "Dialoogvenster sluiten",
"page.users.title": "Gebruikers",
"page.users.username": "Gebruikersnaam",
"page.users.never_logged": "Nooit",
@@ -207,27 +208,28 @@
"page.users.admin.no": "Nee",
"page.users.actions": "Acties",
"page.users.last_login": "Laatste login",
"page.users.is_admin": "Administrator",
"page.users.is_admin": "Beheerder",
"page.settings.title": "Instellingen",
"page.settings.link_google_account": "Koppel mijn Google-account",
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
"page.settings.link_oidc_account": "Koppel mijn %s-account",
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s-account",
"page.settings.link_oidc_account": "Koppel mijn %s account",
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s account",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Wachtwoord registreren",
"page.settings.webauthn.register.error": "Kan wachtwoord niet registreren",
"page.settings.webauthn.actions": "Acties",
"page.settings.webauthn.passkey_name": "Passkey Naam",
"page.settings.webauthn.added_on": "Toegevoegd op",
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
"page.settings.webauthn.register": "Passkey registreren",
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Verwijder %d wachtwoord",
"Verwijder %d wachtwoordsleutels"
"Verwijder %d passkey",
"Verwijder %d passkeys"
],
"page.login.oidc_signin": "Inloggen via %s",
"page.login.webauthn_login": "Inloggen met wachtwoord",
"page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord",
"page.login.google_signin": "Inloggen via Google",
"page.login.oidc_signin": "Inloggen met %s",
"page.login.webauthn_login": "Inloggen met passkey",
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
"page.login.google_signin": "Inloggen met Google",
"page.integrations.title": "Integraties",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API-URL",
@@ -237,7 +239,7 @@
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.",
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abonneren op een website.",
"page.sessions.title": "Sessies",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP-adres",
@@ -245,8 +247,8 @@
"page.sessions.table.actions": "Acties",
"page.sessions.table.current_session": "Huidige sessie",
"page.api_keys.title": "API-sleutels",
"page.api_keys.table.description": "Beschrijving",
"page.api_keys.table.token": "Blijk",
"page.api_keys.table.description": "Omschrijving",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.created_at": "Aanmaakdatum",
"page.api_keys.table.actions": "Acties",
@@ -255,155 +257,161 @@
"page.offline.title": "Offline modus",
"page.offline.message": "Je bent offline",
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Er is geen gedeelde toegang.",
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"page.webauthn_rename.title": "Hernoem Passkey",
"alert.no_shared_entry": "Er is geen gedeeld artikel.",
"alert.no_bookmark": "Er zijn geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
"alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
"alert.no_feed_in_category": "Er is geen feed voor deze categorie.",
"alert.no_history": "Geschiedenis is op dit moment leeg.",
"alert.feed_error": "Er is een probleem met deze feed",
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
"alert.no_user": "Je bent de enige gebruiker.",
"alert.account_unlinked": "Uw externe account is nu gedissocieerd!",
"alert.account_linked": "Uw externe account is nu gekoppeld!",
"alert.pocket_linked": "Uw Pocket-account is nu gekoppeld!",
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
"alert.account_linked": "Jouw externe account is nu gekoppeld!",
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!",
"alert.prefs_saved": "Instellingen opgeslagen!",
"error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
"error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
"error.category_already_exists": "Deze categorie bestaat al.",
"error.unable_to_create_category": "Kan deze categorie niet maken.",
"error.unable_to_update_category": "Kon categorie niet updaten.",
"error.unable_to_create_category": "Kan deze categorie niet aanmaken.",
"error.unable_to_update_category": "Kan categorie niet bijwerken.",
"error.user_already_exists": "Deze gebruiker bestaat al.",
"error.unable_to_create_user": "Kan deze gebruiker niet maken.",
"error.unable_to_update_user": "Kan deze gebruiker niet updaten.",
"error.unable_to_create_user": "Kan deze gebruiker niet aanmaken.",
"error.unable_to_update_user": "Kan deze gebruiker niet bijwerken.",
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
"error.subscription_not_found": "Kon geen feeds vinden.",
"error.subscription_not_found": "Kan geen feeds vinden.",
"error.empty_file": "Dit bestand is leeg.",
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
"error.title_required": "Naam van categorie is verplicht.",
"error.title_required": "De titel is verplicht.",
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
"error.settings_mandatory_fields": "Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.",
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
"error.settings_keep_rule_separator_required": "Ongeldige bewaarregel: het patroon van regel #%d moet worden gescheiden door een '='",
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
"error.entries_per_page_invalid": "Het aantal artikelen per pagina is niet geldig.",
"error.feed_mandatory_fields": "De velden URL en categorie zijn verplicht.",
"error.feed_already_exists": "Deze feed bestaat al.",
"error.invalid_feed_url": "Ongeldige feed-URL.",
"error.invalid_site_url": "Ongeldige site-URL.",
"error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.",
"error.site_url_not_empty": "De site-URL mag niet leeg zijn.",
"error.feed_title_not_empty": "De feedtitel mag niet leeg zijn.",
"error.invalid_feed_url": "Ongeldige feed URL.",
"error.invalid_site_url": "Ongeldige site URL.",
"error.feed_url_not_empty": "De feed URL mag niet leeg zijn.",
"error.site_url_not_empty": "De site URL mag niet leeg zijn.",
"error.feed_title_not_empty": "De feed titel mag niet leeg zijn.",
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
"error.feed_invalid_blocklist_rule": "De regel voor de blokkeerlijst is ongeldig.",
"error.feed_invalid_keeplist_rule": "De regel voor het bewaren van een lijst is ongeldig.",
"error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.",
"error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet aanmaken.",
"error.invalid_theme": "Ongeldig thema.",
"error.invalid_language": "Ongeldige taal.",
"error.invalid_timezone": "Ongeldige tijdzone.",
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
"error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
"error.invalid_default_home_page": "Ongeldige standaard homepage!",
"form.feed.label.title": "Naam",
"error.invalid_default_home_page": "Ongeldige startpagina!",
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Beschrijving",
"form.feed.label.description": "Omschrijving",
"form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed-gebruikersnaam",
"form.feed.label.crawler": "Download originele inhoud",
"form.feed.label.feed_username": "Feed gebruikersnaam",
"form.feed.label.feed_password": "Feed wachtwoord",
"form.feed.label.user_agent": "Standaard User Agent overschrijven",
"form.feed.label.user_agent": "Standaard User-agent overschrijven",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.scraper_rules": "Scraper regels",
"form.feed.label.rewrite_rules": "Rewrite regels",
"form.feed.label.blocklist_rules": "Blokkeer regels",
"form.feed.label.keeplist_rules": "toestemmingsregels",
"form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.scraper_rules": "Extractieregels",
"form.feed.label.rewrite_rules": "Herschrijfregels",
"form.feed.label.blocklist_rules": "Blokkeerregels",
"form.feed.label.keeplist_rules": "Bewaarregels",
"form.feed.label.urlrewrite_rules": "Herschrijfregels voor URL's",
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
"form.feed.label.disabled": "Vernieuw deze feed niet",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Naam",
"form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
"form.feed.label.disabled": "Deze feed niet vernieuwen",
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
"form.feed.label.ntfy_high_priority": "Ntfy hoge prioriteit",
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit",
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "Algemeen",
"form.feed.fieldset.rules": "Regels",
"form.feed.fieldset.network_settings": "Netwerk Instellingen",
"form.feed.fieldset.integration": "Diensten van derden",
"form.category.label.title": "Titel",
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
"form.user.label.username": "Gebruikersnaam",
"form.user.label.password": "Wachtwoord",
"form.user.label.confirmation": "Bevestig wachtwoord",
"form.user.label.admin": "Administrator",
"form.user.label.admin": "Beheerder",
"form.prefs.label.language": "Taal",
"form.prefs.label.timezone": "Tijdzone",
"form.prefs.label.theme": "Skin",
"form.prefs.label.entry_sorting": "Volgorde van items",
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
"form.prefs.label.theme": "Thema",
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
"form.prefs.label.entries_per_page": "Artikelen per pagina",
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
"form.prefs.select.older_first": "Oudere items eerst",
"form.prefs.select.recent_first": "Recente items eerst",
"form.prefs.select.older_first": "Oudere artikelen eerst",
"form.prefs.select.recent_first": "Recente artikelen eerst",
"form.prefs.select.fullscreen": "Volledig scherm",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimaal",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Tijd van binnenkomst",
"form.prefs.select.created_time": "Tijdstip van binnenkomst",
"form.prefs.select.publish_time": "Tijdstip van publiceren artikel",
"form.prefs.select.created_time": "Tijdstip van aanmaken artikel",
"form.prefs.select.alphabetical": "Alfabetisch",
"form.prefs.select.unread_count": "Ongelezen tellen",
"form.prefs.select.unread_count": "Aantal ongelezen artikelen",
"form.prefs.select.none": "Geen",
"form.prefs.select.tap": "Dubbeltik",
"form.prefs.select.swipe": "Vegen",
"form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
"form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
"form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
"form.prefs.label.custom_css": "Aangepaste CSS",
"form.prefs.label.entry_order": "Ingang Sorteerkolom",
"form.prefs.label.default_home_page": "Standaard startpagina",
"form.prefs.label.categories_sorting_order": "Categorieën sorteren",
"form.prefs.label.mark_read_on_view": "Items automatisch markeren als gelezen wanneer ze worden bekeken",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.custom_js": "Aangepaste JavaScript",
"form.prefs.label.entry_order": "Artikelen sorteren",
"form.prefs.label.default_home_page": "Startpagina",
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
"form.prefs.label.mark_read_on_media_completion": "Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt",
"form.prefs.label.mark_read_manually": "Markeer artikelen handmatig als gelezen",
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML-bestand",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_activate": "Artikelen opslaan in Betula",
"form.integration.betula_url": "Betula server URL",
"form.integration.betula_token": "Betula Token",
"form.integration.fever_activate": "Activeer Fever API",
@@ -413,102 +421,102 @@
"form.integration.googlereader_activate": "Activeer Google Reader API",
"form.integration.googlereader_username": "Google Reader gebruikersnaam",
"form.integration.googlereader_password": "Google Reader wachtwoord",
"form.integration.googlereader_endpoint": "Google Reader URL:",
"form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
"form.integration.googlereader_endpoint": "Google Reader API-endpoint:",
"form.integration.pinboard_activate": "Artikelen opslaan in Pinboard",
"form.integration.pinboard_token": "Pinboard API token",
"form.integration.pinboard_tags": "Pinboard tags",
"form.integration.pinboard_bookmark": "Markeer bookmark als gelezen",
"form.integration.instapaper_activate": "Artikelen opstaan naar Instapaper",
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
"form.integration.instapaper_password": "Instapaper wachtwoord",
"form.integration.pocket_activate": "Bewaar artikelen in Pocket",
"form.integration.pocket_activate": "Artikelen opslaan in Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
"form.integration.wallabag_activate": "Opslaan naar Wallabag",
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
"form.integration.wallabag_password": "Wallabag wachtwoord",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_activate": "Artikelen opslaan in Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_activate": "Artikelen opslaan in Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's",
"form.integration.nunux_keeper_activate": "Artikelen opslaan in Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
"form.integration.omnivore_activate": "Opslaan naar Omnivore",
"form.integration.omnivore_activate": "Artikelen opslaan in Omnivore",
"form.integration.omnivore_url": "Omnivore URL",
"form.integration.omnivore_api_key": "Omnivore API-sleutel",
"form.integration.espial_activate": "Opslaan naar Espial",
"form.integration.espial_activate": "Artikelen opslaan in Espial",
"form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_api_key": "Espial API-sleutel",
"form.integration.espial_tags": "Espial tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_activate": "Artikelen opslaan in Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Push nieuwe artikelen naar Telegram-chat",
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen",
"form.integration.telegram_bot_activate": "Stuur nieuwe artikelen naar Telegram",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.telegram_bot_disable_web_page_preview": "Webpaginavoorbeeld uitschakelen",
"form.integration.telegram_bot_disable_notification": "Notificatie uitschakelen",
"form.integration.telegram_bot_disable_buttons": "Knoppen uitschakelen",
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Opslaan naar Linkding",
"form.integration.linkace_api_key": "LinkAce API-sleutel",
"form.integration.linkace_tags": "LinkAce tags",
"form.integration.linkace_is_private": "Koppeling als privé markeren",
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
"form.integration.linkding_activate": "Artikelen opslaan in Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API-sleutel",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
"form.integration.linkwarden_activate": "Opslaan naar Linkwarden",
"form.integration.linkding_tags": "Linkding tags",
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
"form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
"form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
"form.integration.matrix_bot_user": "Matrix gebruikersnaam",
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Opslaan naar Readeck",
"form.integration.raindrop_activate": "Artikelen opslaan in Raindrop",
"form.integration.raindrop_token": "Raindrop Token",
"form.integration.raindrop_collection_id": "Collectie ID",
"form.integration.raindrop_tags": "Tags (commagescheiden)",
"form.integration.readeck_activate": "Artikelen opslaan in Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.shiori_activate": "Opslaan naar Shiori",
"form.integration.shiori_activate": "Artikelen opslaan in Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Shiori gebruikersnaam",
"form.integration.shiori_password": "Shiori wachtwoord",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_activate": "Webhook activeren",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
"form.integration.ntfy_topic": "Ntfy topic",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.api_key.label.description": "API-sleutellabel",
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
"form.api_key.label.description": "API-sleutel omschrijving",
"form.submit.loading": "Laden...",
"form.submit.saving": "Opslaag...",
"time_elapsed.not_yet": "in de toekomst",
"form.submit.saving": "Opslaan...",
"time_elapsed.not_yet": "nog niet",
"time_elapsed.yesterday": "gisteren",
"time_elapsed.now": "minder dan een minuut geleden",
"time_elapsed.minutes": [
@@ -536,43 +544,43 @@
"%d jaar geleden"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
"error.http_response_too_large": "De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)",
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
"error.tls_error": "TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.",
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
"error.http_client_error": "HTTP-client-fout: %v.",
"error.http_not_authorized": "Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.",
"error.http_too_many_requests": "Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.",
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
"error.http_resource_not_found": "De gevraagde bron is niet gevonden. Controleer de URL.",
"error.http_internal_server_error": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_service_unavailable": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_gateway_timeout": "De website is momenteel niet beschikbaar vanwege een timeout bij de gateway. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_unexpected_status_code": "De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.database_error": "Database fout: %v.",
"error.category_not_found": "Deze categorie bestaat niet of hoort niet bij deze gebruiker.",
"error.duplicated_feed": "Deze feed bestaat al.",
"error.unable_to_parse_feed": "Kan deze feed niet verwerken: %v.",
"error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.",
"error.unable_to_detect_rssbridge": "Kan feed niet detecteren met RSS-Bridge: %v.",
"error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %v.",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.seek" : "Vooruit/terug:",
"enclosure_media_controls.seek.title" : " Vooruit/terug met %s seconden",
"enclosure_media_controls.speed" : "Snelheid:",
"enclosure_media_controls.speed.faster" : "Versnel",
"enclosure_media_controls.speed.faster.title" : "Versnel met %sx",
"enclosure_media_controls.speed.slower" : "Vertraag",
"enclosure_media_controls.speed.slower.title" : "Vertraag met %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
"enclosure_media_controls.speed.reset.title" : "Reset snelheid naar 1x"
}
+316 -308
View File
@@ -1,10 +1,10 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Czy jesteś pewny?",
"confirm.question.refresh": "Czy chcesz wymusić odświeżenie?",
"skip_to_content": "Przejdź do treści",
"confirm.question": "Czy na pewno?",
"confirm.question.refresh": "Czy na pewno chcesz wymusić odświeżenie?",
"confirm.yes": "tak",
"confirm.no": "nie",
"confirm.loading": "W toku...",
"confirm.loading": "W toku",
"action.subscribe": "Subskrypcja",
"action.save": "Zapisz",
"action.or": "lub",
@@ -20,7 +20,7 @@
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
"tooltip.logged_user": "Zalogowany jako %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.home_page": "Strona główna",
"menu.unread": "Nieprzeczytane",
"menu.starred": "Ulubione",
"menu.history": "Historia",
@@ -32,138 +32,139 @@
"menu.integrations": "Usługi",
"menu.sessions": "Sesje",
"menu.users": "Użytkownicy",
"menu.about": "O stronie",
"menu.about": "O czytniku",
"menu.export": "Eksportuj",
"menu.import": "Importuj",
"menu.search": "Szukaj",
"menu.create_category": "Utwórz kategorię",
"menu.mark_page_as_read": "Oznacz jako przeczytane",
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
"menu.show_all_entries": "Pokaż wszystkie artykuły",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane artykuły",
"menu.mark_all_as_read": "Oznacz wszystkie jako przeczytane",
"menu.show_all_entries": "Pokaż wszystkie wpisy",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane wpisy",
"menu.show_only_starred_entries": "Pokaż tylko ulubione wpisy",
"menu.refresh_feed": "Odśwież",
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"menu.refresh_all_feeds": "Odśwież w tle wszystkie subskrypcje",
"menu.edit_feed": "Edytuj",
"menu.edit_category": "Edytuj",
"menu.add_feed": "Dodaj subskrypcję",
"menu.add_feed": "Dodaj kanał",
"menu.add_user": "Dodaj użytkownika",
"menu.flush_history": "Usuń historię",
"menu.feed_entries": "Artykuły",
"menu.feed_entries": "Wpisy",
"menu.api_keys": "Klucze API",
"menu.create_api_key": "Utwórz nowy klucz API",
"menu.shared_entries": "Udostępnione wpisy",
"search.label": "Szukaj",
"search.placeholder": "Szukaj...",
"search.submit": "Search",
"pagination.last": "Ostatni",
"pagination.next": "Następny",
"pagination.first": "Pierwszy",
"pagination.previous": "Poprzedni",
"entry.status.unread": "Nieprzeczytane",
"entry.status.read": "Przeczytane",
"entry.status.toast.unread": "Oznaczone jako nieprzeczytane",
"entry.status.toast.read": "Oznaczone jako przeczytane",
"entry.status.title": "Zmień status artykułu",
"entry.bookmark.toggle.on": "Oznacz gwiazdką",
"entry.bookmark.toggle.off": "Usuń gwiazdkę",
"entry.bookmark.toast.on": "Oznaczone gwiazdką",
"entry.bookmark.toast.off": "Bez gwiazdek",
"entry.state.saving": "Zapisywanie...",
"entry.state.loading": "Ładowanie...",
"search.placeholder": "Szukaj",
"search.submit": "Szukaj",
"pagination.last": "Ostatnia",
"pagination.next": "Następna",
"pagination.first": "Pierwsza",
"pagination.previous": "Poprzednia",
"entry.status.unread": "Nieprzeczytany",
"entry.status.read": "Przeczytany",
"entry.status.toast.unread": "Oznaczono jako nieprzeczytany",
"entry.status.toast.read": "Oznaczono jako przeczytany",
"entry.status.title": "Zmień status wpisu",
"entry.bookmark.toggle.on": "Dodaj do ulubionych",
"entry.bookmark.toggle.off": "Usuń z ulubionych",
"entry.bookmark.toast.on": "Dodano do ulubionych",
"entry.bookmark.toast.off": "Usunięto z ulubionych",
"entry.state.saving": "Zapisywanie",
"entry.state.loading": "Ładowanie",
"entry.save.label": "Zapisz",
"entry.save.title": "Zapisz ten artykuł",
"entry.save.title": "Zapisz ten wpis",
"entry.save.completed": "Gotowe!",
"entry.save.toast.completed": "Artykuł zapisany",
"entry.scraper.label": "Ściągnij",
"entry.save.toast.completed": "Zapisano wpis",
"entry.scraper.label": "Pobierz treść",
"entry.scraper.title": "Pobierz oryginalną treść",
"entry.scraper.completed": "Gotowe!",
"entry.external_link.label": "Link zewnętrzny",
"entry.external_link.label": "Łącze zewnętrzne",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.share.label": "Podzielić się",
"entry.share.title": "Podzielić się ten artykuł",
"entry.unshare.label": "Unshare",
"entry.shared_entry.title": "Otwórz publiczny link",
"entry.shared_entry.label": "Udostępnianie",
"entry.share.label": "Udostępnij",
"entry.share.title": "Udostępnij ten wpis",
"entry.unshare.label": "Cofnij udostępnianie",
"entry.shared_entry.title": "Otwórz publiczne łącze",
"entry.shared_entry.label": "Udostępnij",
"entry.estimated_reading_time": [
"%d minuta czytania",
"%d minuty czytania",
"%d minut czytania"
],
"entry.tags.label": "Tagi:",
"entry.tags.label": "Znaczniki:",
"page.shared_entries.title": "Udostępnione wpisy",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entry",
"%d shared entries"
"%d udostępniony wpis",
"%d udostępnione wpisy",
"%d udostępnionych wpisów"
],
"page.unread.title": "Nieprzeczytane",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entry",
"%d unread entries"
"%d nieprzeczytany wpis",
"%d nieprzeczytane wpisy",
"%d nieprzeczytanych wpisów"
],
"page.total_entry_count": [
"%d entry in total",
"%d entry in total",
"%d entries in total"
"%d wpis łącznie",
"%d wpisy łącznie",
"%d wpisów łącznie"
],
"page.starred.title": "Oznaczone gwiazdką",
"page.starred.title": "Ulubione",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entry",
"%d starred entries"
"%d ulubiony wpis",
"%d ulubione wpisy",
"%d ulubionych wpisów"
],
"page.categories.title": "Kategorie",
"page.categories.no_feed": "Brak kanałów.",
"page.categories.entries": "Artykuły",
"page.categories.feeds": "Subskrypcje",
"page.categories.entries": "Wpisy",
"page.categories.feeds": "Kanały",
"page.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"page.categories_count": [
"%d category",
"%d category",
"%d categories"
"%d kategoria",
"%d kategorie",
"%d kategorii"
],
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
"page.edit_category.title": "Edycja Kategorii: %s",
"page.edit_category.title": "Edytuj kategorię: %s",
"page.edit_user.title": "Edytuj użytkownika: %s",
"page.feeds.title": "Kanały",
"page.category_label": "Category: %s",
"page.category_label": "Kategoria: %s",
"page.feeds.last_check": "Ostatnia aktualizacja:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Następna aktualizacja:",
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
"page.feeds.error_count": [
"%d błąd",
"%d błąd",
"%d błędy",
"%d błędów"
],
"page.history.title": "Historia",
"page.read_entry_count": [
"%d read entry",
"%d read entry",
"%d read entries"
"%d przeczytany wpis",
"%d przeczytane wpisy",
"%d przeczytanych wpisów"
],
"page.import.title": "Importuj",
"page.search.title": "Wyniki wyszukiwania",
"page.about.title": "O",
"page.about.title": "O stronie",
"page.about.credits": "Prawa autorskie",
"page.about.version": "Wersja:",
"page.about.build_date": "Data opracowania:",
"page.about.author": "Autor:",
"page.about.license": "Licencja:",
"page.about.postgres_version": "Postgres wersja:",
"page.about.go_version": "Go wersja:",
"page.about.global_config_options": "globalne opcje konfiguracji",
"page.about.postgres_version": "Wersja PostgreSQL:",
"page.about.go_version": "Wersja Go:",
"page.about.global_config_options": "Globalne opcje konfiguracji",
"page.add_feed.title": "Nowa subskrypcja",
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
"page.add_feed.label.url": "URL",
"page.add_feed.label.url": "Adres URL",
"page.add_feed.submit": "Znajdź subskrypcję",
"page.add_feed.legend.advanced_options": "Zaawansowane opcje",
"page.add_feed.legend.advanced_options": "Opcje zaawansowane",
"page.add_feed.choose_feed": "Wybierz subskrypcję",
"page.edit_feed.title": "Edytuj kanał: %s",
"page.edit_feed.last_check": "Ostatnia aktualizacja:",
@@ -174,39 +175,39 @@
"page.entry.attachments": "Załączniki",
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między artykułami",
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między elementami",
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
"page.keyboard_shortcuts.subtitle.actions": "Działania",
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych artykułów",
"page.keyboard_shortcuts.go_to_starred": "Przejdź do zakładek",
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych",
"page.keyboard_shortcuts.go_to_starred": "Przejdź do ulubionych",
"page.keyboard_shortcuts.go_to_history": "Przejdź do historii",
"page.keyboard_shortcuts.go_to_feeds": "Przejdź do kanałów",
"page.keyboard_shortcuts.go_to_categories": "Przejdź do kategorii",
"page.keyboard_shortcuts.go_to_settings": "Przejdź do ustawień",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Pokaż listę skrótów klawiszowych",
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego artykułu",
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego punktu artykułu",
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego elementu",
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego elementu",
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do najwyższego elementu",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
"page.keyboard_shortcuts.open_comments": "Otwórz link do komentarzy",
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz link do komentarzy w bieżącej karcie",
"page.keyboard_shortcuts.toggle_read_status_next": "Oznacz jako przeczytane/nieprzeczytane, skup się dalej",
"page.keyboard_shortcuts.toggle_read_status_prev": "Oznacz jako przeczytane/nieprzeczytane, skup poprzednie",
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do górnego elementu",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony element",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalne łącze",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalne łącze w bieżącej karcie",
"page.keyboard_shortcuts.open_comments": "Otwórz łącze do komentarzy",
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz łącze do komentarzy w bieżącej karcie",
"page.keyboard_shortcuts.toggle_read_status_next": "Przełącz przeczytane/nieprzeczytane, przejdź dalej",
"page.keyboard_shortcuts.toggle_read_status_prev": "Przełącz przeczytane/nieprzeczytane, przejdź wstecz",
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież w tle wszystkie kanały",
"page.keyboard_shortcuts.mark_page_as_read": "Zaznacz aktualną stronę jako przeczytaną",
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną zawartość",
"page.keyboard_shortcuts.toggle_bookmark_status": "Dodaj/usuń zakładki",
"page.keyboard_shortcuts.save_article": "Zapisz artykuł",
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń artykuł do góry",
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną treść",
"page.keyboard_shortcuts.toggle_bookmark_status": "Przełącz dodanie do ulubionych",
"page.keyboard_shortcuts.save_article": "Zapisz wpis",
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń element do góry",
"page.keyboard_shortcuts.remove_feed": "Usuń ten kanał",
"page.keyboard_shortcuts.go_to_search": "Ustaw fokus na formularzu wyszukiwania",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
"page.keyboard_shortcuts.close_modal": "Zamknij listę skrótów klawiszowych",
"page.users.title": "Użytkownicy",
"page.users.username": "Nazwa użytkownika",
@@ -221,33 +222,34 @@
"page.settings.unlink_google_account": "Odłącz moje konto Google",
"page.settings.link_oidc_account": "Połącz z moim kontem %s",
"page.settings.unlink_oidc_account": "Odłącz moje konto %s",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkeys": "Klucze dostępu",
"page.settings.webauthn.actions": "Działania",
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
"page.settings.webauthn.added_on": "Dodano",
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
"page.settings.webauthn.delete": [
"Usuń %d klucz dostępu",
"Usuń %d klucze dostępu",
"Usuń %d klucze dostępu"
"Usuń %d kluczy dostępu"
],
"page.login.title": "Zaloguj się",
"page.login.google_signin": "Zaloguj przez Google",
"page.login.oidc_signin": "Zaloguj przez %s",
"page.login.webauthn_login": "Zaloguj się za pomocą hasła",
"page.login.google_signin": "Zaloguj się przez Google",
"page.login.oidc_signin": "Zaloguj się przez %s",
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
"page.integrations.title": "Usługi",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Punkt końcowy API",
"page.integration.miniflux_api_username": "Nazwa Użytkownika",
"page.integration.miniflux_api_username": "Nazwa użytkownika",
"page.integration.miniflux_api_password": "Hasło",
"page.integration.miniflux_api_password_value": "Hasło konta",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.miniflux_api_password_value": "Hasło do konta",
"page.integration.bookmarklet": "Skryptozakładka",
"page.integration.bookmarklet.name": "Dodaj do Miniflux",
"page.integration.bookmarklet.instructions": "Przeciągnij i upuść to łącze do zakładek.",
"page.integration.bookmarklet.help": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
"page.integration.bookmarklet.help": "To łącze umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
"page.sessions.title": "Sesje",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Adres IP",
@@ -256,7 +258,7 @@
"page.sessions.table.current_session": "Bieżąca sesja",
"page.api_keys.title": "Klucze API",
"page.api_keys.table.description": "Opis",
"page.api_keys.table.token": "Znak",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.created_at": "Data utworzenia",
"page.api_keys.table.actions": "Działania",
@@ -265,19 +267,19 @@
"page.offline.title": "Tryb offline",
"page.offline.message": "Jesteś odłączony od sieci",
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Brak wspólnego wpisu.",
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
"page.webauthn_rename.title": "Zmień nazwę klucza dostępu",
"alert.no_shared_entry": "Brak udostępnionego wpisu.",
"alert.no_bookmark": "Brak ulubionych w tej chwili.",
"alert.no_category": "Brak kategorii!",
"alert.no_category_entry": "Brak wpisów w tej kategorii",
"alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.",
"alert.no_feed_entry": "Brak wpisów tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
"alert.no_feed_in_category": "Nie ma subskrypcji tej kategorii.",
"alert.no_history": "Obecnie nie ma żadnej historii.",
"alert.feed_error": "Z tym kanałem jest problem",
"alert.no_search_result": "Brak wyników dla tego wyszukiwania.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych artykułów.",
"alert.no_search_result": "Brak wyników tego wyszukiwania.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.",
"alert.no_user": "Jesteś jedynym użytkownikiem.",
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
"alert.account_linked": "Twoje konto zewnętrzne jest teraz połączone!",
@@ -286,7 +288,7 @@
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
"error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
"error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"error.duplicate_googlereader_username": "Już ktoś inny używa tej nazwy użytkownika Google Reader!",
"error.duplicate_googlereader_username": "Istnieje już ktoś inny z tą samą nazwą użytkownika Google Reader!",
"error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
"error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
"error.category_already_exists": "Ta kategoria już istnieje.",
@@ -296,7 +298,7 @@
"error.unable_to_create_user": "Nie można utworzyć tego użytkownika.",
"error.unable_to_update_user": "Nie można zaktualizować tego użytkownika.",
"error.unable_to_update_feed": "Nie można zaktualizować tego kanału.",
"error.subscription_not_found": "Nie znaleziono żadnych subskrypcji.",
"error.subscription_not_found": "Nie znaleziono żadnych kanałów.",
"error.empty_file": "Ten plik jest pusty.",
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
"error.fields_mandatory": "Wszystkie pola są obowiązkowe.",
@@ -304,17 +306,17 @@
"error.different_passwords": "Hasła nie są identyczne.",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_reading_speed_is_positive": "Szybkości czytania muszą być dodatnimi liczbami całkowitymi.",
"error.settings_block_rule_fieldname_invalid": "Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_block_rule_separator_required": "Nieprawidłowa reguła blokowania: wzór reguły #%d musi być oddzielony znakiem '='",
"error.settings_block_rule_regex_required": "Nieprawidłowa reguła blokowania: nie podano wzorca reguły #%d",
"error.settings_block_rule_invalid_regex": "Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
"error.settings_keep_rule_fieldname_invalid": "Nieprawidłowa reguła utrzymywania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_keep_rule_separator_required": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d musi być oddzielony znakiem '='",
"error.settings_keep_rule_regex_required": "Nieprawidłowa reguła utrzymywania nie podano wzorca reguły #%d",
"error.settings_keep_rule_invalid_regex": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.feed_mandatory_fields": "Adres URL i kategoria są obowiązkowe.",
"error.feed_already_exists": "Ten kanał już istnieje.",
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
"error.invalid_site_url": "Nieprawidłowy adres URL witryny.",
@@ -325,49 +327,51 @@
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
"error.feed_invalid_keeplist_rule": "Reguła listy zachowania jest nieprawidłowa.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.api_key_already_exists": "Ten klucz API już istnieje.",
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
"error.invalid_theme": "Nieprawidłowy motyw.",
"error.invalid_language": "Nieprawidłowy język.",
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji sieciowej.",
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
"form.feed.label.site_url": "Adres URL strony",
"form.feed.label.feed_url": "Adres URL kanału",
"form.feed.label.description": "Opis",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
"form.feed.label.feed_password": "Subskrypcję Hasło",
"form.feed.label.feed_username": "Nazwa użytkownika subskrypcji",
"form.feed.label.feed_password": "Hasło do subskrypcji",
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
"form.feed.label.cookie": "Ustawianie ciasteczek",
"form.feed.label.scraper_rules": "Zasady ekstrakcji",
"form.feed.label.cookie": "Ustaw ciasteczka",
"form.feed.label.scraper_rules": "Reguły ekstrakcji",
"form.feed.label.rewrite_rules": "Reguły zapisu",
"form.feed.label.blocklist_rules": "Zasady blokowania",
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
"form.feed.label.urlrewrite_rules": "Zasady przepisywania adresów URL",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.blocklist_rules": "Reguły blokowania",
"form.feed.label.keeplist_rules": "Reguły utrzymywania",
"form.feed.label.urlrewrite_rules": "Reguły przepisywania adresów URL",
"form.feed.label.apprise_service_urls": "Rozdzielana przecinkami lista adresów URL usług Appprise",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty",
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
"form.feed.label.no_media_player": "Brak odtwarzacza multimedialnego (audio i wideo)",
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.label.ntfy_activate": "Prześlij wpisy do ntfy",
"form.feed.label.ntfy_priority": "Priorytet ntfy",
"form.feed.label.ntfy_max_priority": "Maksymalny priorytet ntfy",
"form.feed.label.ntfy_high_priority": "Wysoki priorytet ntfy",
"form.feed.label.ntfy_default_priority": "Domyślny priorytet ntfy",
"form.feed.label.ntfy_low_priority": "Niski priorytet ntfy",
"form.feed.label.ntfy_min_priority": "Minimalny priorytet ntfy",
"form.integration.cubox_activate": "Zapisuj wpisy w Cubox",
"form.integration.cubox_api_link": "Łącze API Cubox",
"form.feed.fieldset.general": "Ogólne",
"form.feed.fieldset.rules": "Reguły",
"form.feed.fieldset.network_settings": "Ustawienia sieci",
"form.feed.fieldset.integration": "Usługi dostawców zewnętrznych",
"form.category.label.title": "Tytuł",
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.user.label.username": "Nazwa użytkownika",
@@ -377,147 +381,151 @@
"form.prefs.label.language": "Język",
"form.prefs.label.timezone": "Strefa czasowa",
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
"form.prefs.label.entries_per_page": "Wpisy na stronie",
"form.prefs.label.default_reading_speed": "Tryb wyświetlania Progressive Web App (PWA).",
"form.prefs.label.cjk_reading_speed": "Prędkość czytania dla języka chińskiego, koreańskiego i japońskiego (znaki na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
"form.prefs.label.entry_sorting": "Sortowanie wpisów",
"form.prefs.label.entries_per_page": "Wpisy na stronę",
"form.prefs.label.default_reading_speed": "Szybkość czytania w innych językach (słowa na minutę)",
"form.prefs.label.cjk_reading_speed": "Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania progresywnej aplikacji sieciowej (PWA)",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
"form.prefs.label.entry_swipe": "Włącz machnięcie wpisu na ekranach dotykowych",
"form.prefs.label.gesture_nav": "Gest, aby poruszać się między wpisami",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiszowe",
"form.prefs.label.entry_swipe": "Włącz przesuwanie wpisów na ekranach dotykowych",
"form.prefs.label.gesture_nav": "Gest do poruszania się między wpisami",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
"form.prefs.select.fullscreen": "Pełny ekran",
"form.prefs.select.fullscreen": "Pełnoekranowy",
"form.prefs.select.standalone": "Samodzielny",
"form.prefs.select.minimal_ui": "Minimalny",
"form.prefs.select.browser": "Przeglądarka",
"form.prefs.select.browser": "Przeglądarkowy",
"form.prefs.select.publish_time": "Czas publikacji wpisu",
"form.prefs.select.created_time": "Czas utworzenia wpisu",
"form.prefs.select.alphabetical": "Alfabetycznie",
"form.prefs.select.unread_count": "Liczba nieprzeczytanych",
"form.prefs.select.none": "Nic",
"form.prefs.select.tap": "Podwójne wciśnięcie",
"form.prefs.select.swipe": "Trzepnąć",
"form.prefs.select.none": "Brak",
"form.prefs.select.tap": "Podwójne stuknięcie",
"form.prefs.select.swipe": "Przesuwanie",
"form.prefs.label.custom_css": "Niestandardowy CSS",
"form.prefs.label.custom_js": "Niestandardowy JavaScript",
"form.prefs.label.entry_order": "Kolumna sortowania wpisów",
"form.prefs.label.default_home_page": "Domyślna strona główna",
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
"form.prefs.label.mark_read_on_view": "Automatycznie oznaczaj wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
"form.prefs.label.mark_read_on_media_completion": "Oznacz jako przeczytane dopiero wtedy, gdy odtwarzanie audio i wideo osiągnie 90%% ukończenia",
"form.prefs.label.mark_read_manually": "Oznacz wpisy jako przeczytane ręcznie",
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
"form.prefs.label.external_font_hosts": "Hosty zewnętrznych czcionek",
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
"error.settings_invalid_domain_list": "Nieprawidłowa lista domen. Podaj listę domen rozdzielonych spacjami.",
"form.import.label.file": "Plik OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_url": "Betula server URL",
"form.integration.betula_token": "Betula Token",
"form.integration.fever_activate": "Aktywuj Fever API",
"form.import.label.url": "Adres URL",
"form.integration.betula_activate": "Zapisuj wpisy w Betula",
"form.integration.betula_url": "Adres URL serwera Betula",
"form.integration.betula_token": "Token do Betula",
"form.integration.fever_activate": "Aktywuj API Fever",
"form.integration.fever_username": "Login do Fever",
"form.integration.fever_password": "Hasło do Fever",
"form.integration.fever_endpoint": "Punkt końcowy API gorączka:",
"form.integration.googlereader_activate": "Aktywuj Google Reader API",
"form.integration.fever_endpoint": "Punkt końcowy API Fever:",
"form.integration.googlereader_activate": "Aktywuj API Google Reader",
"form.integration.googlereader_username": "Login do Google Reader",
"form.integration.googlereader_password": "Hasło do Google Reader",
"form.integration.googlereader_endpoint": "Punkt końcowy API gorączka:",
"form.integration.pinboard_activate": "Zapisz artykuł w Pinboard",
"form.integration.pinboard_token": "Token Pinboard API",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.googlereader_endpoint": "Punkt końcowy API Google Reader:",
"form.integration.pinboard_activate": "Zapisuj wpisy w Pinboard",
"form.integration.pinboard_token": "Token API do Pinboard",
"form.integration.pinboard_tags": "Znaczniki Pinboard",
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.instapaper_activate": "Zapisz artykuł w Instapaper",
"form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
"form.integration.instapaper_username": "Login do Instapaper",
"form.integration.instapaper_password": "Hasło do Instapaper",
"form.integration.pocket_activate": "Zapisz artykuły w Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Token dostępu kieszeń",
"form.integration.pocket_activate": "Zapisuj wpisy w Pocket",
"form.integration.pocket_consumer_key": "Klucz klienta do Pocket",
"form.integration.pocket_access_token": "Token dostępu do Pocket",
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
"form.integration.wallabag_activate": "Zapisz artykuły do Wallabag",
"form.integration.wallabag_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_endpoint": "Punkt końcowy API Wallabag",
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
"form.integration.wallabag_username": "Login do Wallabag",
"form.integration.wallabag_password": "Hasło do Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.integration.omnivore_activate": "Zapisz artykuly do Omnivore",
"form.integration.omnivore_url": "Omnivore URL",
"form.integration.omnivore_api_key": "Omnivore API key",
"form.integration.espial_activate": "Zapisz artykuly do Espial",
"form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_api_key": "Espial API key",
"form.integration.espial_tags": "Espial Tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Przesyłaj nowe artykuły do czatu Telegram",
"form.integration.telegram_bot_token": "Token bota",
"form.integration.notion_activate": "Zapisuj wpisy w Notion",
"form.integration.notion_page_id": "Identyfikator strony Notion",
"form.integration.notion_token": "Tajny token do Notion",
"form.integration.apprise_activate": "Przesyłaj wpisy do Apprise",
"form.integration.apprise_url": "Adres URL API Apprise",
"form.integration.apprise_services_url": "Oddzielona przecinkami lista adresów URL usługi Apprise",
"form.integration.nunux_keeper_activate": "Zapisuj wpisy w Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Punkt końcowy API Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Klucz API do Nunux Keeper",
"form.integration.omnivore_activate": "Zapisuj wpisy w Omnivore",
"form.integration.omnivore_url": "Punkt końcowy API Omnivore",
"form.integration.omnivore_api_key": "Klucz API do Omnivore",
"form.integration.espial_activate": "Zapisuj wpisy w Espial",
"form.integration.espial_endpoint": "Punkt końcowy API Espial",
"form.integration.espial_api_key": "Klucz API do Espial",
"form.integration.espial_tags": "Znaczniki Espial",
"form.integration.readwise_activate": "Zapisuj wpisy w czytniku Readwise",
"form.integration.readwise_api_key": "Token dostępu do czytnika Readwise",
"form.integration.readwise_api_key_link": "Zdobądź token dostępu Readwise",
"form.integration.telegram_bot_activate": "Przesyłaj nowe wpisy do czatu Telegram",
"form.integration.telegram_bot_token": "Token do bota",
"form.integration.telegram_chat_id": "Identyfikator czatu",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Zapisz artykuły do Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.linkwarden_activate": "Zapisz artykuły do Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.matrix_bot_activate": "Przenieś nowe artykuły do Matrix",
"form.integration.matrix_bot_user": "Nazwa użytkownika dla Matrix",
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.telegram_topic_id": "Identyfikator tematu",
"form.integration.telegram_bot_disable_web_page_preview": "Wyłącz podgląd strony internetowej",
"form.integration.telegram_bot_disable_notification": "Wyłącz powiadomienie",
"form.integration.telegram_bot_disable_buttons": "Wyłącz przyciski",
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
"form.integration.linkace_endpoint": "Punkt końcowy API LinkAce",
"form.integration.linkace_api_key": "Klucz API do LinkAce",
"form.integration.linkace_tags": "Znaczniki LinkAce",
"form.integration.linkace_is_private": "Oznacz łącze jako prywatne",
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
"form.integration.linkding_activate": "Zapisuj wpisy w Linkding",
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
"form.integration.linkding_api_key": "Klucz API do Linkding",
"form.integration.linkding_tags": "Znaczniki Linkding",
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
"form.integration.linkwarden_endpoint": "Punkt końcowy API Linkwarden",
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
"form.integration.matrix_bot_user": "Login do Matrix",
"form.integration.matrix_bot_password": "Hasło do Matrix",
"form.integration.matrix_bot_url": "Adres URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.raindrop_activate": "Zapisuj wpisy do Raindrop",
"form.integration.raindrop_token": "Token (testowy)",
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
"form.integration.readeck_endpoint": "Punkt końcowy API Readeck",
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
"form.integration.readeck_labels": "Etykiety Readeck",
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.shiori_activate": "Zapisuj artykuły w Shiori",
"form.integration.shiori_endpoint": "Punkt końcowy API Shiori",
"form.integration.shiori_username": "Login do Shiori",
"form.integration.shiori_password": "Hasło do Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_topic": "Ntfy topic",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.shaarli_activate": "Zapisuj artykuły w Shaarli",
"form.integration.shaarli_endpoint": "Adres URL Shaarli",
"form.integration.shaarli_api_secret": "Tajny klucz API do Shaarli",
"form.integration.webhook_activate": "Włącz Webhook",
"form.integration.webhook_url": "Adres URL Webhook",
"form.integration.webhook_secret": "Tajny klucz do Webhook",
"form.integration.rssbridge_activate": "Sprawdź RSS-Bridge podczas dodawania subskrypcji",
"form.integration.rssbridge_url": "Adres URL serwera RSS-Bridge",
"form.integration.ntfy_activate": "Przesyłaj wpisy do ntfy",
"form.integration.ntfy_topic": "Temay ntfy",
"form.integration.ntfy_url": "Adres URL ntfy (opcjonalny, domyślny to ntfy.sh)",
"form.integration.ntfy_api_token": "Token API ntfy (opcjonalny)",
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
"form.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie...",
"form.submit.saving": "Zapisywanie...",
"form.submit.loading": "Ładowanie",
"form.submit.saving": "Zapisywanie",
"time_elapsed.not_yet": "jeszcze nie",
"time_elapsed.yesterday": "wczoraj",
"time_elapsed.now": "przed chwilą",
@@ -538,12 +546,12 @@
],
"time_elapsed.weeks": [
"%d tydzień temu",
"%d tygodni temu",
"%d tygodnie temu",
"%d tygodni temu"
],
"time_elapsed.months": [
"%d miesiąc temu",
"%d miesięcy temu",
"%d miesiące temu",
"%d miesięcy temu"
],
"time_elapsed.years": [
@@ -552,44 +560,44 @@
"%d lat temu"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.",
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minuty przed ponowną próbą.",
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minut przed ponowną próbą."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
"error.http_response_too_large": "Odpowiedź HTTP jest za duża. Możesz zwiększyć limit rozmiaru odpowiedzi HTTP w ustawieniach globalnych (wymaga ponownego uruchomienia serwera).",
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
"error.tls_error": "Błąd TLS: %q. Jeśli chcesz, możesz wyłączyć weryfikację TLS w ustawieniach kanału.",
"error.network_operation": "Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.",
"error.network_timeout": "Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v",
"error.http_client_error": "Błąd klienta HTTP: %v.",
"error.http_not_authorized": "Dostęp do tej witryny nie jest autoryzowany. Może to być błędna nazwa użytkownika lub hasło.",
"error.http_too_many_requests": "Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.",
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
"error.http_resource_not_found": "Nie znaleziono żądanego zasobu. Sprawdź adres URL.",
"error.http_internal_server_error": "Strona jest w tej chwili niedostępna z powodu błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_service_unavailable": "Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_gateway_timeout": "Strona internetowa jest w tej chwili niedostępna z powodu błędu przekroczenia limitu czasu bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_unexpected_status_code": "Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.database_error": "Błąd bazy danych: %v.",
"error.category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
"error.duplicated_feed": "Ten kanał już istnieje.",
"error.unable_to_parse_feed": "Nie można przeanalizować tego kanału: %v.",
"error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.",
"error.unable_to_detect_rssbridge": "Nie można wykryć kanału za pomocą RSS-Bridge: %v.",
"error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.",
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
"error.settings_media_playback_rate_range": "Szybkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Przewiń:",
"enclosure_media_controls.seek.title" : "Przewiń o %s sek.",
"enclosure_media_controls.speed" : "Szybkość:",
"enclosure_media_controls.speed.faster" : "Szybciej",
"enclosure_media_controls.speed.faster.title" : "Szybciej o %sx",
"enclosure_media_controls.speed.slower" : "Wolniej",
"enclosure_media_controls.speed.slower.title" : "Wolniej o %sx",
"enclosure_media_controls.speed.reset" : "Przywróć",
"enclosure_media_controls.speed.reset.title" : "Przywróć szybkość do 1x"
}
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Marcar todos como lido",
"menu.show_all_entries": "Mostrar todas os itens",
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos",
"menu.show_only_starred_entries": "Mostrar apenas os favoritos",
"menu.refresh_feed": "Atualizar",
"menu.refresh_all_feeds": "Atualizar todas as fontes",
"menu.edit_feed": "Editar",
@@ -219,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registrar senha",
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Remover %d senha",
"Remover %d senhas"
@@ -354,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -390,6 +394,7 @@
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
"form.prefs.label.custom_css": "CSS customizado",
"form.prefs.label.custom_js": "JavaScript customizado",
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
"form.prefs.label.default_home_page": "Página inicial predefinida",
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
@@ -401,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Arquivo OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Отметить всё как прочитанное",
"menu.show_all_entries": "Показать все статьи",
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
"menu.show_only_starred_entries": "Показывать только избранные статьи",
"menu.refresh_feed": "Обновить",
"menu.refresh_all_feeds": "Обновить все подписки в фоне",
"menu.edit_feed": "Изменить",
@@ -228,6 +229,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Зарегистрировать пароль",
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Удалить %d пароль",
"Удалить %d пароля",
@@ -364,6 +366,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -400,6 +404,7 @@
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
"form.prefs.label.custom_css": "Пользовательский CSS",
"form.prefs.label.custom_js": "Пользовательский JavaScript",
"form.prefs.label.entry_order": "Столбец сортировки статей",
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
@@ -411,6 +416,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML файл",
"form.import.label.url": "Ссылка",
"form.integration.betula_activate": "Сохранять статьи в Бетулу",
+8
View File
@@ -286,6 +286,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
@@ -293,6 +295,7 @@
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.custom_css": "Özel CSS",
"form.prefs.label.custom_js": "Özel JavaScript",
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
@@ -324,6 +327,9 @@
"form.prefs.select.swipe": "Kaydırma",
"form.prefs.select.tap": "Çift dokunma",
"form.prefs.select.unread_count": "Okunmamış sayısı",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"form.user.label.admin": "Yönetici",
@@ -359,6 +365,7 @@
"menu.shared_entries": "Paylaşılan makaleler",
"menu.show_all_entries": "Tüm makaleleri göster",
"menu.show_only_unread_entries": "Sadece okunmamış makaleleri göster",
"menu.show_only_starred_entries": "Sadece yıldızlanmış makaleleri göster",
"menu.starred": "Yıldız",
"menu.title": "Menü",
"menu.unread": "Okunmadı",
@@ -459,6 +466,7 @@
"page.login.title": "Oturum aç",
"page.login.webauthn_login": "Passkey ile giriş yap",
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
+19 -11
View File
@@ -19,8 +19,8 @@
"action.home_screen": "Додати до головного екрану",
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
"tooltip.logged_user": "Здійснено вхід як %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.title": "Меню",
"menu.home_page": "Головна сторінка",
"menu.unread": "Непрочитане",
"menu.starred": "З зірочкою",
"menu.history": "Історія",
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "Відмітити все як прочитане",
"menu.show_all_entries": "Показати всі записи",
"menu.show_only_unread_entries": "Показати тільки непрочитані записи",
"menu.show_only_starred_entries": "Показати тільки записи з зірочкою",
"menu.refresh_feed": "Оновити",
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
"menu.edit_feed": "Редагувати",
@@ -54,11 +55,11 @@
"menu.shared_entries": "Спільні записи",
"search.label": "Пошук",
"search.placeholder": "Шукати...",
"search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Вперед",
"pagination.first": "First",
"pagination.previous": "Назад",
"search.submit": "Знайти",
"pagination.last": "Остання",
"pagination.next": "Наступна",
"pagination.first": "Перша",
"pagination.previous": "Попередня",
"entry.status.unread": "Непрочитане",
"entry.status.read": "Прочитане",
"entry.status.toast.unread": "Відмічено непрочитаним",
@@ -91,7 +92,7 @@
"читати %d хвилин"
],
"entry.tags.label": "Теги:",
"page.shared_entries.title": "Спильні записи",
"page.shared_entries.title": "Спільні записи",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
@@ -133,9 +134,9 @@
"page.edit_category.title": "Редагування категорії: %s",
"page.edit_user.title": "Редагування користувача: %s",
"page.feeds.title": "Стрічки",
"page.category_label": "Category: %s",
"page.category_label": "Категорія: %s",
"page.feeds.last_check": "Остання перевірка:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Наступна перевірка:",
"page.feeds.read_counter": "Кількість прочитаних записів",
"page.feeds.error_count": [
"%d помилка",
@@ -228,6 +229,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Зареєструвати пароль",
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Видалити %d ключ доступу",
"Видаліть %d ключа доступу",
@@ -364,6 +366,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.category.label.title": "Назва",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.feed.fieldset.general": "General",
@@ -400,6 +404,7 @@
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
"form.prefs.label.custom_css": "Спеціальний CSS",
"form.prefs.label.custom_js": "Спеціальний JavaScript",
"form.prefs.label.entry_order": "Стовпець сортування записів",
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
@@ -411,6 +416,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Файл OPML",
"form.import.label.url": "URL-адреса",
"form.integration.betula_activate": "Save entries to Betula",
@@ -492,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 (замість повного вмісту)",
+12 -4
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "全部标为已读",
"menu.show_all_entries": "显示所有文章",
"menu.show_only_unread_entries": "仅显示未读文章",
"menu.show_only_starred_entries": "仅显示已收藏文章",
"menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "在后台更新全部源",
"menu.edit_feed": "编辑",
@@ -91,18 +92,18 @@
"entry.tags.label": "标签:",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"%d shared entry"
"%d 已分享的文章"
],
"page.unread.title": "未读",
"page.unread_entry_count": [
"%d unread entry"
"%d 未读的文章"
],
"page.total_entry_count": [
"%d entry in total"
"%d 文章总数"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d starred entry"
"%d 收藏的文章"
],
"page.categories.title": "分类",
"page.categories.no_feed": "没有源",
@@ -210,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "最后使用时间",
"page.settings.webauthn.register": "注册 Passkey",
"page.settings.webauthn.register.error": "无法注册 Passkey",
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入用户名。",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey"
],
@@ -344,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy默认优先级",
"form.feed.label.ntfy_low_priority": "Ntfy低优先级",
"form.feed.label.ntfy_min_priority": "Ntfy最低优先级",
"form.integration.cubox_activate": "保存文章到 Cubox",
"form.integration.cubox_api_link": "Cubox API 链接",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.rules": "规则",
"form.feed.fieldset.network_settings": "网络设置",
@@ -380,6 +384,7 @@
"form.prefs.label.gesture_nav": "在条目之间导航的手势",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
"form.prefs.label.custom_css": "自定义 CSS",
"form.prefs.label.custom_js": "自定义 JavaScript",
"form.prefs.label.entry_order": "文章排序依据",
"form.prefs.label.default_home_page": "默认主页",
"form.prefs.label.categories_sorting_order": "分类排序",
@@ -391,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "用户认证设置",
"form.prefs.fieldset.reader_settings": "阅读器设置",
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
"form.prefs.label.external_font_hosts": "外部字体托管",
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
"error.settings_invalid_domain_list": "域名列表无效。请提供一个用空格分隔的域名列表。",
"form.import.label.file": "OPML 文件",
"form.import.label.url": "URL",
"form.integration.betula_activate": "保存文章到 Betula",
+8
View File
@@ -41,6 +41,7 @@
"menu.mark_all_as_read": "全部標為已讀",
"menu.show_all_entries": "顯示所有文章",
"menu.show_only_unread_entries": "僅顯示未讀文章",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "背景更新全部Feeds",
"menu.edit_feed": "編輯",
@@ -210,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "最後使用時間",
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"刪除 %d 個 Passkey"
],
@@ -344,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.rules": "規則",
"form.feed.fieldset.network_settings": "網路設定",
@@ -380,6 +384,7 @@
"form.prefs.label.gesture_nav": "在條目之間導航的手勢",
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.custom_css": "自定義 CSS",
"form.prefs.label.custom_js": "自定義 JavaScript",
"form.prefs.label.entry_order": "文章排序依據",
"form.prefs.label.default_home_page": "預設主頁",
"form.prefs.label.categories_sorting_order": "分類排序",
@@ -391,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+13 -4
View File
@@ -182,7 +182,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
}
}
func TestAbsoluteProxyFilterWithCustomPortInBaseURL(t *testing.T) {
func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org:88/folder/")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
@@ -198,11 +198,20 @@ func TestAbsoluteProxyFilterWithCustomPortInBaseURL(t *testing.T) {
t.Fatalf(`Unexpected base URL, got "%s"`, config.Opts.BaseURL())
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
if config.Opts.RootURL() != "http://example.org:88" {
t.Fatalf(`Unexpected root URL, got "%s"`, config.Opts.RootURL())
}
router := mux.NewRouter()
if config.Opts.BasePath() != "" {
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
}
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
output := RewriteDocumentWithAbsoluteProxyURL(router, input)
expected := `<p><img src="http://example.org:88/folder/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
+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
@@ -40,9 +40,9 @@ func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
// Note that the proxyified URL is relative to the root URL.
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
absoluteURL, err := url.JoinPath(config.Opts.BaseURL(), proxifiedUrl)
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
if err != nil {
return mediaURL
}
+26
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"strings"
@@ -34,9 +35,34 @@ func (e Enclosure) Html5MimeType() string {
return e.MimeType
}
func (e *Enclosure) IsAudio() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
}
func (e *Enclosure) IsVideo() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
}
func (e *Enclosure) IsImage() bool {
mimeType := strings.ToLower(e.MimeType)
mediaURL := strings.ToLower(e.URL)
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
}
// EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
for _, enclosure := range el {
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
return enclosure
}
}
return nil
}
func (el EnclosureList) ContainsAudioOrVideo() bool {
for _, enclosure := range el {
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
+15 -13
View File
@@ -56,11 +56,12 @@ type Feed struct {
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
// Non persisted attributes
// Non-persisted attributes
Category *Category `json:"category,omitempty"`
Icon *FeedIcon `json:"icon"`
Entries Entries `json:"entries,omitempty"`
// Internal attributes (not exposed in the API and not persisted in the database)
TTL int `json:"-"`
IconURL string `json:"-"`
UnreadCount int `json:"-"`
@@ -111,26 +112,27 @@ func (f *Feed) CheckedNow() {
}
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) {
f.TTL = newTTL
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
f.TTL = refreshDelayInMinutes
// Default to the global config Polling Frequency.
var intervalMinutes int
switch config.Opts.PollingScheduler() {
case SchedulerEntryFrequency:
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval()
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
if weeklyCount <= 0 {
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval())
}
default:
intervalMinutes = config.Opts.SchedulerRoundRobinMinInterval()
}
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
if newTTL > intervalMinutes && newTTL > 0 {
intervalMinutes = newTTL
// If the feed has a TTL or a Retry-After defined, we use it to make sure we don't check it too often.
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes {
intervalMinutes = refreshDelayInMinutes
}
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
}
+57 -13
View File
@@ -14,7 +14,7 @@ import (
const (
largeWeeklyCount = 10080
noNewTTL = 0
noRefreshDelay = 0
)
func TestFeedCategorySetter(t *testing.T) {
@@ -76,7 +76,7 @@ func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefor
}
}
func TestFeedScheduleNextCheckDefault(t *testing.T) {
func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
os.Clearenv()
var err error
@@ -88,15 +88,60 @@ func TestFeedScheduleNextCheckDefault(t *testing.T) {
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 10
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, targetInterval, timeBefore, "default SchedulerRoundRobinMinInterval")
checkTargetInterval(t, feed, targetInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinDefault")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
@@ -114,15 +159,14 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 100
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
checkTargetInterval(t, feed, targetInterval, timeBefore, "round robin min interval")
expectedInterval := minInterval
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
@@ -144,7 +188,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 1
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
@@ -173,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 0
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
@@ -202,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
@@ -228,7 +272,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 7
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
+2
View File
@@ -104,4 +104,6 @@ type Integration struct {
NtfyUsername string
NtfyPassword string
NtfyIconURL string
CuboxEnabled bool
CuboxAPILink string
}
+12
View File
@@ -21,6 +21,8 @@ type User struct {
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
ExternalFontHosts string `json:"external_font_hosts"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
@@ -60,6 +62,8 @@ type UserModificationRequest struct {
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
ExternalFontHosts *string `json:"external_font_hosts"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
@@ -118,6 +122,14 @@ func (u *UserModificationRequest) Patch(user *User) {
user.Stylesheet = *u.Stylesheet
}
if u.CustomJS != nil {
user.CustomJS = *u.CustomJS
}
if u.ExternalFontHosts != nil {
user.ExternalFontHosts = *u.ExternalFontHosts
}
if u.GoogleID != nil {
user.GoogleID = *u.GoogleID
}
@@ -13,7 +13,9 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/locale"
)
@@ -51,6 +53,26 @@ func (r *ResponseHandler) ETag() string {
return r.httpResponse.Header.Get("ETag")
}
func (r *ResponseHandler) ParseRetryDelay() int {
retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After")
if retryAfterHeaderValue != "" {
// First, try to parse as an integer (number of seconds)
if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {
return seconds
}
// If not an integer, try to parse as an HTTP-date
if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {
return int(time.Until(t).Seconds())
}
}
return 0
}
func (r *ResponseHandler) IsRateLimited() bool {
return r.httpResponse != nil && r.httpResponse.StatusCode == http.StatusTooManyRequests
}
func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bool {
if r.httpResponse.StatusCode == http.StatusNotModified {
return false
@@ -6,6 +6,7 @@ package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
import (
"net/http"
"testing"
"time"
)
func TestIsModified(t *testing.T) {
@@ -30,7 +31,7 @@ func TestIsModified(t *testing.T) {
ETag: cachedEtag,
IsModified: false,
},
// This case is invalid per RFC9110 8.8.1, so ETag takes precedence.
// ETag takes precedence per RFC9110 8.8.1.
"Last-Modified changed only": {
Status: 200,
LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
@@ -67,3 +68,37 @@ func TestIsModified(t *testing.T) {
})
}
}
func TestRetryDelay(t *testing.T) {
var testCases = map[string]struct {
RetryAfterHeader string
ExpectedDelay int
}{
"Empty header": {
RetryAfterHeader: "",
ExpectedDelay: 0,
},
"Integer value": {
RetryAfterHeader: "42",
ExpectedDelay: 42,
},
"HTTP-date": {
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
ExpectedDelay: 41,
},
}
for name, tc := range testCases {
t.Run(name, func(tt *testing.T) {
header := http.Header{}
header.Add("Retry-After", tc.RetryAfterHeader)
rh := ResponseHandler{
httpResponse: &http.Response{
Header: header,
},
}
if tc.ExpectedDelay != rh.ParseRetryDelay() {
tt.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedDelay, rh.ParseRetryDelay(), name)
}
})
}
}
+61 -75
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)
@@ -93,13 +88,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
checkFeedIcon(
store,
requestBuilder,
subscription.ID,
subscription.SiteURL,
subscription.IconURL,
)
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
return subscription, nil
}
@@ -111,11 +100,6 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
slog.String("feed_url", feedCreationRequest.FeedURL),
)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
}
@@ -176,7 +160,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.WithCategoryID(feedCreationRequest.CategoryID)
subscription.CheckedNow()
processor.ProcessFeedEntries(store, subscription, user, true)
processor.ProcessFeedEntries(store, subscription, userID, true)
if storeErr := store.CreateFeed(subscription); storeErr != nil {
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
@@ -188,13 +172,8 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
slog.String("feed_url", subscription.FeedURL),
)
checkFeedIcon(
store,
requestBuilder,
subscription.ID,
subscription.SiteURL,
subscription.IconURL,
)
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
return subscription, nil
}
@@ -206,11 +185,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
slog.Bool("force_refresh", forceRefresh),
)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed, storeErr := store.FeedByID(userID, feedID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
@@ -221,7 +195,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
weeklyEntryCount := 0
newTTL := 0
refreshDelayInMinutes := 0
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
var weeklyCountErr error
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
@@ -231,7 +205,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
@@ -252,8 +226,25 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
defer responseHandler.Close()
if responseHandler.IsRateLimited() {
retryDelayInSeconds := responseHandler.ParseRetryDelay()
refreshDelayInMinutes = retryDelayInSeconds / 60
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
slog.Warn("Feed is rate limited",
slog.String("feed_url", originalFeed.FeedURL),
slog.Int("retry_delay_in_seconds", retryDelayInSeconds),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
}
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch feed", slog.String("feed_url", originalFeed.FeedURL), slog.Any("error", localizedError.Error()))
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -261,6 +252,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -270,6 +265,8 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
slog.Debug("Feed modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.String("etag_header", originalFeed.EtagHeader),
slog.String("last_modified_header", originalFeed.LastModifiedHeader),
)
responseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())
@@ -285,6 +282,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
}
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
@@ -292,24 +293,30 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
newTTL = updatedFeed.TTL
refreshDelayInMinutes = updatedFeed.TTL
// Set the next check at with updated arguments.
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
slog.Debug("Updated next check date",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Int("ttl", newTTL),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
originalFeed.Entries = updatedFeed.Entries
processor.ProcessFeedEntries(store, originalFeed, user, forceRefresh)
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
updateExistingEntries := forceRefresh || !originalFeed.Crawler
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
if storeErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -326,29 +333,37 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
go integration.PushEntries(originalFeed, newEntries, userIntegrations)
}
// We update caching headers only if the feed has been modified,
// because some websites don't return the same headers when replying with a 304.
originalFeed.EtagHeader = responseHandler.ETag()
originalFeed.LastModifiedHeader = responseHandler.LastModified()
checkFeedIcon(
store,
requestBuilder,
originalFeed.ID,
originalFeed.SiteURL,
updatedFeed.IconURL,
)
originalFeed.IconURL = updatedFeed.IconURL
iconChecker := icon.NewIconChecker(store, originalFeed)
if forceRefresh {
iconChecker.UpdateOrCreateFeedIcon()
} else {
iconChecker.CreateFeedIconIfMissing()
}
} else {
slog.Debug("Feed not modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
)
// Last-Modified may be updated even if ETag is not. In this case, per
// RFC9111 sections 3.2 and 4.3.4, the stored response must be updated.
if responseHandler.LastModified() != "" {
originalFeed.LastModifiedHeader = responseHandler.LastModified()
}
}
originalFeed.ResetErrorCounter()
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
user, storeErr := store.UserByID(userID)
if storeErr != nil {
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
}
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
store.UpdateFeedError(originalFeed)
return localizedError
@@ -356,32 +371,3 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
return nil
}
func checkFeedIcon(store *storage.Storage, requestBuilder *fetcher.RequestBuilder, feedID int64, websiteURL, feedIconURL string) {
if !store.HasIcon(feedID) {
iconFinder := icon.NewIconFinder(requestBuilder, websiteURL, feedIconURL)
if icon, err := iconFinder.FindIcon(); err != nil {
slog.Debug("Unable to find feed icon",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
slog.Any("error", err),
)
} else if icon == nil {
slog.Debug("No icon found",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
)
} else {
if err := store.CreateFeedIcon(feedID, icon); err != nil {
slog.Error("Unable to store feed icon",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
slog.Any("error", err),
)
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package icon // import "miniflux.app/v2/internal/reader/icon"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/storage"
)
type IconChecker struct {
store *storage.Storage
feed *model.Feed
}
func NewIconChecker(store *storage.Storage, feed *model.Feed) *IconChecker {
return &IconChecker{
store: store,
feed: feed,
}
}
func (c *IconChecker) fetchAndStoreIcon() {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(c.feed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(c.feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
iconFinder := NewIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
if icon, err := iconFinder.FindIcon(); err != nil {
slog.Debug("Unable to find feed icon",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Any("error", err),
)
} else if icon == nil {
slog.Debug("No icon found",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
)
} else {
if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
slog.Error("Unable to store feed icon",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Any("error", err),
)
} else {
slog.Debug("Feed icon stored",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.SiteURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Int64("icon_id", icon.ID),
slog.String("icon_hash", icon.Hash),
)
}
}
}
func (c *IconChecker) CreateFeedIconIfMissing() {
if c.store.HasFeedIcon(c.feed.ID) {
slog.Debug("Feed icon already exists",
slog.Int64("feed_id", c.feed.ID),
)
return
}
c.fetchAndStoreIcon()
}
func (c *IconChecker) UpdateOrCreateFeedIcon() {
c.fetchAndStoreIcon()
}
+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")
}
}
+92
View File
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
import (
"encoding/json"
"fmt"
"log/slog"
"regexp"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/fetcher"
)
var (
bilibiliURLRegex = regexp.MustCompile(`bilibili\.com/video/(.*)$`)
bilibiliVideoIdRegex = regexp.MustCompile(`/video/(?:av(\d+)|BV([a-zA-Z0-9]+))`)
)
func shouldFetchBilibiliWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchBilibiliWatchTime() {
return false
}
matches := bilibiliURLRegex.FindStringSubmatch(entry.URL)
urlMatchesBilibiliPattern := len(matches) == 2
return urlMatchesBilibiliPattern
}
func extractBilibiliVideoID(websiteURL string) (string, string, error) {
matches := bilibiliVideoIdRegex.FindStringSubmatch(websiteURL)
if matches == nil {
return "", "", fmt.Errorf("no video ID found in URL: %s", websiteURL)
}
if matches[1] != "" {
return "aid", matches[1], nil
}
if matches[2] != "" {
return "bvid", matches[2], nil
}
return "", "", fmt.Errorf("unexpected regex match result for URL: %s", websiteURL)
}
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
idType, videoID, extractErr := extractBilibiliVideoID(websiteURL)
if extractErr != nil {
return 0, extractErr
}
bilibiliApiURL := fmt.Sprintf("https://api.bilibili.com/x/web-interface/view?%s=%s", idType, videoID)
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(bilibiliApiURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Bilibili API",
slog.String("website_url", websiteURL),
slog.String("api_url", bilibiliApiURL),
slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
var result map[string]interface{}
doc := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr := doc.Decode(&result); docErr != nil {
return 0, fmt.Errorf("failed to decode API response: %v", docErr)
}
if code, ok := result["code"].(float64); !ok || code != 0 {
return 0, fmt.Errorf("API returned error code: %v", result["code"])
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("data field not found or not an object")
}
duration, ok := data["duration"].(float64)
if !ok {
return 0, fmt.Errorf("duration not found or not a number")
}
intDuration := int(duration)
durationMin := intDuration / 60
if intDuration%60 != 0 {
durationMin++
}
return durationMin, nil
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
import (
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"github.com/PuerkitoBio/goquery"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/fetcher"
)
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchNebulaWatchTime() {
return false
}
u, err := url.Parse(entry.URL)
if err != nil {
return false
}
return u.Hostname() == "nebula.tv"
}
func fetchNebulaWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.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")
}
dur, err := strconv.ParseInt(durs, 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur / 60), nil
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
import (
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"github.com/PuerkitoBio/goquery"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/fetcher"
)
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchOdyseeWatchTime() {
return false
}
u, err := url.Parse(entry.URL)
if err != nil {
return false
}
return u.Hostname() == "odysee.com"
}
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Odysee watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.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")
}
dur, err := strconv.ParseInt(durs, 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur / 60), nil
}
+59 -220
View File
@@ -4,15 +4,15 @@
package processor
import (
"errors"
"fmt"
"log/slog"
"regexp"
"slices"
"strconv"
"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"
@@ -23,26 +23,20 @@ import (
"miniflux.app/v2/internal/reader/scraper"
"miniflux.app/v2/internal/reader/urlcleaner"
"miniflux.app/v2/internal/storage"
"github.com/PuerkitoBio/goquery"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/html"
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
bilibiliRegex = regexp.MustCompile(`bilibili\.com/video/(.*)$`)
timelengthRegex = regexp.MustCompile(`"timelength":\s*(\d+)`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
)
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("([^"]+)"\|"([^"]+)"\)`)
// ProcessFeedEntries downloads original web page for entries and apply filters.
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
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]
@@ -65,6 +59,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
pageBaseURL := ""
rewrittenURL := rewriteEntryURL(feed, entry)
entry.URL = rewrittenURL
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
if feed.Crawler && (entryIsNew || forceRefresh) {
slog.Debug("Scraping entry",
@@ -146,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":
@@ -216,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":
@@ -445,209 +446,6 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
}
}
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchYouTubeWatchTime() {
return false
}
matches := youtubeRegex.FindStringSubmatch(entry.URL)
urlMatchesYouTubePattern := len(matches) == 2
return urlMatchesYouTubePattern
}
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchNebulaWatchTime() {
return false
}
matches := nebulaRegex.FindStringSubmatch(entry.URL)
return matches != nil
}
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchOdyseeWatchTime() {
return false
}
matches := odyseeRegex.FindStringSubmatch(entry.URL)
return matches != nil
}
func shouldFetchBilibiliWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchBilibiliWatchTime() {
return false
}
matches := bilibiliRegex.FindStringSubmatch(entry.URL)
urlMatchesBilibiliPattern := len(matches) == 2
return urlMatchesBilibiliPattern
}
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch YouTube page", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := parseISO8601(durs)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur.Minutes()), nil
}
func fetchNebulaWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
// durs contains video watch time in seconds
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := strconv.ParseInt(durs, 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur / 60), nil
}
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Odysee watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.Find(`meta[property="og:video:duration"]`).First().Attr("content")
// durs contains video watch time in seconds
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := strconv.ParseInt(durs, 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur / 60), nil
}
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Bilibili page", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
timelengthMatches := timelengthRegex.FindStringSubmatch(doc.Text())
if len(timelengthMatches) < 2 {
return 0, errors.New("duration has not found")
}
durationMs, err := strconv.ParseInt(timelengthMatches[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", timelengthMatches[1], err)
}
durationSec := durationMs / 1000
durationMin := durationSec / 60
if durationSec%60 != 0 {
durationMin++
}
return int(durationMin), nil
}
// parseISO8601 parses an ISO 8601 duration string.
func parseISO8601(from string) (time.Duration, error) {
var match []string
var d time.Duration
if iso8601Regex.MatchString(from) {
match = iso8601Regex.FindStringSubmatch(from)
} else {
return 0, errors.New("could not parse duration string")
}
for i, name := range iso8601Regex.SubexpNames() {
part := match[i]
if i == 0 || name == "" || part == "" {
continue
}
val, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return 0, err
}
switch name {
case "hour":
d += (time.Duration(val) * time.Hour)
case "minute":
d += (time.Duration(val) * time.Minute)
case "second":
d += (time.Duration(val) * time.Second)
default:
return 0, fmt.Errorf("unknown field %s", name)
}
}
return d, nil
}
func isRecentEntry(entry *model.Entry) bool {
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
return true
@@ -670,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
}
+6 -29
View File
@@ -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 {
@@ -85,35 +91,6 @@ func TestAllowEntries(t *testing.T) {
}
}
func TestParseISO8601(t *testing.T) {
var scenarios = []struct {
duration string
expected time.Duration
}{
// Live streams and radio.
{"PT0M0S", 0},
// https://www.youtube.com/watch?v=HLrqNhgdiC0
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
// https://www.youtube.com/watch?v=LZa5KKfqHtA
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
// https://www.youtube.com/watch?v=yIxEEgEuhT4
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
// https://www.youtube.com/watch?v=bpHf1XcoiFs
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
}
for _, tc := range scenarios {
result, err := parseISO8601(tc.duration)
if err != nil {
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
}
if tc.expected != result {
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
}
}
}
func TestIsRecentEntry(t *testing.T) {
parser := config.NewParser()
var err error
+165
View File
@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"regexp"
"strconv"
"time"
"github.com/PuerkitoBio/goquery"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/fetcher"
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
)
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchYouTubeWatchTime() {
return false
}
matches := youtubeRegex.FindStringSubmatch(entry.URL)
urlMatchesYouTubePattern := len(matches) == 2
return urlMatchesYouTubePattern
}
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
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())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch YouTube page", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := parseISO8601(durs)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur.Minutes()), nil
}
func 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
if iso8601Regex.MatchString(from) {
match = iso8601Regex.FindStringSubmatch(from)
} else {
return 0, errors.New("could not parse duration string")
}
for i, name := range iso8601Regex.SubexpNames() {
part := match[i]
if i == 0 || name == "" || part == "" {
continue
}
val, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return 0, err
}
switch name {
case "hour":
d += (time.Duration(val) * time.Hour)
case "minute":
d += (time.Duration(val) * time.Minute)
case "second":
d += (time.Duration(val) * time.Second)
default:
return 0, fmt.Errorf("unknown field %s", name)
}
}
return d, nil
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package processor // import "miniflux.app/v2/internal/reader/processor"
import (
"testing"
"time"
)
func TestParseISO8601(t *testing.T) {
var scenarios = []struct {
duration string
expected time.Duration
}{
// Live streams and radio.
{"PT0M0S", 0},
// https://www.youtube.com/watch?v=HLrqNhgdiC0
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
// https://www.youtube.com/watch?v=LZa5KKfqHtA
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
// https://www.youtube.com/watch?v=yIxEEgEuhT4
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
// https://www.youtube.com/watch?v=bpHf1XcoiFs
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
}
for _, tc := range scenarios {
result, err := parseISO8601(tc.duration)
if err != nil {
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
}
if tc.expected != result {
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
}
}
}
+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`,
+43 -2
View File
@@ -3,6 +3,11 @@
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
import (
"net/url"
"strings"
)
// List of predefined rewrite rules (alphabetically sorted)
// Available rules: "add_image_title", "add_youtube_video"
// domain => rule name
@@ -10,7 +15,6 @@ var predefinedRules = map[string]string{
"abstrusegoose.com": "add_image_title",
"amazingsuperpowers.com": "add_image_title",
"blog.cloudflare.com": `add_image_title,remove("figure.kg-image-card figure.kg-image + img")`,
"blog.laravel.com": "parse_markdown",
"cowbirdsinlove.com": "add_image_title",
"drawingboardcomic.com": "add_image_title",
"exocomics.com": "add_image_title",
@@ -34,7 +38,44 @@ var predefinedRules = map[string]string{
"treelobsters.com": "add_image_title",
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
"www.qwantz.com": "add_image_title,add_mailto_subject",
"www.recalbox.com": "parse_markdown",
"xkcd.com": "add_image_title",
"youtube.com": "add_youtube_video",
}
// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.
func GetRefererForURL(u string) string {
parsedUrl, err := url.Parse(u)
if err != nil {
return ""
}
switch parsedUrl.Hostname() {
case "moyu.im":
return "https://i.jandan.net"
case "i.pximg.net":
return "https://www.pixiv.net"
case "sp1.piokok.com":
return "https://sp1.piokok.com"
case "cdnfile.sspai.com":
return "https://sspai.com"
case "f.video.weibocdn.com":
return "https://weibo.com"
case "img.hellogithub.com":
return "https://hellogithub.com"
case "bjp.org.cn":
return "https://bjp.org.cn"
case "appinn.com":
return "https://appinn.com"
}
switch {
case strings.HasSuffix(parsedUrl.Hostname(), ".sinaimg.cn"):
return "https://weibo.com"
case strings.HasSuffix(parsedUrl.Hostname(), ".cdninstagram.com"):
return "https://www.instagram.com"
case strings.HasSuffix(parsedUrl.Hostname(), ".moyu.im"):
return "https://i.jandan.net"
}
return ""
}
+5 -2
View File
@@ -101,10 +101,13 @@ func (r *RSSAdapter) BuildFeed(baseURL string) *model.Feed {
}
// Generate the entry hash.
if item.GUID.Data != "" {
switch {
case item.GUID.Data != "":
entry.Hash = crypto.Hash(item.GUID.Data)
} else if entryURL != "" {
case entryURL != "":
entry.Hash = crypto.Hash(entryURL)
default:
entry.Hash = crypto.Hash(entry.Title + entry.Content)
}
// Find CommentsURL if defined.
+33
View File
@@ -336,6 +336,39 @@ func TestParseEntryWithoutLink(t *testing.T) {
}
}
func TestParseEntryWithoutLinkAndWithoutGUID(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<link>https://example.org/</link>
<item>
<title>Item 1</title>
</item>
<item>
<title>Item 2</title>
<pubDate>Wed, 02 Oct 2002 08:00:00 GMT</pubDate>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 2 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Hash != "c5ddfeffb275254140796b8c080f372d65ebb1b0590e238b191f595d5fcd32ca" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
}
if feed.Entries[1].Hash != "0a937478f9bdbfca2de5cdeeb5ee7b09678a3330fc7cc5b05169a50d4516c9a3" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[1].Hash)
}
}
func TestParseEntryWithOnlyGuidPermalink(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
+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
}
+38 -47
View File
@@ -11,8 +11,8 @@ import (
"miniflux.app/v2/internal/model"
)
// HasIcon checks if the given feed has an icon.
func (s *Storage) HasIcon(feedID int64) bool {
// HasFeedIcon checks if the given feed has an icon.
func (s *Storage) HasFeedIcon(feedID int64) bool {
var result bool
query := `SELECT true FROM feed_icons WHERE feed_id=$1`
s.db.QueryRow(query, feedID).Scan(&result)
@@ -57,59 +57,50 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
return &icon, nil
}
// IconByHash returns an icon by the hash (checksum).
func (s *Storage) IconByHash(icon *model.Icon) error {
err := s.db.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID)
if err == sql.ErrNoRows {
return nil
// StoreFeedIcon creates or updates a feed icon.
func (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf(`store: unable to start transaction: %v`, err)
}
if err := tx.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID); err == sql.ErrNoRows {
query := `
INSERT INTO icons
(hash, mime_type, content)
VALUES
($1, $2, $3)
RETURNING
id
`
err := tx.QueryRow(
query,
icon.Hash,
normalizeMimeType(icon.MimeType),
icon.Content,
).Scan(&icon.ID)
if err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to create icon: %v`, err)
}
} else if err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to fetch icon by hash %q: %v`, icon.Hash, err)
}
return nil
}
// CreateIcon creates a new icon.
func (s *Storage) CreateIcon(icon *model.Icon) error {
query := `
INSERT INTO icons
(hash, mime_type, content)
VALUES
($1, $2, $3)
RETURNING
id
`
err := s.db.QueryRow(
query,
icon.Hash,
normalizeMimeType(icon.MimeType),
icon.Content,
).Scan(&icon.ID)
if err != nil {
return fmt.Errorf(`store: unable to create icon: %v`, err)
if _, err := tx.Exec(`DELETE FROM feed_icons WHERE feed_id=$1`, feedID); err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to delete feed icon: %v`, err)
}
return nil
}
// CreateFeedIcon creates an icon and associate the icon to the given feed.
func (s *Storage) CreateFeedIcon(feedID int64, icon *model.Icon) error {
err := s.IconByHash(icon)
if err != nil {
return err
if _, err := tx.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID); err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to associate feed and icon: %v`, err)
}
if icon.ID == 0 {
err := s.CreateIcon(icon)
if err != nil {
return err
}
}
_, err = s.db.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID)
if err != nil {
return fmt.Errorf(`store: unable to create feed icon: %v`, err)
if err := tx.Commit(); err != nil {
return fmt.Errorf(`store: unable to commit transaction: %v`, err)
}
return nil
+13 -4
View File
@@ -207,7 +207,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
ntfy_api_token,
ntfy_username,
ntfy_password,
ntfy_icon_url
ntfy_icon_url,
cubox_enabled,
cubox_api_link
FROM
integrations
WHERE
@@ -314,6 +316,8 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.NtfyUsername,
&integration.NtfyPassword,
&integration.NtfyIconURL,
&integration.CuboxEnabled,
&integration.CuboxAPILink,
)
switch {
case err == sql.ErrNoRows:
@@ -428,9 +432,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
ntfy_api_token=$95,
ntfy_username=$96,
ntfy_password=$97,
ntfy_icon_url=$98
ntfy_icon_url=$98,
cubox_enabled=$99,
cubox_api_link=$100
WHERE
user_id=$99
user_id=$101
`
_, err := s.db.Exec(
query,
@@ -532,6 +538,8 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.NtfyUsername,
integration.NtfyPassword,
integration.NtfyIconURL,
integration.CuboxEnabled,
integration.CuboxAPILink,
integration.UserID,
)
@@ -571,7 +579,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
webhook_enabled='t' OR
omnivore_enabled='t' OR
raindrop_enabled='t' OR
betula_enabled='t'
betula_enabled='t' OR
cubox_enabled='t'
)
`
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
+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)
}
+56 -28
View File
@@ -83,6 +83,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
entry_swipe,
gesture_nav,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -124,6 +126,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
&user.EntrySwipe,
&user.GestureNav,
&user.Stylesheet,
&user.CustomJS,
&user.ExternalFontHosts,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
@@ -163,6 +167,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
// UpdateUser updates a user.
func (s *Storage) UpdateUser(user *model.User) error {
user.ExternalFontHosts = strings.TrimSpace(user.ExternalFontHosts)
if user.Password != "" {
hashedPassword, err := crypto.HashPassword(user.Password)
if err != nil {
@@ -184,21 +190,23 @@ func (s *Storage) UpdateUser(user *model.User) error {
entry_swipe=$11,
gesture_nav=$12,
stylesheet=$13,
google_id=$14,
openid_connect_id=$15,
display_mode=$16,
entry_order=$17,
default_reading_speed=$18,
cjk_reading_speed=$19,
default_home_page=$20,
categories_sorting_order=$21,
mark_read_on_view=$22,
mark_read_on_media_player_completion=$23,
media_playback_rate=$24,
block_filter_entry_rules=$25,
keep_filter_entry_rules=$26
custom_js=$14,
external_font_hosts=$15,
google_id=$16,
openid_connect_id=$17,
display_mode=$18,
entry_order=$19,
default_reading_speed=$20,
cjk_reading_speed=$21,
default_home_page=$22,
categories_sorting_order=$23,
mark_read_on_view=$24,
mark_read_on_media_player_completion=$25,
media_playback_rate=$26,
block_filter_entry_rules=$27,
keep_filter_entry_rules=$28
WHERE
id=$27
id=$29
`
_, err = s.db.Exec(
@@ -216,6 +224,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.EntrySwipe,
user.GestureNav,
user.Stylesheet,
user.CustomJS,
user.ExternalFontHosts,
user.GoogleID,
user.OpenIDConnectID,
user.DisplayMode,
@@ -249,21 +259,23 @@ func (s *Storage) UpdateUser(user *model.User) error {
entry_swipe=$10,
gesture_nav=$11,
stylesheet=$12,
google_id=$13,
openid_connect_id=$14,
display_mode=$15,
entry_order=$16,
default_reading_speed=$17,
cjk_reading_speed=$18,
default_home_page=$19,
categories_sorting_order=$20,
mark_read_on_view=$21,
mark_read_on_media_player_completion=$22,
media_playback_rate=$23,
block_filter_entry_rules=$24,
keep_filter_entry_rules=$25
custom_js=$13,
external_font_hosts=$14,
google_id=$15,
openid_connect_id=$16,
display_mode=$17,
entry_order=$18,
default_reading_speed=$19,
cjk_reading_speed=$20,
default_home_page=$21,
categories_sorting_order=$22,
mark_read_on_view=$23,
mark_read_on_media_player_completion=$24,
media_playback_rate=$25,
block_filter_entry_rules=$26,
keep_filter_entry_rules=$27
WHERE
id=$26
id=$28
`
_, err := s.db.Exec(
@@ -280,6 +292,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.EntrySwipe,
user.GestureNav,
user.Stylesheet,
user.CustomJS,
user.ExternalFontHosts,
user.GoogleID,
user.OpenIDConnectID,
user.DisplayMode,
@@ -332,6 +346,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -371,6 +387,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -410,6 +428,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -456,6 +476,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
u.gesture_nav,
u.last_login_at,
u.stylesheet,
u.custom_js,
u.external_font_hosts,
u.google_id,
u.openid_connect_id,
u.display_mode,
@@ -496,6 +518,8 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
&user.GestureNav,
&user.LastLoginAt,
&user.Stylesheet,
&user.CustomJS,
&user.ExternalFontHosts,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
@@ -608,6 +632,8 @@ func (s *Storage) Users() (model.Users, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -649,6 +675,8 @@ func (s *Storage) Users() (model.Users, error) {
&user.GestureNav,
&user.LastLoginAt,
&user.Stylesheet,
&user.CustomJS,
&user.ExternalFontHosts,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
+3
View File
@@ -56,6 +56,9 @@ func (f *funcMap) Map() template.FuncMap {
"safeCSS": func(str string) template.CSS {
return template.CSS(str)
},
"safeJS": func(str string) template.JS {
return template.JS(str)
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
+12 -5
View File
@@ -34,12 +34,19 @@
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
{{ if and .user .user.Stylesheet }}
{{ $stylesheetNonce := nonce }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; style-src 'self' 'nonce-{{ $stylesheetNonce }}'; require-trusted-types-for 'script'; trusted-types ttpolicy;">
<style nonce="{{ $stylesheetNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ if .user }}
{{ $cspNonce := nonce }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types ttpolicy;">
{{ if .user.Stylesheet }}
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ end }}
{{ if .user.CustomJS }}
<script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
{{ end }}
{{ else }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
{{ end }}
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script>
@@ -12,7 +12,9 @@
<h3>Miniflux</h3>
<ul>
<li><strong>{{ t "page.about.version" }}</strong> <a href="https://github.com/miniflux/v2/releases/tag/{{ .version }}">{{ .version }}</a></li>
{{ if .commit }}
<li><strong>Git Commit</strong> <a href="https://github.com/miniflux/v2/commit/{{ .commit }}">{{ .commit }}</a></li>
{{ end }}
<li><strong>{{ t "page.about.build_date" }}</strong> {{ .build_date }}</li>
{{ if .user.IsAdmin }}<li><strong>{{ t "page.about.postgres_version" }}</strong> {{ .postgres_version }}</li>{{ end }}
<li><strong>{{t "page.about.go_version" }}</strong> {{ .go_version }}</li>
@@ -41,10 +41,23 @@
<li>
<a class="page-link" href="{{ route "categoryEntriesAll" "categoryID" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
</li>
<li>
<a class="page-link" href="{{ route "categoryEntriesStarred" "categoryID" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
</li>
{{ else if .showOnlyStarredEntries }}
<li>
<a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
</li>
<li>
<a class="page-link" href="{{ route "categoryEntriesAll" "categoryID" .category.ID }}">{{ icon "show-all-entries" }}{{ t "menu.show_all_entries" }}</a>
</li>
{{ else }}
<li>
<a class="page-link" href="{{ route "categoryEntries" "categoryID" .category.ID }}">{{ icon "show-unread-entries" }}{{ t "menu.show_only_unread_entries" }}</a>
</li>
<li>
<a class="page-link" href="{{ route "categoryEntriesStarred" "categoryID" .category.ID }}">{{ icon "star" }}{{ t "menu.show_only_starred_entries" }}</a>
</li>
{{ end }}
<li>
<a class="page-link" href="{{ route "categoryFeeds" "categoryID" .category.ID }}">{{ icon "feeds" }}{{ t "menu.feeds" }}</a>
+47 -86
View File
@@ -165,56 +165,55 @@
{{ end }}
{{ end }}
<article class="entry-content gesture-nav-{{ $.user.GestureNav }}" dir="auto">
{{ if (and .entry.Enclosures (not .entry.Feed.NoMediaPlayer)) }}
{{ range .entry.Enclosures }}
{{ if ne .URL "" }}
{{ if hasPrefix .MimeType "audio/" }}
<div class="enclosure-audio" >
<audio controls preload="metadata"
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video">
<video controls preload="metadata"
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ if not .entry.Feed.NoMediaPlayer }}
{{ $mediaPlayerEnclosure := .entry.Enclosures.FindMediaPlayerEnclosure }}
{{ if $mediaPlayerEnclosure }}
{{ with $mediaPlayerEnclosure }}
{{ if .IsAudio }}
<div class="enclosure-audio" >
<audio controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{ .ID }}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if .IsVideo }}
<div class="enclosure-video">
<video controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{ .ID }}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{end}}
{{ if .user }}
{{ end }}
{{ if .user }}
{{ noescape (proxyFilter .entry.Content) }}
{{ else }}
{{ else }}
{{ noescape .entry.Content }}
{{ end }}
{{ end }}
</article>
{{ if .entry.Enclosures }}
<details class="entry-enclosures">
@@ -222,45 +221,7 @@
{{ range .entry.Enclosures }}
{{ if ne .URL "" }}
<div class="entry-enclosure">
{{ if hasPrefix .MimeType "audio/" }}
<div class="enclosure-audio">
<audio controls preload="metadata"
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video">
<video controls preload="metadata"
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "image/" }}
{{ if .IsImage }}
<div class="enclosure-image">
{{ if (and $.user (mustBeProxyfied "image")) }}
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
@@ -271,7 +232,7 @@
{{ end }}
<div class="entry-enclosure-download">
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
<small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
</div>
</div>
@@ -57,6 +57,22 @@
</div>
</details>
<details {{ if .form.CuboxEnabled }}open{{ end }}>
<summary>Cubox</summary>
<div class="form-section">
<label>
<input type="checkbox" name="cubox_enabled" value="1" {{ if .form.CuboxEnabled }}checked{{ end }}> {{ t "form.integration.cubox_activate" }}
</label>
<label for="form-cubox-api-link">{{ t "form.integration.cubox_api_link" }}</label>
<input type="url" name="cubox_api_link" id="form-cubox-api-link" value="{{ .form.CuboxAPILink }}" placeholder="https://cubox.pro/c/api/save/xxx" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
</div>
</details>
<details {{ if .form.EspialEnabled }}open{{ end }}>
<summary>Espial</summary>
<div class="form-section">
+15 -3
View File
@@ -24,16 +24,28 @@
</div>
</form>
{{ end }}
{{ if and (not disableLocalAuth) (.webAuthnEnabled) }}
<hr>
{{ end }}
{{ if .webAuthnEnabled }}
<div class="webauthn">
<div role="alert" class="alert alert-error hidden" id="webauthn-error">
{{ t "page.login.webauthn_login.error" }}
</div>
<template id="webauthn-error">
<div role="alert" class="alert alert-error" id="webauthn-error-alert">
<h4>{{ t "page.login.webauthn_login.error" }}</h4>
<p id="webauthn-error-message"></p>
</div>
</template>
<div class="buttons">
<button class="button button-primary" id="webauthn-login" disabled>{{ t "page.login.webauthn_login" }}</button>
</div>
<div class="form-help">
<p>{{ t "page.login.webauthn_login.help" }}</p>
</div>
</div>
{{ end }}
{{ if and (.webAuthnEnabled) (or (hasOAuth2Provider "google") (hasOAuth2Provider "oidc")) }}
<hr>
{{ end }}
{{ if hasOAuth2Provider "google" }}
<div class="oauth2">
<a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "page.login.google_signin" }}</a>
@@ -210,6 +210,13 @@
<label for="form-custom-css">{{t "form.prefs.label.custom_css" }}</label>
<textarea id="form-custom-css" name="custom_css" cols="40" rows="10" spellcheck="false">{{ .form.CustomCSS }}</textarea>
<label for="form-external-font-hosts">{{t "form.prefs.label.external_font_hosts" }}</label>
<input type="text" id="form-external-font-hosts" name="external_font_hosts" spellcheck="false" value="{{ .form.ExternalFontHosts }}">
<div class="form-help">{{t "form.prefs.help.external_font_hosts" }}</div>
<label for="form-custom-js">{{t "form.prefs.label.custom_js" }}</label>
<textarea id="form-custom-js" name="custom_js" cols="40" rows="10" spellcheck="false">{{ .form.CustomJS }}</textarea>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
@@ -33,6 +33,9 @@
{{ if not .entries }}
<p role="alert" class="alert alert-info">{{ t "alert.no_shared_entry" }}</p>
{{ else }}
<div class="pagination-top">
{{ template "pagination" .pagination }}
</div>
<div class="items">
{{ range .entries }}
<article
@@ -82,6 +85,9 @@
</article>
{{ end }}
</div>
<div class="pagination-bottom">
{{ template "pagination" .pagination }}
</div>
{{ end }}
{{ end }}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package ui // import "miniflux.app/v2/internal/ui"
import (
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view"
)
func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
categoryID := request.RouteInt64Param(r, "categoryID")
category, err := h.store.Category(request.UserID(r), categoryID)
if err != nil {
html.ServerError(w, r, err)
return
}
if category == nil {
html.NotFound(w, r)
return
}
offset := request.QueryIntParam(r, "offset", 0)
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred(true)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
entries, err := builder.GetEntries()
if err != nil {
html.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
html.ServerError(w, r, err)
return
}
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("category", category)
view.Set("total", count)
view.Set("entries", entries)
view.Set("pagination", getPagination(route.Path(h.router, "categoryEntriesStarred", "categoryID", category.ID), count, offset, user.EntriesPerPage))
view.Set("menu", "categories")
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
view.Set("showOnlyStarredEntries", true)
html.OK(w, r, view.Render("category_entries"))
}

Some files were not shown because too many files have changed in this diff Show More