Compare commits

...

56 Commits

Author SHA1 Message Date
dependabot[bot] 10b2b36895 build(deps): bump github.com/go-webauthn/webauthn from 0.13.4 to 0.14.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.4 to 0.14.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.4...v0.14.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-15 16:57:47 -07:00
Frédéric Guillot 87e65f800e feat(locale): update French translation 2025-09-14 11:15:52 -07:00
Frédéric Guillot 907cf16af1 fix(fever): fix typo in variable name 2025-09-14 11:11:41 -07:00
Cthulhux 854a78a7d1 feat(locale): update German translation 2025-09-14 10:51:32 -07:00
Frédéric Guillot 5e607be86a refactor(config): rewrite config parser
This PR refactors the configuration parser, replacing the old parser implementation with a new, more structured approach that includes validation and improved organization.

Key changes:
- Complete rewrite of the configuration parser using a map-based structure with built-in validation
- Addition of comprehensive validator functions for configuration values
- Renamed numerous configuration getter methods for better consistency
2025-09-14 10:51:04 -07:00
Matthaiks 502e7108dd feat(locale): update Polish translation 2025-09-12 18:00:58 -07:00
Julien Voisin 0b93d8abcc refactor(subscription): combine findSubscriptionsFromYouTubeChannelPage and findSubscriptionsFromYouTubePlaylistPage functions 2025-09-12 17:59:41 -07:00
Julien Voisin 93a8629910 refactor(js): simplify modal_handler.js 2025-09-12 17:24:51 -07:00
Frédéric Guillot b1742168e1 fix(timezone): make sure legacy time zones are no longer used
Debian Trixie has removed several time zones. This change makes sure only the current IANA time zones are in use.
2025-09-12 16:20:27 -07:00
Steven vanZyl 4eff9129ab feat(ui): add "back to top" link 2025-09-12 13:41:39 -07:00
Cthulhux eb22d90b56 feat(locale): update German translation 2025-09-10 18:26:23 -07:00
Julien Voisin 8f2dd02f3f refactor(subscription): combine all JSON feed mime types in one query
- Look for JSON feeds in one pass instead of two
- Move conditions around to reduce the amount of comparisons
- Edit an existing test to exercise this commit's changes
2025-09-10 16:50:40 -07:00
Matthaiks da8d4d86c3 feat(locale): update Polish translation 2025-09-10 16:28:21 -07:00
Frédéric Guillot 7ada5d54be fix(icon): implement better handling of relative icon URLs within a subfolder 2025-09-09 20:18:50 -07:00
Kevin Sicong Jiang 8129500296 feat(integration): add support for Wallabag tags 2025-09-09 17:47:51 -07:00
dependabot[bot] f1143151c5 build(deps): bump golang.org/x/net from 0.43.0 to 0.44.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/net/compare/v0.43.0...v0.44.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 16:16:09 -07:00
dependabot[bot] baf8e40152 build(deps): bump golang.org/x/image from 0.30.0 to 0.31.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/image/compare/v0.30.0...v0.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:59:40 -07:00
dependabot[bot] a98374b81f build(deps): bump golang.org/x/crypto from 0.41.0 to 0.42.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.41.0 to 0.42.0.
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.42.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:50:16 -07:00
dependabot[bot] 77e0b07f9f build(deps): bump golang.org/x/term from 0.34.0 to 0.35.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/term/compare/v0.34.0...v0.35.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:42:29 -07:00
Julien Voisin 645800ce3c refactor(js): remove isTouchSupported() static function 2025-09-08 15:41:50 -07:00
dependabot[bot] a60a153003 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.0 to 2.24.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.0 to 2.24.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.0...v2.24.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:36:47 -07:00
dependabot[bot] 7d40e2993b build(deps): bump golang.org/x/oauth2 from 0.30.0 to 0.31.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.30.0...v0.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:36:03 -07:00
dependabot[bot] 91a625c559 build(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:35:34 -07:00
dependabot[bot] dbb3855901 build(deps): bump actions/setup-go from 5 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 15:35:06 -07:00
Julien Voisin 3051acf369 perf(xml): eliminate bound checks in filterValidXMLChars
Optimizes the filterValidXMLChars function by changing the loop variable type from int to uint to eliminate bound checks during compilation, resulting in a ~4% performance improvement.

- Changes loop variable i from int to uint to remove compiler-generated bound checks
- Adjusts type conversions accordingly to maintain correctness

```
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/parser
        │   old.txt   │              new.txt               │
        │   sec/op    │   sec/op     vs base               │
Parse-8   40.91m ± 3%   39.30m ± 2%  -3.94% (p=0.000 n=50)
```
2025-09-08 15:33:55 -07:00
Frédéric Guillot 5f38054965 fix(ui): incorrect force refresh interval unit used in messages
Regression introduced in commit c6536e8
2025-09-08 12:12:11 -07:00
Julien Voisin fa361ab1ce perf: convert some slices to array 2025-09-08 12:11:18 -07:00
jvoisin afe80e6bae refactor(proxy): remove usage of fmt
There is no need to use the heavy machinery (fmt) when we can simply use
string concatenation instead.
2025-09-08 12:00:59 -07:00
Julien Voisin f2976bff5d refactor: remove model.UserSessions struct 2025-09-08 11:56:43 -07:00
Julien Voisin 84078c7c20 refactor: avoid unnecessary usage of Printf 2025-09-08 11:54:16 -07:00
dependabot[bot] d70817e441 build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.0 to 1.23.2.
- [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.23.0...v1.23.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 11:42:23 -07:00
Frédéric Guillot e8f5c2446c fix(config): FORCE_REFRESH_INTERVAL duration should be in minutes
Regression introduced in commit c6536e8
2025-08-25 15:46:10 -07:00
Matthaiks 737f25f441 feat(locale): update Polish translation 2025-08-24 13:04:25 -07:00
Frédéric Guillot f83d66769d fix(api): do not return removed entries
Since Miniflux 2.2.12, the content of removed entries is cleared.
2025-08-23 15:17:37 -07:00
jvoisin af149e46df Revert "refactor(storage): simplify feed.go by using min(), inline errors, and use idiomatic conditions"
This reverts commit b1cbaae71c.
2025-08-22 12:51:54 -07:00
jvoisin 4f252b33c9 refactor(template): rename noescape to safeHTML
This makes the code more consistent, since all the other escaping escape
hatches have a `safe` prefix.
2025-08-22 12:51:21 -07:00
jvoisin da9c3a4032 feat(js): tighten the trusted types policy
- Implement a better/simpler polyfill for web browsers that don't supported
  trusted types yet
- Use two separate policies: one to create HTML, another to create/use script
  urls
- Instead of having the policy live in the top-level scope, they're now
  declared at the lowest possible scope, right before they're used, making them
  inaccessible outside of it. This puts their usage completely out of reach of
  an attacker unable to gain some control outside of those two (small) scopes,
  and thus removes the need to tighten the policies.
- Remove the now-unused tt.js file

This has been tested on Firefox (doesn't support trusted types) and on Chromium
(does support trusted types).
2025-08-21 19:41:45 -07:00
Benedikt Hopmann 8e1d0bb693 fix(locale): Update de_DE translation to match changes from 'bookmark' to 'starred'
Updated strings to match changes from 'bookmark' to 'starred'. Also renamed some strings from 'Einträge' to 'Artikel' for more consistency.
2025-08-21 16:59:54 -07:00
Steven vanZyl 60cd7ffe88 refactor: Replace "Bookmarks" with "Starred"
Replaces usage of the word "bookmark" with "star"/"starred" in order to be more
consistent with the UI and database models, and to reduce confusion with
"bookmarklet" and integration features.

This is in preparation of future work on read-it-later features.
Which are also not called "bookmarks" to prevent any further confusion.
https://github.com/orgs/miniflux/discussions/3719

Related-to: https://github.com/miniflux/v2/pull/2219
2025-08-20 20:49:45 -07:00
Peter Sanchez 4d656d2739 feat(integration): add LinkTaco service for saving articles 2025-08-20 20:35:33 -07:00
gudvinr 983291c78b refactor(cli): use time.Duration for cleanup tasks 2025-08-20 19:45:24 -07:00
gudvinr 7060ecc163 refactor(cli): use time.Duration for scheduler frequency
Polling frequency is undocumented so it's not exacly clear what units were.
2025-08-20 19:45:24 -07:00
gudvinr 4af12a4129 refactor(metric): use time.Duration for refresh duration 2025-08-20 19:45:24 -07:00
gudvinr c6536e8d90 refactor(http): use time.Duration for refresh interval
It's not clear which units of time used for refresh interval.
Convert to time.Duration for clarity.
2025-08-20 19:45:24 -07:00
gudvinr 30453ad7ec refactor(fetcher): use time.Duration for client timeout values
All functions use time.Duration, so instead of converting everywhere, do it once.
2025-08-20 19:45:24 -07:00
gudvinr 71af68becd refactor(server): use time.Duration for timeout values
Instead of converting at the very last moment,
it's simpler and more readable to use time.Duration ASAP.
2025-08-20 19:45:24 -07:00
gudvinr ed3bf59356 refactor(reader): use time.Duration instead of minutes count
In general, duration is used as time unit representation.

At some places when int is returned, there's no documentation which unit is used.

So just convert to time.Duration ASAP.
2025-08-20 19:45:24 -07:00
gudvinr 03021af53c feat(config): time interval parser
Simplifies handling of time intervals in config values.
2025-08-20 19:45:24 -07:00
gudvinr 83254a1f26 refactor(model): replace Sprintf("%d") in tests
This is just Itoa but weird.
2025-08-20 19:45:24 -07:00
Julien Voisin 3acb888309 refactor(response): simplify switch-case and remove unnecessary defer
- b.body can never be of type error, so let's remove it from the switch-case
  construct.
- there is no need to use defer when the only return statement is two lines
  after.
2025-08-20 19:31:28 -07:00
Julien Voisin 7c29166ef9 refactor(ui): move inline SVG to sprite.svg
There is no need to have an inline svg when we can have it in the sprite.svg
file.
2025-08-20 19:26:47 -07:00
Julien Voisin 71105bec56 refactor(template): improve consistency in layout.html (meta tags, whitespace, comments)
- Group the `<meta>` tags together
- Trim some superfluous whitespace
- Remove an obvious and thus useless comment
2025-08-20 19:24:13 -07:00
Julien Voisin 2a372674a2 refactor(icon): avoid calling AbsoluteURL twice for the feed icon URL
The url of a feed's icon is always absolute.
2025-08-20 19:01:59 -07:00
Julien Voisin 9d32b23ab0 perf(sanitizer): make sanitizer ~10% faster by using slices.Contains instead of nested maps
```console
$ go test -bench=. -count=25 > old.txt
$ go test -bench=. -count=25 > new.txt
$ benchstat old.txt new.txt
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/sanitizer
           │   old.txt   │            new.txt            │
           │   sec/op    │   sec/op     vs base          │
Sanitize-8   21.55m ± 5%   19.64m ± 9%  ~ (p=0.059 n=25)
```

Almost a 10% improvement, yay.
2025-08-20 18:54:49 -07:00
jvoisin da8bf3890c refactor(templates): be explicit about dependencies
Instead of blindly compiling all the common/ templates for every view/ ones,
let's be explicit about the dependencies. This should significantly decrease
the resident memory consumption, as ParseTemplate is responsible for ~10M of
the current 11M of heap memory on my instance, so any win there is interesting.
This will also allow better factorization of templates, now that everything is
explicit. Another side-effect is that it'll make testing easier, as we now have
a comprehensive list of views/ templates affected by a change in a file in
common/
2025-08-20 18:51:51 -07:00
dependabot[bot] b30eac4ebe build(deps): bump github.com/tdewolff/minify/v2 from 2.23.11 to 2.24.0
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.11 to 2.24.0.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.11...v2.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 17:52:22 -07:00
132 changed files with 6504 additions and 4385 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Golang
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: stable
check-latest: true
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: stable
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: stable
- uses: golangci/golangci-lint-action@v8
@@ -47,7 +47,7 @@ jobs:
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Validate PR commits
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: stable
- name: Run unit tests with coverage and race conditions checking
@@ -46,7 +46,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: stable
- name: Install Postgres client
+1 -1
View File
@@ -72,7 +72,7 @@ Features
### Integrations
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- Bookmarklet for subscribing to websites directly from any web browser.
- Webhooks for real-time notifications or custom integrations.
- Compatibility with existing mobile applications using the Fever or Google Reader API.
+3 -3
View File
@@ -650,9 +650,9 @@ func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationReque
return entry, nil
}
// ToggleBookmark toggles entry bookmark value.
func (c *Client) ToggleBookmark(entryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
// ToggleStarred toggles entry starred value.
func (c *Client) ToggleStarred(entryID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/star", entryID), nil)
return err
}
+17 -15
View File
@@ -6,21 +6,21 @@ require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.15.0
github.com/go-webauthn/webauthn v0.13.4
github.com/go-webauthn/webauthn v0.14.0
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.23.0
github.com/tdewolff/minify/v2 v2.23.11
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.30.0
golang.org/x/term v0.34.0
github.com/prometheus/client_golang v1.23.2
github.com/tdewolff/minify/v2 v2.24.3
golang.org/x/crypto v0.42.0
golang.org/x/image v0.31.0
golang.org/x/net v0.44.0
golang.org/x/oauth2 v0.31.0
golang.org/x/term v0.35.0
)
require (
github.com/go-webauthn/x v0.1.23 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
)
@@ -31,16 +31,18 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
go 1.24.0
+46 -32
View File
@@ -10,18 +10,19 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -33,6 +34,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -43,20 +48,22 @@ 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.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -66,16 +73,20 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
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/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -90,10 +101,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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=
@@ -112,8 +123,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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=
@@ -123,8 +134,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
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=
@@ -134,8 +145,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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=
@@ -143,7 +154,10 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -1
View File
@@ -67,7 +67,8 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/star", handler.toggleStarred).Methods(http.MethodPut)
sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost)
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
+61 -71
View File
@@ -2381,7 +2381,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
}
feedIDEntry, err := regularUserClient.Feed(feedID)
if err != nil {
t.Fatal(err)
}
@@ -2413,6 +2412,64 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
}
}
func TestCannotGetRemovedEntries(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)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if feedEntries.Total == 0 {
t.Fatalf(`Expected at least one entry, got none`)
}
if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
t.Fatalf(`Expected entry to be not found, got %v`, err)
}
updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
if err != nil {
t.Fatal(err)
}
if updatedFeedEntries.Total != feedEntries.Total-1 {
t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
}
}
func TestUpdateEnclosureEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -2631,64 +2688,6 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
}
}
func TestUpdateEntryRemovedStatusEndpoint(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)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
// First we set the entry as "removed"
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.Status != miniflux.EntryStatusRemoved {
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
}
// Then we try to set it to "unread"
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
t.Fatal(err)
}
entry, err = regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
// It should stay set to "removed"
if entry.Status != miniflux.EntryStatusRemoved {
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
}
}
func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -2749,7 +2748,7 @@ func TestUpdateEntryEndpoint(t *testing.T) {
}
}
func TestToggleBookmarkEndpoint(t *testing.T) {
func TestToggleStarredEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
@@ -2777,7 +2776,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
t.Fatalf(`Failed to get entries: %v`, err)
}
if err := regularUserClient.ToggleBookmark(result.Entries[0].ID); err != nil {
if err := regularUserClient.ToggleStarred(result.Entries[0].ID); err != nil {
t.Fatal(err)
}
@@ -2787,7 +2786,7 @@ func TestToggleBookmarkEndpoint(t *testing.T) {
}
if !entry.Starred {
t.Fatalf(`The entry should be bookmarked`)
t.Fatalf(`The entry should be starred`)
}
}
@@ -2932,13 +2931,4 @@ func TestFlushHistoryEndpoint(t *testing.T) {
if readEntries.Total != 0 {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
removedEntries, err := regularUserClient.Entries(&miniflux.Filter{Status: miniflux.EntryStatusRemoved})
if err != nil {
t.Fatal(err)
}
if removedEntries.Total != 2 {
t.Fatalf(`Invalid total, got %d`, removedEntries.Total)
}
}
+6 -2
View File
@@ -47,6 +47,7 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -58,6 +59,7 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -66,6 +68,7 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
h.getEntryFromBuilder(w, r, builder)
}
@@ -136,6 +139,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
builder.WithLimit(limit)
builder.WithTags(tags)
builder.WithEnclosures()
builder.WithoutStatus(model.EntryStatusRemoved)
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
@@ -186,9 +190,9 @@ func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
json.NoContent(w, r)
}
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
func (h *handler) toggleStarred(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
if err := h.store.ToggleStarred(request.UserID(r), entryID); err != nil {
json.ServerError(w, r, err)
return
}
+3 -2
View File
@@ -5,6 +5,7 @@ package cli // import "miniflux.app/v2/internal/cli"
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
@@ -16,7 +17,7 @@ func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
printErrorAndExit(errors.New("this is not an interactive terminal, exiting"))
}
fmt.Print("Enter Username: ")
@@ -30,6 +31,6 @@ func askCredentials() (string, string) {
defer term.Restore(fd, state)
bytePassword, _ := term.ReadPassword(fd)
fmt.Printf("\n")
fmt.Print("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
}
+4 -4
View File
@@ -14,15 +14,15 @@ import (
)
func runCleanupTasks(store *storage.Storage) {
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsDays())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsDays())
nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
slog.Info("Sessions cleanup completed",
slog.Int64("application_sessions_removed", nbSessions),
slog.Int64("user_sessions_removed", nbUserSessions),
)
startTime := time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive read entries", slog.Any("error", err))
} else {
slog.Info("Archiving read entries completed",
@@ -35,7 +35,7 @@ func runCleanupTasks(store *storage.Storage) {
}
startTime = time.Now()
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadDays(), config.Opts.CleanupArchiveBatchSize()); err != nil {
if rowsAffected, err := store.ArchiveEntries(model.EntryStatusUnread, config.Opts.CleanupArchiveUnreadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to archive unread entries", slog.Any("error", err))
} else {
slog.Info("Archiving unread entries completed",
+2 -2
View File
@@ -78,7 +78,7 @@ func Parse() {
flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
flag.Parse()
cfg := config.NewParser()
cfg := config.NewConfigParser()
if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile)
@@ -263,6 +263,6 @@ func Parse() {
}
func printErrorAndExit(err error) {
fmt.Fprintf(os.Stderr, "%v\n", err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+5 -5
View File
@@ -26,12 +26,12 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
go cleanupScheduler(
store,
config.Opts.CleanupFrequencyHours(),
config.Opts.CleanupFrequency(),
)
}
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(time.Duration(frequency) * time.Minute) {
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency time.Duration, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(frequency) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
batchBuilder.WithBatchSize(batchSize)
@@ -49,8 +49,8 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
}
}
func cleanupScheduler(store *storage.Storage, frequency int) {
for range time.Tick(time.Duration(frequency) * time.Hour) {
func cleanupScheduler(store *storage.Storage, frequency time.Duration) {
for range time.Tick(frequency) {
runCleanupTasks(store)
}
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import "miniflux.app/v2/internal/version"
// Opts holds parsed configuration options.
var Opts *configOptions
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
File diff suppressed because it is too large Load Diff
+806 -633
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207 -268
View File
@@ -7,310 +7,214 @@ import (
"bufio"
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// parser handles configuration parsing.
type parser struct {
opts *options
type configParser struct {
options *configOptions
}
// NewParser returns a new Parser.
func NewParser() *parser {
return &parser{
opts: NewOptions(),
func NewConfigParser() *configParser {
return &configParser{
options: NewConfigOptions(),
}
}
// ParseEnvironmentVariables loads configuration values from environment variables.
func (p *parser) ParseEnvironmentVariables() (*options, error) {
err := p.parseLines(os.Environ())
if err != nil {
func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
if err := cp.parseLines(os.Environ()); err != nil {
return nil, err
}
return p.opts, nil
return cp.options, nil
}
// ParseFile loads configuration values from a local file.
func (p *parser) ParseFile(filename string) (*options, error) {
func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fp.Close()
err = p.parseLines(p.parseFileContent(fp))
if err != nil {
if err := cp.parseLines(parseFileContent(fp)); err != nil {
return nil, err
}
return p.opts, nil
return cp.options, nil
}
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
func (cp *configParser) postParsing() error {
// Parse basePath and rootURL based on BASE_URL
baseURL := cp.options.options["BASE_URL"].ParsedStringValue
baseURL = strings.TrimSuffix(baseURL, "/")
func (p *parser) parseLines(lines []string) (err error) {
var port string
for lineNum, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "LOG_FILE":
p.opts.logFile = parseString(value, defaultLogFile)
case "LOG_DATE_TIME":
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
case "LOG_LEVEL":
parsedValue := parseString(value, defaultLogLevel)
if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
p.opts.logLevel = parsedValue
}
case "LOG_FORMAT":
parsedValue := parseString(value, defaultLogFormat)
if parsedValue == "json" || parsedValue == "text" {
p.opts.logFormat = parsedValue
}
case "BASE_URL":
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
if err != nil {
return err
}
case "PORT":
port = value
case "LISTEN_ADDR":
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
case "DATABASE_URL":
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
case "DATABASE_URL_FILE":
p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
case "DATABASE_MAX_CONNS":
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
case "DATABASE_MIN_CONNS":
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
case "DATABASE_CONNECTION_LIFETIME":
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
case "FILTER_ENTRY_MAX_AGE_DAYS":
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
case "RUN_MIGRATIONS":
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
case "DISABLE_HSTS":
p.opts.hsts = !parseBool(value, defaultHSTS)
case "HTTPS":
p.opts.HTTPS = parseBool(value, defaultHTTPS)
case "DISABLE_SCHEDULER_SERVICE":
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
case "DISABLE_HTTP_SERVICE":
p.opts.httpService = !parseBool(value, defaultHTTPService)
case "CERT_FILE":
p.opts.certFile = parseString(value, defaultCertFile)
case "KEY_FILE":
p.opts.certKeyFile = parseString(value, defaultKeyFile)
case "CERT_DOMAIN":
p.opts.certDomain = parseString(value, defaultCertDomain)
case "CLEANUP_FREQUENCY_HOURS":
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
case "CLEANUP_ARCHIVE_READ_DAYS":
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
case "CLEANUP_ARCHIVE_UNREAD_DAYS":
p.opts.cleanupArchiveUnreadDays = parseInt(value, defaultCleanupArchiveUnreadDays)
case "CLEANUP_ARCHIVE_BATCH_SIZE":
p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
case "CLEANUP_REMOVE_SESSIONS_DAYS":
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
case "WORKER_POOL_SIZE":
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
case "FORCE_REFRESH_INTERVAL":
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
case "BATCH_SIZE":
p.opts.batchSize = parseInt(value, defaultBatchSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "POLLING_LIMIT_PER_HOST":
p.opts.pollingLimitPerHost = parseInt(value, 0)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
case "POLLING_SCHEDULER":
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
p.opts.schedulerEntryFrequencyMaxInterval = parseInt(value, defaultSchedulerEntryFrequencyMaxInterval)
case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
p.opts.schedulerEntryFrequencyMinInterval = parseInt(value, defaultSchedulerEntryFrequencyMinInterval)
case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_MODE":
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "MEDIA_PROXY_RESOURCE_TYPES":
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_CUSTOM_URL":
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
if err != nil {
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
}
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME":
p.opts.adminUsername = parseString(value, defaultAdminUsername)
case "ADMIN_USERNAME_FILE":
p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
case "ADMIN_PASSWORD":
p.opts.adminPassword = parseString(value, defaultAdminPassword)
case "ADMIN_PASSWORD_FILE":
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
case "OAUTH2_USER_CREATION":
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
case "OAUTH2_CLIENT_ID":
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_ID_FILE":
p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_SECRET":
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
case "OAUTH2_CLIENT_SECRET_FILE":
p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
case "OAUTH2_REDIRECT_URL":
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
case "OAUTH2_OIDC_PROVIDER_NAME":
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
case "OAUTH2_PROVIDER":
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
case "DISABLE_LOCAL_AUTH":
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
case "HTTP_CLIENT_TIMEOUT":
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
case "HTTP_CLIENT_MAX_BODY_SIZE":
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
case "HTTP_CLIENT_PROXY":
p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
if err != nil {
return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
}
case "HTTP_CLIENT_PROXIES":
p.opts.httpClientProxies = parseStringList(value, []string{})
case "HTTP_CLIENT_USER_AGENT":
p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
case "HTTP_SERVER_TIMEOUT":
p.opts.httpServerTimeout = parseInt(value, defaultHTTPServerTimeout)
case "AUTH_PROXY_HEADER":
p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
case "AUTH_PROXY_USER_CREATION":
p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
case "MAINTENANCE_MODE":
p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
case "MAINTENANCE_MESSAGE":
p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
case "METRICS_COLLECTOR":
p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
case "METRICS_REFRESH_INTERVAL":
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
case "METRICS_ALLOWED_NETWORKS":
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
case "METRICS_USERNAME":
p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
case "METRICS_USERNAME_FILE":
p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
case "METRICS_PASSWORD":
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
case "FETCH_BILIBILI_WATCH_TIME":
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
case "FETCH_NEBULA_WATCH_TIME":
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME":
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":
p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "WEBAUTHN":
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
}
}
if port != "" {
p.opts.listenAddr = []string{":" + port}
}
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
parsedURL, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
}
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
return nil
}
func parseBaseURL(value string) (string, string, string, error) {
if value == "" {
return defaultBaseURL, defaultRootURL, "", nil
}
value = strings.TrimSuffix(value, "/")
parsedURL, err := url.Parse(value)
if err != nil {
return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
return fmt.Errorf("invalid BASE_URL: %v", err)
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "https" && scheme != "http" {
return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
return fmt.Errorf("BASE_URL scheme must be http or https")
}
basePath := parsedURL.Path
cp.options.options["BASE_URL"].ParsedStringValue = baseURL
cp.options.basePath = parsedURL.Path
parsedURL.Path = ""
return value, parsedURL.String(), basePath, nil
cp.options.rootURL = parsedURL.String()
// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
if youTubeEmbedURLOverride != "" {
parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
if err != nil {
return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
}
cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
}
// Generate a media proxy private key if not set
if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue) == 0 {
randomKey := make([]byte, 16)
rand.Read(randomKey)
cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue = randomKey
}
// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
if cp.options.Port() != "" {
cp.options.options["LISTEN_ADDR"].ParsedStringList = []string{":" + cp.options.Port()}
cp.options.options["LISTEN_ADDR"].RawValue = ":" + cp.options.Port()
}
return nil
}
func parseBool(value string, fallback bool) bool {
func (cp *configParser) parseLines(lines []string) error {
for lineNum, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if err := cp.parseLine(key, value); err != nil {
return err
}
}
if err := cp.postParsing(); err != nil {
return err
}
return nil
}
func (cp *configParser) parseLine(key, value string) error {
field, exists := cp.options.options[key]
if !exists {
// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
return nil
}
// Validate the option if a validator is provided
if field.Validator != nil {
if err := field.Validator(value); err != nil {
return fmt.Errorf("invalid value for key %s: %v", key, err)
}
}
// Convert the raw value based on its type
switch field.ValueType {
case stringType:
field.ParsedStringValue = parseStringValue(value, field.ParsedStringValue)
field.RawValue = value
case stringListType:
field.ParsedStringList = parseStringListValue(value, field.ParsedStringList)
field.RawValue = value
case boolType:
parsedValue, err := parseBoolValue(value, field.ParsedBoolValue)
if err != nil {
return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
}
field.ParsedBoolValue = parsedValue
field.RawValue = value
case intType:
field.ParsedIntValue = parseIntValue(value, field.ParsedIntValue)
field.RawValue = value
case int64Type:
field.ParsedInt64Value = ParsedInt64Value(value, field.ParsedInt64Value)
field.RawValue = value
case secondType:
field.ParsedDuration = parseDurationValue(value, time.Second, field.ParsedDuration)
field.RawValue = value
case minuteType:
field.ParsedDuration = parseDurationValue(value, time.Minute, field.ParsedDuration)
field.RawValue = value
case hourType:
field.ParsedDuration = parseDurationValue(value, time.Hour, field.ParsedDuration)
field.RawValue = value
case dayType:
field.ParsedDuration = parseDurationValue(value, time.Hour*24, field.ParsedDuration)
field.RawValue = value
case urlType:
parsedURL, err := parseURLValue(value, field.ParsedURLValue)
if err != nil {
return fmt.Errorf("invalid URL for key %s: %v", key, err)
}
field.ParsedURLValue = parsedURL
field.RawValue = value
case secretFileType:
secretValue, err := readSecretFileValue(value)
if err != nil {
return fmt.Errorf("error reading secret file for key %s: %v", key, err)
}
if field.TargetKey != "" {
if targetField, ok := cp.options.options[field.TargetKey]; ok {
targetField.ParsedStringValue = secretValue
targetField.RawValue = secretValue
}
}
field.RawValue = value
case bytesType:
if value != "" {
field.ParsedBytesValue = []byte(value)
field.RawValue = value
}
}
return nil
}
func parseStringValue(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func parseBoolValue(value string, fallback bool) (bool, error) {
if value == "" {
return fallback, nil
}
value = strings.ToLower(value)
if value == "1" || value == "yes" || value == "true" || value == "on" {
return true
return true, nil
}
if value == "0" || value == "no" || value == "false" || value == "off" {
return false, nil
}
return false
return false, fmt.Errorf("invalid boolean value: %q", value)
}
func parseInt(value string, fallback int) int {
func parseIntValue(value string, fallback int) int {
if value == "" {
return fallback
}
@@ -323,14 +227,20 @@ func parseInt(value string, fallback int) int {
return v
}
func parseString(value string, fallback string) string {
func ParsedInt64Value(value string, fallback int64) int64 {
if value == "" {
return fallback
}
return value
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fallback
}
return v
}
func parseStringList(value string, fallback []string) []string {
func parseStringListValue(value string, fallback []string) []string {
if value == "" {
return fallback
}
@@ -350,24 +260,53 @@ func parseStringList(value string, fallback []string) []string {
return strList
}
func parseBytes(value string, fallback []byte) []byte {
func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
if value == "" {
return fallback
}
return []byte(value)
}
func readSecretFile(filename, fallback string) string {
data, err := os.ReadFile(filename)
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
value := string(bytes.TrimSpace(data))
return time.Duration(v) * unit
}
func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
if value == "" {
return fallback
return fallback, nil
}
return value
parsedURL, err := url.Parse(value)
if err != nil {
return fallback, err
}
return parsedURL, nil
}
func readSecretFileValue(filename string) (string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return "", err
}
value := string(bytes.TrimSpace(data))
if value == "" {
return "", fmt.Errorf("secret file is empty")
}
return value, nil
}
func parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
+406 -137
View File
@@ -4,163 +4,432 @@
package config // import "miniflux.app/v2/internal/config"
import (
"net/url"
"os"
"reflect"
"testing"
"time"
)
func TestParseBoolValue(t *testing.T) {
scenarios := map[string]bool{
"": true,
"1": true,
"Yes": true,
"yes": true,
"True": true,
"true": true,
"on": true,
"false": false,
"off": false,
"invalid": false,
func TestParseStringValue(t *testing.T) {
// Test with non-empty value
result := parseStringValue("test", "fallback")
if result != "test" {
t.Errorf("Expected 'test', got '%s'", result)
}
for input, expected := range scenarios {
result := parseBool(input, true)
if result != expected {
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
// Test with empty value
result = parseStringValue("", "fallback")
if result != "fallback" {
t.Errorf("Expected 'fallback', got '%s'", result)
}
// Test with empty value and empty fallback
result = parseStringValue("", "")
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}
func TestParseBoolValue(t *testing.T) {
// Test with empty value - should return fallback
result, err := parseBoolValue("", true)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != true {
t.Errorf("Expected true, got %v", result)
}
// Test true values
trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
for _, value := range trueValues {
result, err := parseBoolValue(value, false)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != true {
t.Errorf("Expected true for '%s', got %v", value, result)
}
}
}
func TestParseStringValueWithUnsetVariable(t *testing.T) {
if parseString("", "defaultValue") != "defaultValue" {
t.Errorf(`Unset variables should returns the default value`)
// Test false values
falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
for _, value := range falseValues {
result, err := parseBoolValue(value, true)
if err != nil {
t.Errorf("Unexpected error for value '%s': %v", value, err)
}
if result != false {
t.Errorf("Expected false for '%s', got %v", value, result)
}
}
}
func TestParseStringValue(t *testing.T) {
if parseString("test", "defaultValue") != "test" {
t.Errorf(`Defined variables should returns the specified value`)
}
}
func TestParseIntValueWithUnsetVariable(t *testing.T) {
if parseInt("", 42) != 42 {
t.Errorf(`Unset variables should returns the default value`)
}
}
func TestParseIntValueWithInvalidInput(t *testing.T) {
if parseInt("invalid integer", 42) != 42 {
t.Errorf(`Invalid integer should returns the default value`)
// Test invalid value - should return error
_, err = parseBoolValue("invalid", false)
if err == nil {
t.Error("Expected error for invalid boolean value")
}
}
func TestParseIntValue(t *testing.T) {
if parseInt("2018", 42) != 2018 {
t.Errorf(`Defined variables should returns the specified value`)
// Test with empty value - should return fallback
result := parseIntValue("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with valid integer
result = parseIntValue("123", 42)
if result != 123 {
t.Errorf("Expected 123, got %d", result)
}
// Test with invalid integer - should return fallback
result = parseIntValue("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
// Test with zero
result = parseIntValue("0", 42)
if result != 0 {
t.Errorf("Expected 0, got %d", result)
}
}
func TestParseListenAddr(t *testing.T) {
defaultExpected := []string{defaultListenAddr}
tests := []struct {
name string
listenAddr string
port string
expected []string
lines []string // Used for direct lines parsing instead of individual env vars
isLineOriented bool // Flag to indicate if we use lines
}{
{
name: "Single LISTEN_ADDR",
listenAddr: "127.0.0.1:8080",
expected: []string{"127.0.0.1:8080"},
},
{
name: "Multiple LISTEN_ADDR comma-separated",
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
},
{
name: "Multiple LISTEN_ADDR with spaces around commas",
listenAddr: "127.0.0.1:8080 , :8081",
expected: []string{"127.0.0.1:8080", ":8081"},
},
{
name: "Empty LISTEN_ADDR",
listenAddr: "",
expected: defaultExpected,
},
{
name: "PORT overrides LISTEN_ADDR",
listenAddr: "127.0.0.1:8000",
port: "8082",
expected: []string{":8082"},
},
{
name: "PORT overrides empty LISTEN_ADDR",
listenAddr: "",
port: "8083",
expected: []string{":8083"},
},
{
name: "LISTEN_ADDR with empty segment (comma)",
listenAddr: "127.0.0.1:8080,,:8081",
expected: []string{"127.0.0.1:8080", ":8081"},
},
{
name: "PORT override with lines parsing",
isLineOriented: true,
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
expected: []string{":8082"},
},
{
name: "LISTEN_ADDR only with lines parsing (comma)",
isLineOriented: true,
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
},
{
name: "Empty LISTEN_ADDR with lines parsing (default)",
isLineOriented: true,
lines: []string{"LISTEN_ADDR="},
expected: defaultExpected,
},
func TestParsedInt64Value(t *testing.T) {
// Test with empty value - should return fallback
result := ParsedInt64Value("", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
var err error
// Test with valid int64
result = ParsedInt64Value("9223372036854775807", 42)
if result != 9223372036854775807 {
t.Errorf("Expected 9223372036854775807, got %d", result)
}
if tt.isLineOriented {
err = parser.parseLines(tt.lines)
} else {
// Simulate os.Environ() behaviour for individual var testing
var envLines []string
if tt.listenAddr != "" {
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
}
if tt.port != "" {
envLines = append(envLines, "PORT="+tt.port)
}
// Add a dummy var if both are empty to avoid empty lines slice if not intended
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
// This case specifically tests empty LISTEN_ADDR resulting in default
// So, we pass LISTEN_ADDR=
envLines = append(envLines, "LISTEN_ADDR=")
}
err = parser.parseLines(envLines)
}
if err != nil {
t.Fatalf("parseLines() error = %v", err)
}
opts := parser.opts
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
}
})
// Test with invalid int64 - should return fallback
result = ParsedInt64Value("invalid", 42)
if result != 42 {
t.Errorf("Expected 42, got %d", result)
}
}
func TestParseStringListValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := []string{"a", "b"}
result := parseStringListValue("", fallback)
if !reflect.DeepEqual(result, fallback) {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with single value
result = parseStringListValue("item1", nil)
expected := []string{"item1"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with multiple values
result = parseStringListValue("item1,item2,item3", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with duplicates - should remove duplicates
result = parseStringListValue("item1,item2,item1", nil)
expected = []string{"item1", "item2"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with spaces
result = parseStringListValue(" item1 , item2 , item3 ", nil)
expected = []string{"item1", "item2", "item3"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
func TestParseDurationValue(t *testing.T) {
// Test with empty value - should return fallback
fallback := 5 * time.Second
result := parseDurationValue("", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
// Test with valid duration
result = parseDurationValue("30", time.Second, fallback)
expected := 30 * time.Second
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with minutes
result = parseDurationValue("5", time.Minute, fallback)
expected = 5 * time.Minute
if result != expected {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with invalid value - should return fallback
result = parseDurationValue("invalid", time.Second, fallback)
if result != fallback {
t.Errorf("Expected %v, got %v", fallback, result)
}
}
func TestParseURLValue(t *testing.T) {
// Test with empty value - should return fallback
fallbackURL, _ := url.Parse("https://fallback.com")
result, err := parseURLValue("", fallbackURL)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != fallbackURL {
t.Errorf("Expected %v, got %v", fallbackURL, result)
}
// Test with valid URL
result, err = parseURLValue("https://example.com", nil)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.String() != "https://example.com" {
t.Errorf("Expected https://example.com, got %s", result.String())
}
// Test with invalid URL - should return fallback and error
result, err = parseURLValue("://invalid", fallbackURL)
if err == nil {
t.Error("Expected error for invalid URL")
}
if result != fallbackURL {
t.Errorf("Expected fallback URL, got %v", result)
}
}
func TestConfigFileParsing(t *testing.T) {
fileContent := `
# This is a comment
LOG_FILE=miniflux.log
LOG_DATE_TIME=1
LOG_FORMAT=json
LISTEN_ADDR=:8080,:8443
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
configOptions, err := configParser.ParseFile(filename)
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
if configOptions.LogFormat() != "json" {
t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
}
if configOptions.LogLevel() != "info" {
t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
}
if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
}
}
func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
fileContent := `
LOG_FILE=miniflux.log
INVALID_LINE
`
// Write a temporary config file and parse it
tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
filename := tmpFile.Name()
if _, err := tmpFile.WriteString(fileContent); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
configParser := NewConfigParser()
_, err = configParser.ParseFile(filename)
if err != nil {
t.Fatal("Invalid lines should be ignored, but got error:", err)
}
}
func TestParseAdminPasswordFileOption(t *testing.T) {
tmpFile, err := os.CreateTemp("", "password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
password := "supersecret"
if _, err := tmpFile.WriteString(password); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.AdminPassword() != password {
t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
}
}
func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
os.Clearenv()
os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
configParser := NewConfigParser()
_, err = configParser.ParseEnvironmentVariables()
if err == nil {
t.Fatal("Expected error due to empty password file, but got none")
}
}
func TestParseLogFileOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "miniflux.log")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "miniflux.log" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_FILE", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogFile() != "stderr" {
t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
}
}
func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
os.Clearenv()
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "true")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != true {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "")
configParser := NewConfigParser()
configOptions, err := configParser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf("Unexpected parsing error: %v", err)
}
if configOptions.LogDateTime() != false {
t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
}
}
func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
os.Clearenv()
os.Setenv("LOG_DATE_TIME", "invalid")
configParser := NewConfigParser()
if _, err := configParser.ParseEnvironmentVariables(); err == nil {
t.Fatal("Expected parsing error, got nil")
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"fmt"
"slices"
"strconv"
"strings"
)
func validateChoices(rawValue string, choices []string) error {
if !slices.Contains(choices, rawValue) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
}
return nil
}
func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if !slices.Contains(choices, value) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
}
}
return nil
}
func validateGreaterThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return fmt.Errorf("value must be an integer")
}
if intValue > min {
return nil
}
return fmt.Errorf("value must be at least %d", min)
}
func validateGreaterOrEqualThan(rawValue string, min int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return fmt.Errorf("value must be an integer")
}
if intValue >= min {
return nil
}
return fmt.Errorf("value must be greater or equal than %d", min)
}
func validateRange(rawValue string, min, max int) error {
intValue, err := strconv.Atoi(rawValue)
if err != nil {
return fmt.Errorf("value must be an integer")
}
if intValue < min || intValue > max {
return fmt.Errorf("value must be between %d and %d", min, max)
}
return nil
}
+372
View File
@@ -0,0 +1,372 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
import (
"strings"
"testing"
)
func TestValidateChoices(t *testing.T) {
tests := []struct {
name string
rawValue string
choices []string
expectError bool
}{
{
name: "valid choice",
rawValue: "option1",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from middle",
rawValue: "option2",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "valid choice from end",
rawValue: "option3",
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "invalid choice",
rawValue: "invalid",
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "empty value with non-empty choices",
rawValue: "",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "case sensitive - different case",
rawValue: "OPTION1",
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "single choice valid",
rawValue: "only",
choices: []string{"only"},
expectError: false,
},
{
name: "empty choices list",
rawValue: "anything",
choices: []string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChoices(tt.rawValue, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateListChoices(t *testing.T) {
tests := []struct {
name string
inputValues []string
choices []string
expectError bool
}{
{
name: "all valid choices",
inputValues: []string{"option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "single valid choice",
inputValues: []string{"option1"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "empty input list",
inputValues: []string{},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "all choices from available list",
inputValues: []string{"option1", "option2", "option3"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "duplicate valid choices",
inputValues: []string{"option1", "option1", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: false,
},
{
name: "one invalid choice",
inputValues: []string{"option1", "invalid"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "all invalid choices",
inputValues: []string{"invalid1", "invalid2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
{
name: "case sensitive - different case",
inputValues: []string{"OPTION1"},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty string in input",
inputValues: []string{""},
choices: []string{"option1", "option2"},
expectError: true,
},
{
name: "empty choices list with non-empty input",
inputValues: []string{"anything"},
choices: []string{},
expectError: true,
},
{
name: "mixed valid and invalid choices",
inputValues: []string{"option1", "invalid", "option2"},
choices: []string{"option1", "option2", "option3"},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateListChoices(tt.inputValues, tt.choices)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else {
// Verify error message format
expectedPrefix := "value must be one of:"
if !strings.Contains(err.Error(), expectedPrefix) {
t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
}
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
func TestValidateGreaterThan(t *testing.T) {
if err := validateGreaterThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterThan("5", 5); err == nil {
t.Errorf("expected error, got none")
}
if err := validateGreaterThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateGreaterOrEqualThan(t *testing.T) {
if err := validateGreaterOrEqualThan("10", 5); err != nil {
t.Errorf("expected no error, got: %v", err)
}
if err := validateGreaterOrEqualThan("5", 5); err != nil {
t.Errorf("expected no error for equal value, got: %v", err)
}
if err := validateGreaterOrEqualThan("abc", 5); err == nil {
t.Errorf("expected error for non-integer input, got none")
}
if err := validateGreaterOrEqualThan("-1", 0); err == nil {
t.Errorf("expected error for value below minimum, got none")
}
}
func TestValidateRange(t *testing.T) {
tests := []struct {
name string
rawValue string
min int
max int
expectError bool
errorMsg string
}{
{
name: "valid integer within range",
rawValue: "5",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at minimum",
rawValue: "1",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid integer at maximum",
rawValue: "10",
min: 1,
max: 10,
expectError: false,
},
{
name: "valid zero in range",
rawValue: "0",
min: -5,
max: 5,
expectError: false,
},
{
name: "valid negative in range",
rawValue: "-3",
min: -5,
max: 5,
expectError: false,
},
{
name: "integer below minimum",
rawValue: "0",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer above maximum",
rawValue: "11",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far below minimum",
rawValue: "-100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "integer far above maximum",
rawValue: "100",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be between 1 and 10",
},
{
name: "non-integer string",
rawValue: "abc",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "empty string",
rawValue: "",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "float string",
rawValue: "5.5",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "string with spaces",
rawValue: " 5 ",
min: 1,
max: 10,
expectError: true,
errorMsg: "value must be an integer",
},
{
name: "single value range",
rawValue: "5",
min: 5,
max: 5,
expectError: false,
},
{
name: "single value range - below",
rawValue: "4",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
{
name: "single value range - above",
rawValue: "6",
min: 5,
max: 5,
expectError: true,
errorMsg: "value must be between 5 and 5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRange(tt.rawValue, tt.min, tt.max)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
})
}
}
+207 -1
View File
@@ -12,7 +12,7 @@ import (
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = []func(tx *sql.Tx) error{
var migrations = [...]func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE schema_version (
@@ -1135,4 +1135,210 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE linktaco_link_visibility AS ENUM (
'PUBLIC',
'PRIVATE'
);
ALTER TABLE integrations
ADD COLUMN linktaco_enabled bool default 'f',
ADD COLUMN linktaco_api_token text default '',
ADD COLUMN linktaco_org_slug text default '',
ADD COLUMN linktaco_tags text default '',
ADD COLUMN linktaco_visibility linktaco_link_visibility default 'PUBLIC';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN wallabag_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
// This migration replaces deprecated timezones by their equivalent on Debian Trixie.
func(tx *sql.Tx) (err error) {
var deprecatedTimeZoneMap = map[string]string{
// Africa
"Africa/Asmera": "Africa/Asmara",
// America - Argentina
"America/Argentina/ComodRivadavia": "America/Argentina/Catamarca",
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
"America/Catamarca": "America/Argentina/Catamarca",
"America/Cordoba": "America/Argentina/Cordoba",
"America/Jujuy": "America/Argentina/Jujuy",
"America/Mendoza": "America/Argentina/Mendoza",
"America/Rosario": "America/Argentina/Cordoba",
// America - US
"America/Fort_Wayne": "America/Indiana/Indianapolis",
"America/Indianapolis": "America/Indiana/Indianapolis",
"America/Knox_IN": "America/Indiana/Knox",
"America/Louisville": "America/Kentucky/Louisville",
// America - Greenland
"America/Godthab": "America/Nuuk",
// Antarctica
"Antarctica/South_Pole": "Pacific/Auckland",
// Asia
"Asia/Ashkhabad": "Asia/Ashgabat",
"Asia/Calcutta": "Asia/Kolkata",
"Asia/Choibalsan": "Asia/Ulaanbaatar",
"Asia/Chungking": "Asia/Chongqing",
"Asia/Dacca": "Asia/Dhaka",
"Asia/Katmandu": "Asia/Kathmandu",
"Asia/Macao": "Asia/Macau",
"Asia/Rangoon": "Asia/Yangon",
"Asia/Saigon": "Asia/Ho_Chi_Minh",
"Asia/Thimbu": "Asia/Thimphu",
"Asia/Ujung_Pandang": "Asia/Makassar",
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
// Atlantic
"Atlantic/Faeroe": "Atlantic/Faroe",
// Australia
"Australia/ACT": "Australia/Sydney",
"Australia/LHI": "Australia/Lord_Howe",
"Australia/North": "Australia/Darwin",
"Australia/NSW": "Australia/Sydney",
"Australia/Queensland": "Australia/Brisbane",
"Australia/South": "Australia/Adelaide",
"Australia/Tasmania": "Australia/Hobart",
"Australia/Victoria": "Australia/Melbourne",
"Australia/West": "Australia/Perth",
// Brazil
"Brazil/Acre": "America/Rio_Branco",
"Brazil/DeNoronha": "America/Noronha",
"Brazil/East": "America/Sao_Paulo",
"Brazil/West": "America/Manaus",
// Canada
"Canada/Atlantic": "America/Halifax",
"Canada/Central": "America/Winnipeg",
"Canada/Eastern": "America/Toronto",
"Canada/Mountain": "America/Edmonton",
"Canada/Newfoundland": "America/St_Johns",
"Canada/Pacific": "America/Vancouver",
"Canada/Saskatchewan": "America/Regina",
"Canada/Yukon": "America/Whitehorse",
// Europe
"CET": "Europe/Paris",
"EET": "Europe/Sofia",
"Europe/Kiev": "Europe/Kyiv",
"Europe/Uzhgorod": "Europe/Kyiv",
"Europe/Zaporozhye": "Europe/Kyiv",
"MET": "Europe/Paris",
"WET": "Europe/Lisbon",
// Chile
"Chile/Continental": "America/Santiago",
"Chile/EasterIsland": "Pacific/Easter",
// Fixed offset and generic zones
"CST6CDT": "America/Chicago",
"EST": "America/New_York",
"EST5EDT": "America/New_York",
"HST": "Pacific/Honolulu",
"MST": "America/Denver",
"MST7MDT": "America/Denver",
"PST8PDT": "America/Los_Angeles",
// Countries/Regions
"Cuba": "America/Havana",
"Egypt": "Africa/Cairo",
"Eire": "Europe/Dublin",
"GB": "Europe/London",
"GB-Eire": "Europe/London",
"Hongkong": "Asia/Hong_Kong",
"Iceland": "Atlantic/Reykjavik",
"Iran": "Asia/Tehran",
"Israel": "Asia/Jerusalem",
"Jamaica": "America/Jamaica",
"Japan": "Asia/Tokyo",
"Libya": "Africa/Tripoli",
"Poland": "Europe/Warsaw",
"Portugal": "Europe/Lisbon",
"PRC": "Asia/Shanghai",
"ROC": "Asia/Taipei",
"ROK": "Asia/Seoul",
"Singapore": "Asia/Singapore",
"Turkey": "Europe/Istanbul",
// GMT variations
"GMT+0": "GMT",
"GMT-0": "GMT",
"GMT0": "GMT",
"Greenwich": "GMT",
"UCT": "UTC",
"Universal": "UTC",
"Zulu": "UTC",
// Mexico
"Mexico/BajaNorte": "America/Tijuana",
"Mexico/BajaSur": "America/Mazatlan",
"Mexico/General": "America/Mexico_City",
// US zones
"Navajo": "America/Denver",
"US/Alaska": "America/Anchorage",
"US/Aleutian": "America/Adak",
"US/Arizona": "America/Phoenix",
"US/Central": "America/Chicago",
"US/Eastern": "America/New_York",
"US/East-Indiana": "America/Indiana/Indianapolis",
"US/Hawaii": "Pacific/Honolulu",
"US/Indiana-Starke": "America/Indiana/Knox",
"US/Michigan": "America/Detroit",
"US/Mountain": "America/Denver",
"US/Pacific": "America/Los_Angeles",
"US/Samoa": "Pacific/Pago_Pago",
// Pacific
"Kwajalein": "Pacific/Kwajalein",
"NZ": "Pacific/Auckland",
"NZ-CHAT": "Pacific/Chatham",
"Pacific/Enderbury": "Pacific/Kanton",
"Pacific/Ponape": "Pacific/Pohnpei",
"Pacific/Truk": "Pacific/Chuuk",
// Special cases
"Factory": "UTC", // Factory is used for unconfigured systems
"W-SU": "Europe/Moscow",
}
// Loop through each user and correct the timezone
rows, err := tx.Query(`SELECT id, timezone FROM users`)
if err != nil {
return err
}
userTimezoneMap := make(map[int64]string)
for rows.Next() {
var userID int64
var userTimezone string
if err := rows.Scan(&userID, &userTimezone); err != nil {
return err
}
userTimezoneMap[userID] = userTimezone
}
rows.Close()
for userID, userTimezone := range userTimezoneMap {
if newTimezone, found := deprecatedTimeZoneMap[userTimezone]; found {
if _, err := tx.Exec(`UPDATE users SET timezone = $1 WHERE id = $2`, newTimezone, userID); err != nil {
return err
}
}
}
return nil
},
}
+5 -5
View File
@@ -145,7 +145,7 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
var result feedsResponse
result.Feeds = make([]feed, 0)
for _, f := range feeds {
subscripion := feed{
subscription := feed{
ID: f.ID,
Title: f.Title,
URL: f.FeedURL,
@@ -155,10 +155,10 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
}
if f.Icon != nil {
subscripion.FaviconID = f.Icon.IconID
subscription.FaviconID = f.Icon.IconID
}
result.Feeds = append(result.Feeds, subscripion)
result.Feeds = append(result.Feeds, subscription)
}
result.FeedsGroups = h.buildFeedGroups(feeds)
@@ -455,7 +455,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
@@ -474,7 +474,7 @@ func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
slog.Int64("entry_id", entryID),
)
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
if err := h.store.ToggleStarred(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
+3 -3
View File
@@ -357,7 +357,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
}
if len(unstarredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, false)
err = h.store.SetEntriesStarredState(userID, unstarredEntryIDs, false)
if err != nil {
json.ServerError(w, r, err)
return
@@ -365,7 +365,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
}
if len(starredEntryIDs) > 0 {
err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true)
err = h.store.SetEntriesStarredState(userID, starredEntryIDs, true)
if err != nil {
json.ServerError(w, r, err)
return
@@ -793,7 +793,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Content: entry.Content,
},
Origin: contentItemOrigin{
StreamID: fmt.Sprintf("feed/%d", entry.FeedID),
StreamID: fmt.Sprintf(feedPrefix+"%d", entry.FeedID),
Title: entry.Feed.Title,
HTMLUrl: entry.Feed.SiteURL,
},
+1 -1
View File
@@ -24,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
Expires: time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
SameSite: http.SameSiteLaxMode,
}
}
+4 -3
View File
@@ -6,6 +6,7 @@ package request // import "miniflux.app/v2/internal/http/request"
import (
"net/http"
"strconv"
"time"
"miniflux.app/v2/internal/model"
)
@@ -135,13 +136,13 @@ func FlashErrorMessage(r *http.Request) string {
}
// LastForceRefresh returns the last force refresh timestamp.
func LastForceRefresh(r *http.Request) int64 {
func LastForceRefresh(r *http.Request) time.Time {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
if err != nil {
return 0
return time.Time{}
}
return timestamp
return time.Unix(timestamp, 0)
}
// ClientIP returns the client IP address stored in the context.
+3 -5
View File
@@ -84,8 +84,6 @@ func (b *Builder) Write() {
b.compress(v)
case string:
b.compress([]byte(v))
case error:
b.compress([]byte(v.Error()))
case io.Reader:
// Compression not implemented in this case
b.writeHeaders()
@@ -117,24 +115,24 @@ func (b *Builder) compress(data []byte) {
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
defer brotliWriter.Close()
brotliWriter.Write(data)
brotliWriter.Close()
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()
gzipWriter := gzip.NewWriter(b.w)
defer gzipWriter.Close()
gzipWriter.Write(data)
gzipWriter.Close()
return
case strings.Contains(acceptEncoding, "deflate"):
b.headers["Content-Encoding"] = "deflate"
b.writeHeaders()
flateWriter, _ := flate.NewWriter(b.w, -1)
defer flateWriter.Close()
flateWriter.Write(data)
flateWriter.Close()
return
}
}
-22
View File
@@ -4,7 +4,6 @@
package response // import "miniflux.app/v2/internal/http/response"
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -105,27 +104,6 @@ func TestBuildResponseWithAttachment(t *testing.T) {
}
}
func TestBuildResponseWithError(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(errors.New("Some error")).Write()
})
handler.ServeHTTP(w, r)
expectedBody := `Some error`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
}
func TestBuildResponseWithByteBody(t *testing.T) {
r, err := http.NewRequest("GET", "/", nil)
if err != nil {
+6 -7
View File
@@ -12,7 +12,6 @@ import (
"os"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/api"
"miniflux.app/v2/internal/config"
@@ -61,15 +60,15 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
config.Opts.HTTPS = true
config.Opts.SetHTTPSValue(true)
httpServers = append(httpServers, challengeServer)
}
for i, listenAddr := range listenAddresses {
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
ReadTimeout: config.Opts.HTTPServerTimeout(),
WriteTimeout: config.Opts.HTTPServerTimeout(),
IdleTimeout: config.Opts.HTTPServerTimeout(),
Handler: setupHandler(store, pool),
}
@@ -96,7 +95,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
case certFile != "" && keyFile != "":
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
config.Opts.HTTPS = true
config.Opts.SetHTTPSValue(true)
default:
server.Addr = listenAddr
startHTTPServer(server)
@@ -149,7 +148,7 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
slog.String("key_file", keyFile),
)
// Ensure HTTPS is marked as true if any listener uses TLS
config.Opts.HTTPS = true
config.Opts.SetHTTPSValue(true)
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
+2 -2
View File
@@ -20,7 +20,7 @@ func middleware(next http.Handler) http.Handler {
ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
if r.Header.Get("X-Forwarded-Proto") == "https" {
config.Opts.HTTPS = true
config.Opts.SetHTTPSValue(true)
}
t1 := time.Now()
@@ -36,7 +36,7 @@ func middleware(next http.Handler) http.Handler {
)
}()
if config.Opts.HTTPS && config.Opts.HasHSTS() {
if config.Opts.HTTPS() && config.Opts.HasHSTS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
+27
View File
@@ -15,6 +15,7 @@ import (
"miniflux.app/v2/internal/integration/karakeep"
"miniflux.app/v2/internal/integration/linkace"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/linktaco"
"miniflux.app/v2/internal/integration/linkwarden"
"miniflux.app/v2/internal/integration/matrixbot"
"miniflux.app/v2/internal/integration/notion"
@@ -107,6 +108,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
if userIntegrations.WallabagEnabled {
slog.Debug("Sending entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.WallabagTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
@@ -117,12 +119,14 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
userIntegrations.WallabagClientSecret,
userIntegrations.WallabagUsername,
userIntegrations.WallabagPassword,
userIntegrations.WallabagTags,
userIntegrations.WallabagOnlyURL,
)
if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Wallabag",
slog.Int64("user_id", userIntegrations.UserID),
slog.String("user_tags", userIntegrations.WallabagTags),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
@@ -242,6 +246,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.LinktacoEnabled {
slog.Debug("Sending entry to LinkTaco",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linktaco.NewClient(
userIntegrations.LinktacoAPIToken,
userIntegrations.LinktacoOrgSlug,
userIntegrations.LinktacoTags,
userIntegrations.LinktacoVisibility,
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to LinkTaco",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkwardenEnabled {
slog.Debug("Sending entry to linkwarden",
slog.Int64("user_id", userIntegrations.UserID),
+1 -1
View File
@@ -50,7 +50,7 @@ func (c *Client) SaveURL(entryURL string) error {
return fmt.Errorf("karakeep: unable to create request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
+146
View File
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linktaco // import "miniflux.app/v2/internal/integration/linktaco"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const (
defaultClientTimeout = 10 * time.Second
defaultGraphQLURL = "https://api.linktaco.com/query"
maxTags = 10
maxDescriptionLength = 500
)
type Client struct {
graphqlURL string
apiToken string
orgSlug string
tags string
visibility string
}
func NewClient(apiToken, orgSlug, tags, visibility string) *Client {
if visibility == "" {
visibility = "PUBLIC"
}
return &Client{
graphqlURL: defaultGraphQLURL,
apiToken: apiToken,
orgSlug: orgSlug,
tags: tags,
visibility: visibility,
}
}
func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error {
if c.apiToken == "" || c.orgSlug == "" {
return fmt.Errorf("linktaco: missing API token or organization slug")
}
description := entryContent
if len(description) > maxDescriptionLength {
description = description[:maxDescriptionLength]
}
// tags (limit to 10)
tags := strings.FieldsFunc(c.tags, func(c rune) bool {
return c == ',' || c == ' '
})
if len(tags) > maxTags {
tags = tags[:maxTags]
}
// tagsStr is used in GraphQL query to pass comma separated tags
tagsStr := strings.Join(tags, ",")
mutation := `
mutation AddLink($input: LinkInput!) {
addLink(input: $input) {
id
url
title
}
}
`
variables := map[string]any{
"input": map[string]any{
"url": entryURL,
"title": entryTitle,
"description": description,
"orgSlug": c.orgSlug,
"visibility": c.visibility,
"unread": true,
"starred": false,
"archive": false,
"tags": tagsStr,
},
}
requestBody, err := json.Marshal(map[string]any{
"query": mutation,
"variables": variables,
})
if err != nil {
return fmt.Errorf("linktaco: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, c.graphqlURL, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linktaco: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiToken)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linktaco: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linktaco: unable to create bookmark: status=%d", response.StatusCode)
}
var graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []json.RawMessage `json:"errors"`
}
if err := json.NewDecoder(response.Body).Decode(&graphqlResponse); err != nil {
return fmt.Errorf("linktaco: unable to decode response: %v", err)
}
if len(graphqlResponse.Errors) > 0 {
// Try to extract error message
var errorMsg string
for _, errJSON := range graphqlResponse.Errors {
var errObj struct {
Message string `json:"message"`
}
if json.Unmarshal(errJSON, &errObj) == nil && errObj.Message != "" {
errorMsg = errObj.Message
break
}
}
if errorMsg == "" {
// Fallback. Should never be reached.
errorMsg = "GraphQL error occurred (fallback message)"
}
return fmt.Errorf("linktaco: %s", errorMsg)
}
return nil
}
@@ -0,0 +1,440 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linktaco
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateBookmark(t *testing.T) {
tests := []struct {
name string
apiToken string
orgSlug string
tags string
visibility string
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful bookmark creation",
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1, tag2",
visibility: "PUBLIC",
entryURL: "https://example.com",
entryTitle: "Test Article",
entryContent: "Test content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
// Verify mutation exists
if _, ok := req["query"]; !ok {
t.Error("Missing 'query' field in request")
}
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
"url": "https://example.com",
"title": "Test Article",
},
},
})
},
wantErr: false,
},
{
name: "missing API token",
apiToken: "",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Should not be called
t.Error("Server should not be called when API token is missing")
},
wantErr: true,
errContains: "missing API token or organization slug",
},
{
name: "missing organization slug",
apiToken: "test-token",
orgSlug: "",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Should not be called
t.Error("Server should not be called when org slug is missing")
},
wantErr: true,
errContains: "missing API token or organization slug",
},
{
name: "GraphQL error response",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
"message": "Invalid input",
},
},
})
},
wantErr: true,
errContains: "Invalid input",
},
{
name: "HTTP error status",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
},
wantErr: true,
errContains: "status=401",
},
{
name: "private visibility permission error",
apiToken: "test-token",
orgSlug: "test-org",
visibility: "PRIVATE",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": []interface{}{
map[string]interface{}{
"message": "PRIVATE visibility requires a paid LinkTaco account",
},
},
})
},
wantErr: true,
errContains: "PRIVATE visibility requires a paid LinkTaco account",
},
{
name: "content truncation",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
// Check that description was truncated
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
description := input["description"].(string)
if len(description) != maxDescriptionLength {
t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
},
})
},
wantErr: false,
},
{
name: "tag limiting",
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
// Check that only 10 tags were sent
variables := req["variables"].(map[string]interface{})
input := variables["input"].(map[string]interface{})
tags := input["tags"].(string)
tagCount := len(strings.Split(tags, ","))
if tagCount != maxTags {
t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{"id": "123"},
},
})
},
wantErr: false,
},
{
name: "invalid JSON response",
apiToken: "test-token",
orgSlug: "test-org",
entryURL: "https://example.com",
entryTitle: "Test",
entryContent: "Content",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
},
wantErr: true,
errContains: "unable to decode response",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server if we have a server response function
var serverURL string
if tt.serverResponse != nil {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
serverURL = server.URL
}
// Create client with test server URL
client := &Client{
graphqlURL: serverURL,
apiToken: tt.apiToken,
orgSlug: tt.orgSlug,
tags: tt.tags,
visibility: tt.visibility,
}
// Call CreateBookmark
err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
// Check error expectations
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
apiToken string
orgSlug string
tags string
visibility string
expectedVisibility string
}{
{
name: "with all parameters",
apiToken: "token",
orgSlug: "org",
tags: "tag1,tag2",
visibility: "PRIVATE",
expectedVisibility: "PRIVATE",
},
{
name: "empty visibility defaults to PUBLIC",
apiToken: "token",
orgSlug: "org",
tags: "tag1",
visibility: "",
expectedVisibility: "PUBLIC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
if client.apiToken != tt.apiToken {
t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
}
if client.orgSlug != tt.orgSlug {
t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
}
if client.tags != tt.tags {
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
}
if client.visibility != tt.expectedVisibility {
t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
}
if client.graphqlURL != defaultGraphQLURL {
t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
}
})
}
}
func TestGraphQLMutation(t *testing.T) {
// Test that the GraphQL mutation is properly formatted
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("Failed to parse request: %v", err)
}
// Verify mutation structure
query, ok := req["query"].(string)
if !ok {
t.Fatal("Missing query field")
}
// Check that mutation contains expected parts
if !strings.Contains(query, "mutation AddLink") {
t.Error("Mutation should contain 'mutation AddLink'")
}
if !strings.Contains(query, "$input: LinkInput!") {
t.Error("Mutation should contain input parameter")
}
if !strings.Contains(query, "addLink(input: $input)") {
t.Error("Mutation should contain addLink call")
}
// Verify variables structure
variables, ok := req["variables"].(map[string]interface{})
if !ok {
t.Fatal("Missing variables field")
}
input, ok := variables["input"].(map[string]interface{})
if !ok {
t.Fatal("Missing input in variables")
}
// Check all required fields
requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
for _, field := range requiredFields {
if _, ok := input[field]; !ok {
t.Errorf("Missing required field: %s", field)
}
}
// Return success
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
},
},
})
}))
defer server.Close()
client := &Client{
graphqlURL: server.URL,
apiToken: "test-token",
orgSlug: "test-org",
tags: "test",
visibility: "PUBLIC",
}
err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
func BenchmarkCreateBookmark(b *testing.B) {
// Create a mock server that always returns success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"addLink": map[string]interface{}{
"id": "123",
},
},
})
}))
defer server.Close()
client := &Client{
graphqlURL: server.URL,
apiToken: "test-token",
orgSlug: "test-org",
tags: "tag1,tag2,tag3",
visibility: "PUBLIC",
}
// Run benchmark
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
}
}
func BenchmarkTagProcessing(b *testing.B) {
// Benchmark tag splitting and limiting
tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
b.ResetTimer()
for i := 0; i < b.N; i++ {
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
splitTags := strings.FieldsFunc(tags, tagsSplitFn)
if len(splitTags) > maxTags {
splitTags = splitTags[:maxTags]
}
_ = strings.Join(splitTags, ",")
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ func (c *Client) makeRequest(payload any) error {
// See https://docs.ntfy.sh/publish/#access-tokens
if c.ntfyApiToken != "" {
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ntfyApiToken))
request.Header.Set("Authorization", "Bearer "+c.ntfyApiToken)
}
// See https://docs.ntfy.sh/publish/#username-password
+8 -5
View File
@@ -24,11 +24,12 @@ type Client struct {
clientSecret string
username string
password string
tags string
onlyURL bool
}
func NewClient(baseURL, clientID, clientSecret, username, password string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, onlyURL}
func NewClient(baseURL, clientID, clientSecret, username, password, tags string, onlyURL bool) *Client {
return &Client{baseURL, clientID, clientSecret, username, password, tags, onlyURL}
}
func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
@@ -41,10 +42,10 @@ func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
return err
}
return c.createEntry(accessToken, entryURL, entryTitle, entryContent)
return c.createEntry(accessToken, entryURL, entryTitle, entryContent, c.tags)
}
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent string) error {
func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, tags string) error {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
if err != nil {
return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
@@ -58,6 +59,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent str
URL: entryURL,
Title: entryTitle,
Content: entryContent,
Tags: tags,
})
if err != nil {
return fmt.Errorf("wallbag: unable to encode request body: %v", err)
@@ -81,7 +83,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent str
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
return fmt.Errorf("wallabag: unable to get save entry: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
@@ -140,4 +142,5 @@ type createEntryRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
Tags string `json:"tags,omitempty"`
}
@@ -0,0 +1,321 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package wallabag
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateEntry(t *testing.T) {
entryURL := "https://example.com"
entryTitle := "title"
entryContent := "content"
tags := "tag1,tag2,tag3"
tests := []struct {
name string
username string
password string
clientID string
clientSecret string
tags string
onlyURL bool
entryURL string
entryTitle string
entryContent string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
errContains string
}{
{
name: "successful entry creation with url only",
wantErr: false,
onlyURL: true,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"expires_in": 3600,
"refresh_token": "token",
"scope": "scope",
"token_type": "token_type",
})
return
}
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
}
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
}
if _, ok := req["content"]; ok {
t.Errorf("Expected entryContent to be empty, got value")
}
if requestTags := req["tags"]; requestTags != tags {
t.Errorf("Expected tags %s, got %s", tags, requestTags)
} // Return success response
w.WriteHeader(http.StatusOK)
},
errContains: "",
},
{
name: "successful entry creation with content",
wantErr: false,
onlyURL: false,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"expires_in": 3600,
"refresh_token": "token",
"scope": "scope",
"token_type": "token_type",
})
return
}
// Verify authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token" {
t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
}
// Verify content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
}
// Parse and verify request
body, _ := io.ReadAll(r.Body)
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
if requstEntryURL := req["url"]; requstEntryURL != entryURL {
t.Errorf("Expected entryURL %s, got %s", entryURL, requstEntryURL)
}
if requestEntryTitle := req["title"]; requestEntryTitle != entryTitle {
t.Errorf("Expected entryTitle %s, got %s", entryTitle, requestEntryTitle)
}
if requestEntryContent := req["content"]; requestEntryContent != entryContent {
t.Errorf("Expected entryContent %s, got %s", entryContent, requestEntryContent)
}
if requestTags := req["tags"]; requestTags != tags {
t.Errorf("Expected tags %s, got %s", tags, requestTags)
} // Return success response
w.WriteHeader(http.StatusOK)
},
errContains: "",
},
{
name: "failed when unable to decode accessToken response",
wantErr: true,
onlyURL: true,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
return
}
t.Error("Server should not be called when failed to get accessToken")
},
errContains: "unable to decode token response",
},
{
name: "failed when saving entry",
wantErr: true,
onlyURL: true,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return success response
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-token",
"expires_in": 3600,
"refresh_token": "token",
"scope": "scope",
"token_type": "token_type",
})
return
}
w.WriteHeader(http.StatusUnauthorized)
},
errContains: "unable to get save entry",
},
{
name: "failure due to no accessToken",
wantErr: true,
onlyURL: false,
username: "username",
password: "password",
clientID: "clientId",
clientSecret: "clientSecret",
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth/v2/token") {
// Return error response
w.WriteHeader(http.StatusUnauthorized)
return
}
t.Error("Server should not be called when failed to get accessToken")
},
errContains: "unable to get access token",
},
{
name: "failure due to missing client parameters",
wantErr: true,
onlyURL: false,
tags: tags,
entryURL: entryURL,
entryTitle: entryTitle,
entryContent: entryContent,
serverResponse: func(w http.ResponseWriter, r *http.Request) {
t.Error("Server should not be called when failed to get accessToken")
},
errContains: "wallabag: missing base URL, client ID, client secret, username or password",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server if we have a server response function
var serverURL string
if tt.serverResponse != nil {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
serverURL = server.URL
}
// Create client with test server URL
client := NewClient(serverURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
// Call CreateBookmark
err := client.CreateEntry(tt.entryURL, tt.entryTitle, tt.entryContent)
// Check error expectations
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestNewClient(t *testing.T) {
tests := []struct {
name string
baseURL string
clientID string
clientSecret string
username string
password string
tags string
onlyURL bool
}{
{
name: "with all parameters",
baseURL: "https://wallabag.example.com",
clientID: "clientID",
clientSecret: "clientSecret",
username: "wallabag",
password: "wallabag",
tags: "",
onlyURL: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.baseURL, tt.clientID, tt.clientSecret, tt.username, tt.password, tt.tags, tt.onlyURL)
if client.baseURL != tt.baseURL {
t.Errorf("Expected.baseURL %s, got %s", tt.baseURL, client.baseURL)
}
if client.username != tt.username {
t.Errorf("Expected username %s, got %s", tt.username, client.username)
}
if client.password != tt.password {
t.Errorf("Expected password %s, got %s", tt.password, client.password)
}
if client.clientID != tt.clientID {
t.Errorf("Expected clientID %s, got %s", tt.clientID, client.clientID)
}
if client.clientSecret != tt.clientSecret {
t.Errorf("Expected clientSecret %s, got %s", tt.clientSecret, client.clientSecret)
}
if client.tags != tt.tags {
t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
}
if client.onlyURL != tt.onlyURL {
t.Errorf("Expected onlyURL %v, got %v", tt.onlyURL, client.onlyURL)
}
})
}
}
+55 -42
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
"alert.background_feed_refresh": "Alle Abonnements werden derzeit im Hintergrund aktualisiert. Sie können Miniflux weiterhin benutzen, während dieser Prozess ausgeführt wird.",
"alert.feed_error": "Es gibt ein Problem mit diesem Abonnement",
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_starred": "Es existieren derzeit keine markierten Artikel.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Wiedergabegeschwindigkeit auf 1x zurücksetzen",
"enclosure_media_controls.speed.slower": "Langsamer",
"enclosure_media_controls.speed.slower.title": "%sx langsamer",
"entry.bookmark.toast.off": "Nicht markiert",
"entry.bookmark.toast.on": "Markiert",
"entry.bookmark.toggle.off": "Lesezeichen entfernen",
"entry.bookmark.toggle.on": "Lesezeichen hinzufügen",
"entry.starred.toast.off": "Nicht markiert",
"entry.starred.toast.on": "Markiert",
"entry.starred.toggle.off": "Markierung entfernen",
"entry.starred.toggle.on": "Markierung hinzufügen",
"entry.comments.label": "Kommentare",
"entry.comments.title": "Kommentare anzeigen",
"entry.estimated_reading_time": [
@@ -92,7 +92,7 @@
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
"error.duplicated_feed": "Dieses Abonnement existiert bereits.",
"error.empty_file": "Diese Datei ist leer.",
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
"error.entries_per_page_invalid": "Die Anzahl der Artikel pro Seite ist ungültig.",
"error.feed_already_exists": "Dieser Feed existiert bereits.",
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
@@ -160,8 +160,9 @@
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
"error.user_already_exists": "Dieser Benutzer existiert bereits.",
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
"error.linktaco_missing_required_fields": "LinkTaco API Token und Organization Slug sind erforderlich.",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.category.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.category.label.title": "Titel",
"form.feed.fieldset.general": "Allgemein",
"form.feed.fieldset.integration": "Drittanbieter-Dienste",
@@ -181,12 +182,12 @@
"form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.feed_username": "Benutzername des Abonnements",
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.hide_globally": "Artikel in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
"form.feed.label.ntfy_activate": "Artikel zu ntfy pushen",
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
@@ -195,7 +196,7 @@
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
"form.feed.label.ntfy_topic": "Ntfy-Thema (optional)",
"form.feed.label.proxy_url": "Proxy-URL",
"form.feed.label.pushover_activate": "Einträge an pushover.net senden",
"form.feed.label.pushover_activate": "Artikel an pushover.net senden",
"form.feed.label.pushover_default_priority": "Pushover-Standardpriorität",
"form.feed.label.pushover_high_priority": "Hohe Pushoverpriorität",
"form.feed.label.pushover_low_priority": "Niedrige Pushoverpriorität",
@@ -211,17 +212,17 @@
"form.feed.label.webhook_url": "Webhook-URL überschreiben",
"form.import.label.file": "OPML-Datei",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
"form.integration.apprise_activate": "Artikel zu Apprise pushen",
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
"form.integration.apprise_url": "Apprise-API-URL",
"form.integration.betula_activate": "Einträge in Betula speichern",
"form.integration.betula_activate": "Artikel in Betula speichern",
"form.integration.betula_token": "Betula-Token",
"form.integration.betula_url": "Betula-Server-URL",
"form.integration.cubox_activate": "Einträge in Cubox speichern",
"form.integration.cubox_activate": "Artikel in Cubox speichern",
"form.integration.cubox_api_link": "Cubox-API-Link",
"form.integration.discord_activate": "Einträge zu Discord pushen",
"form.integration.discord_activate": "Artikel zu Discord pushen",
"form.integration.discord_webhook_link": "Discord-Webhook-URL",
"form.integration.espial_activate": "Einträge in Espial speichern",
"form.integration.espial_activate": "Artikel in Espial speichern",
"form.integration.espial_api_key": "Espial-API-Schlüssel",
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
"form.integration.espial_tags": "Espial-Tags",
@@ -233,13 +234,13 @@
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
"form.integration.googlereader_password": "Google-Reader-Passwort",
"form.integration.googlereader_username": "Google-Reader-Benutzername",
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
"form.integration.instapaper_activate": "Artikel in Instapaper speichern",
"form.integration.instapaper_password": "Instapaper-Passwort",
"form.integration.instapaper_username": "Instapaper-Benutzername",
"form.integration.karakeep_activate": "Einträge in Karakeep speichern",
"form.integration.karakeep_activate": "Artikel in Karakeep speichern",
"form.integration.karakeep_api_key": "Karakeep-API-Schlüssel",
"form.integration.karakeep_url": "Karakeep-API-Endpunkt",
"form.integration.linkace_activate": "Einträge in LinkAce speichern",
"form.integration.linkace_activate": "Artikel in LinkAce speichern",
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
"form.integration.linkding_tags": "Linkding-Tags",
"form.integration.linktaco_activate": "Artikel in LinkTaco speichern",
"form.integration.linktaco_api_token": "LinkTaco-API-Token",
"form.integration.linktaco_api_token_hint": "Holen Sie sich Ihr persönliches Zugriffstoken unter",
"form.integration.linktaco_org_slug": "Organisationstitel",
"form.integration.linktaco_tags": "Tags (max. 10, kommagetrennt)",
"form.integration.linktaco_tags_hint": "Maximal 10 Tags, kommagetrennt",
"form.integration.linktaco_visibility": "Sichtbarkeit",
"form.integration.linktaco_visibility_public": "Öffentlich",
"form.integration.linktaco_visibility_private": "Privat",
"form.integration.linktaco_visibility_hint": "PRIVATE Sichtbarkeit erfordert ein kostenpflichtiges LinkTaco-Konto",
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
@@ -258,10 +269,10 @@
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_user": "Benutzername für Matrix",
"form.integration.notion_activate": "Einträge in Notion speichern",
"form.integration.notion_activate": "Artikel in Notion speichern",
"form.integration.notion_page_id": "Notion-Page-ID",
"form.integration.notion_token": "Notion-Geheimnis-Token",
"form.integration.ntfy_activate": "Einträge zu ntfy pushen",
"form.integration.ntfy_activate": "Artikel zu ntfy pushen",
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
"form.integration.ntfy_internal_links": "Interne Links beim Klicken verwenden (optional)",
@@ -272,19 +283,19 @@
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
"form.integration.omnivore_activate": "Artikel in Omnivore speichern",
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
"form.integration.pinboard_activate": "Artikel in Pinboard speichern",
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.pinboard_tags": "Pinboard-Tags",
"form.integration.pinboard_token": "Pinboard-API-Token",
"form.integration.pushover_activate": "Einträge an Pushover senden",
"form.integration.pushover_activate": "Artikel an Pushover senden",
"form.integration.pushover_device": "Pushovergerät (optional)",
"form.integration.pushover_prefix": "Pushover-URL-Präfix (optional)",
"form.integration.pushover_token": "Pushover-Anwendungs-API-Token",
"form.integration.pushover_user": "Pushover-Benutzerschlüssel",
"form.integration.raindrop_activate": "Einträge in Raindrop speichern",
"form.integration.raindrop_activate": "Artikel in Raindrop speichern",
"form.integration.raindrop_collection_id": "Sammlungs-ID",
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
"form.integration.raindrop_token": "(Test-)Token",
@@ -293,7 +304,7 @@
"form.integration.readeck_endpoint": "Readeck-URL",
"form.integration.readeck_labels": "Readeck-Labels",
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
"form.integration.readwise_activate": "Artikel in Readwise Reader speichern",
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
@@ -306,7 +317,7 @@
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
"form.integration.shiori_password": "Shiori-Passwort",
"form.integration.shiori_username": "Shiori-Benutzername",
"form.integration.slack_activate": "Einträge zu Slack pushen",
"form.integration.slack_activate": "Artikel zu Slack pushen",
"form.integration.slack_webhook_link": "Slack-Webhook-URL",
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
@@ -315,13 +326,14 @@
"form.integration.telegram_bot_token": "Bot-Token",
"form.integration.telegram_chat_id": "Chat-ID",
"form.integration.telegram_topic_id": "Thema-ID",
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
"form.integration.wallabag_activate": "Artikel in Wallabag speichern",
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
"form.integration.wallabag_endpoint": "Wallabag-Basis-URL",
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.wallabag_password": "Wallabag-Passwort",
"form.integration.wallabag_username": "Wallabag-Benutzername",
"form.integration.wallabag_tags": "Wallabag-Tags",
"form.integration.webhook_activate": "Webhooks aktivieren",
"form.integration.webhook_secret": "Webhook-Geheimnis",
"form.integration.webhook_url": "Standard-Webhook-URL",
@@ -338,18 +350,18 @@
"form.prefs.label.default_home_page": "Standard-Startseite",
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
"form.prefs.label.display_mode": "Anzeigemodus der progressiven Web-Anwendung (PWA)",
"form.prefs.label.entries_per_page": "Einträge pro Seite",
"form.prefs.label.entries_per_page": "Artikel pro Seite",
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
"form.prefs.label.entry_sorting": "Sortierung der Einträge",
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Einträgen auf Touchscreens",
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Artikeln auf Touchscreens",
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Artikeln",
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
"form.prefs.label.language": "Sprache",
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
"form.prefs.label.mark_read_manually": "Artikel manuell 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_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
"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_view": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.label.mark_read_on_view_or_media_completion": "Artikel automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"form.prefs.label.open_external_links_in_new_tab": "Externe Links in einem neuen Tab öffnen (fügt target=\"_blank\" zu Links hinzu)",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
@@ -361,9 +373,9 @@
"form.prefs.select.fullscreen": "Vollbildschirm",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.none": "Keine",
"form.prefs.select.older_first": "Ältere Einträge zuerst",
"form.prefs.select.older_first": "Ältere Artikel zuerst",
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
"form.prefs.select.recent_first": "Neue Einträge zuerst",
"form.prefs.select.recent_first": "Neue Artikel zuerst",
"form.prefs.select.standalone": "Eigenständige",
"form.prefs.select.swipe": "Wischen",
"form.prefs.select.tap": "Doppeltippen",
@@ -404,7 +416,7 @@
"menu.show_all_entries": "Zeige alle Artikel",
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
"menu.starred": "Lesezeichen",
"menu.starred": "Markiert",
"menu.title": "Menü",
"menu.unread": "Ungelesen",
"menu.users": "Benutzer",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Nächste Aktualisierung:",
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
"page.feeds.title": "Abonnements",
"page.footer.elevator": "Zurück nach oben",
"page.history.title": "Verlauf",
"page.import.title": "Importieren",
"page.integration.bookmarklet": "Bookmarklet",
@@ -487,7 +500,7 @@
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
"page.keyboard_shortcuts.go_to_search": "Fokus auf das Suchformular setzen",
"page.keyboard_shortcuts.go_to_settings": "Zu den Einstellungen gehen",
"page.keyboard_shortcuts.go_to_starred": "Zu den Lesezeichen gehen",
"page.keyboard_shortcuts.go_to_starred": "Zu den markierten Artikeln gehen",
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
"page.keyboard_shortcuts.go_to_unread": "Zu den ungelesenen Artikeln gehen",
"page.keyboard_shortcuts.mark_page_as_read": "Aktuelle Seite als gelesen markieren",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navigation zwischen den Seiten",
"page.keyboard_shortcuts.subtitle.sections": "Navigation zwischen den Menüpunkten",
"page.keyboard_shortcuts.title": "Tastenkürzel",
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen hinzufügen/entfernen",
"page.keyboard_shortcuts.toggle_star_status": "Markierung hinzufügen/entfernen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikelanhänge öffnen/schließen",
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen",
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen",
@@ -554,10 +567,10 @@
"%d geteilter Artikel",
"%d geteilte Artikel"
],
"page.starred.title": "Lesezeichen",
"page.starred.title": "Markiert",
"page.starred_entry_count": [
"%d Lesezeichen",
"%d Lesezeichen"
"%d markierter Artikel",
"%d markierte Artikel"
],
"page.total_entry_count": [
"%d Artikel insgesamt",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
"alert.background_feed_refresh": "Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.",
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_starred": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_feed": "Δεν έχετε συνδρομές.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Επαναφορά ταχύτητας σε 1x",
"enclosure_media_controls.speed.slower": "Πιο αργά",
"enclosure_media_controls.speed.slower.title": "Πιο αργά κατά %sx",
"entry.bookmark.toast.off": "Μη αγαπημένα",
"entry.bookmark.toast.on": "Αγαπημένα",
"entry.bookmark.toggle.off": "Αναίρεση αγαπημένου",
"entry.bookmark.toggle.on": "Αγαπημένο",
"entry.starred.toast.off": "Μη αγαπημένα",
"entry.starred.toast.on": "Αγαπημένα",
"entry.starred.toggle.off": "Αναίρεση αγαπημένου",
"entry.starred.toggle.on": "Αγαπημένο",
"entry.comments.label": "Σχόλια",
"entry.comments.title": "Δείτε Σχόλια",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
"error.user_already_exists": "Αυτός ο χρήστης υπάρχει ήδη.",
"error.user_mandatory_fields": "Το όνομα χρήστη είναι υποχρεωτικό.",
"error.linktaco_missing_required_fields": "Το LinkTaco API Token και το Organization Slug είναι απαραίτητα",
"form.api_key.label.description": "Ετικέτα κλειδιού API",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.category.label.title": "Τίτλος",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
"form.integration.linkding_tags": "Ετικέτες Linkding",
"form.integration.linktaco_activate": "Αποθήκευση καταχωρήσεων στο LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Λάβετε το προσωπικό σας διακριτικό πρόσβασης στο",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Ετικέτες (μέγιστο 10, διαχωρισμένες με κόμμα)",
"form.integration.linktaco_tags_hint": "Μέγιστο 10 ετικέτες, διαχωρισμένες με κόμμα",
"form.integration.linktaco_visibility": "Ορατότητα",
"form.integration.linktaco_visibility_public": "Δημόσια",
"form.integration.linktaco_visibility_private": "Ιδιωτική",
"form.integration.linktaco_visibility_hint": "Η ΙΔΙΩΤΙΚΗ ορατότητα απαιτεί επί πληρωμή λογαριασμό LinkTaco",
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
"form.integration.webhook_secret": "Μυστικό Webhooks",
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Επόμενος έλεγχος:",
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
"page.feeds.title": "Ροές",
"page.footer.elevator": "Back to top",
"page.history.title": "Ιστορικό",
"page.import.title": "Εισαγωγή",
"page.integration.bookmarklet": "Bookmarklet",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Πλοήγηση Σελίδων",
"page.keyboard_shortcuts.subtitle.sections": "Πλοήγηση Τμημάτων",
"page.keyboard_shortcuts.title": "Συντομεύσεις Πληκτρολογίου",
"page.keyboard_shortcuts.toggle_bookmark_status": "Εναλλαγή σελιδοδείκτη",
"page.keyboard_shortcuts.toggle_star_status": "Εναλλαγή σελιδοδείκτη",
"page.keyboard_shortcuts.toggle_entry_attachments": "Εναλλαγή άνοιγμα/κλείσιμο συνημμένων καταχώρησης",
"page.keyboard_shortcuts.toggle_read_status_next": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στη συνέχεια",
"page.keyboard_shortcuts.toggle_read_status_prev": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Your external account is now dissociated!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.feed_error": "There is a problem with this feed",
"alert.no_bookmark": "There are no starred entries.",
"alert.no_starred": "There are no starred entries.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this category.",
"alert.no_feed": "You dont have any feeds.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"entry.bookmark.toast.off": "Unstarred",
"entry.bookmark.toast.on": "Starred",
"entry.bookmark.toggle.off": "Unstar",
"entry.bookmark.toggle.on": "Star",
"entry.starred.toast.off": "Unstarred",
"entry.starred.toast.on": "Starred",
"entry.starred.toggle.off": "Unstar",
"entry.starred.toggle.on": "Star",
"entry.comments.label": "Comments",
"entry.comments.title": "View Comments",
"entry.estimated_reading_time": [
@@ -89,6 +89,7 @@
"error.different_passwords": "Passwords are not the same.",
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
"error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!",
"error.linktaco_missing_required_fields": "LinkTaco API Token and Organization Slug are required",
"error.duplicate_linked_account": "There is already someone associated with this provider!",
"error.duplicated_feed": "This feed already exists.",
"error.empty_file": "This file is empty.",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Mark bookmark as unread",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "Save entries to LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Get your personal access token at",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tags (max 10, comma-separated)",
"form.integration.linktaco_tags_hint": "Maximum 10 tags, comma-separated",
"form.integration.linktaco_visibility": "Visibility",
"form.integration.linktaco_visibility_public": "Public",
"form.integration.linktaco_visibility_private": "Private",
"form.integration.linktaco_visibility_hint": "PRIVATE visibility requires a paid LinkTaco account",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
"form.integration.wallabag_password": "Wallabag Password",
"form.integration.wallabag_username": "Wallabag Username",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Number of read entries",
"page.feeds.title": "Feeds",
"page.footer.elevator": "Back to top",
"page.history.title": "History",
"page.import.title": "Import",
"page.integration.bookmarklet": "Bookmarklet",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Pages Navigation",
"page.keyboard_shortcuts.subtitle.sections": "Sections Navigation",
"page.keyboard_shortcuts.title": "Keyboard Shortcuts",
"page.keyboard_shortcuts.toggle_bookmark_status": "Toggle starred",
"page.keyboard_shortcuts.toggle_star_status": "Toggle starred",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_read_status_next": "Toggle read/unread, focus next",
"page.keyboard_shortcuts.toggle_read_status_prev": "Toggle read/unread, focus previous",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "¡Tu cuenta externa ya está desvinculada!",
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
"alert.feed_error": "Hay un problema con esta fuente.",
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_starred": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoría.",
"alert.no_feed": "No tienes fuentes.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Restablecer la velocidad a 1x",
"enclosure_media_controls.speed.slower": "Despacio",
"enclosure_media_controls.speed.slower.title": "Más despacio a %sx",
"entry.bookmark.toast.off": "Sin estrellas",
"entry.bookmark.toast.on": "Sembrado de estrellas",
"entry.bookmark.toggle.off": "Desmarcar",
"entry.bookmark.toggle.on": "Marcar",
"entry.starred.toast.off": "Sin estrellas",
"entry.starred.toast.on": "Sembrado de estrellas",
"entry.starred.toggle.off": "Desmarcar",
"entry.starred.toggle.on": "Marcar",
"entry.comments.label": "Comentarios",
"entry.comments.title": "Ver comentarios",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
"error.user_already_exists": "Este usuario ya existe.",
"error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
"error.linktaco_missing_required_fields": "LinkTaco API Token y Organization Slug son obligatorios.",
"form.api_key.label.description": "Etiqueta de clave API",
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.category.label.title": "Título",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
"form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_tags": "Etiquetas de Linkding",
"form.integration.linktaco_activate": "Guardar entradas en LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Obtenga su token de acceso personal en",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Etiquetas (máx. 10, separadas por comas)",
"form.integration.linktaco_tags_hint": "Máximo 10 etiquetas, separadas por comas",
"form.integration.linktaco_visibility": "Visibilidad",
"form.integration.linktaco_visibility_public": "Público",
"form.integration.linktaco_visibility_private": "Privado",
"form.integration.linktaco_visibility_hint": "La visibilidad PRIVADA requiere una cuenta de pago de LinkTaco",
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.wallabag_password": "Contraseña de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Habilitar Webhooks",
"form.integration.webhook_secret": "Secreto de Webhooks",
"form.integration.webhook_url": "Defecto URL de Webhook",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Próxima verificación:",
"page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.title": "Fuentes",
"page.footer.elevator": "Back to top",
"page.history.title": "Historial",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Marcapáginas",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navegación de páginas",
"page.keyboard_shortcuts.subtitle.sections": "Navegación de secciones",
"page.keyboard_shortcuts.title": "Atajos de teclado",
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
"page.keyboard_shortcuts.toggle_star_status": "Agregar o quitar marcador",
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/cerrar adjuntos de la entrada",
"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",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
"alert.background_feed_refresh": "Kaikki syötteet päivitetään taustalla. Voit jatkaa Minifluxin käyttöä tämän prosessin aikana.",
"alert.feed_error": "Tässä syötteessä on ongelma",
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_starred": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Palauta nopeus 1x",
"enclosure_media_controls.speed.slower": "Hitaammin",
"enclosure_media_controls.speed.slower.title": "Hitaampi %sx",
"entry.bookmark.toast.off": "Tähdettömät",
"entry.bookmark.toast.on": "Tähdellä merkityt",
"entry.bookmark.toggle.off": "Poista suosikeista",
"entry.bookmark.toggle.on": "Lisää suosikkeihin",
"entry.starred.toast.off": "Tähdettömät",
"entry.starred.toast.on": "Tähdellä merkityt",
"entry.starred.toggle.off": "Poista suosikeista",
"entry.starred.toggle.on": "Lisää suosikkeihin",
"entry.comments.label": "Kommentit",
"entry.comments.title": "Näytä kommentit",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Sinun on määritettävä salasana, muuten et voi kirjautua uudelleen.",
"error.user_already_exists": "Käyttäjä on jo olemassa.",
"error.user_mandatory_fields": "Käyttäjätunnus on pakollinen.",
"error.linktaco_missing_required_fields": "LinkTaco API Token ja Organization Slug vaaditaan",
"form.api_key.label.description": "API Key Label",
"form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.category.label.title": "Otsikko",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "Tallenna kirjoituksia LinkTacoon",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Hanki henkilökohtainen pääsytunnistesi osoitteesta",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tagit (enintään 10, pilkuilla erotettu)",
"form.integration.linktaco_tags_hint": "Enintään 10 tagia, pilkuilla erotettu",
"form.integration.linktaco_visibility": "Näkyvyys",
"form.integration.linktaco_visibility_public": "Julkinen",
"form.integration.linktaco_visibility_private": "Yksityinen",
"form.integration.linktaco_visibility_hint": "YKSITYINEN näkyvyys vaatii maksullisen LinkTaco-tilin",
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.wallabag_password": "Wallabag-salasana",
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
"page.feeds.title": "Syötteet",
"page.footer.elevator": "Back to top",
"page.history.title": "Historia",
"page.import.title": "Tuo",
"page.integration.bookmarklet": "Sovelluskirjanmerkki",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Sivujen navigointi",
"page.keyboard_shortcuts.subtitle.sections": "Osion navigointi",
"page.keyboard_shortcuts.title": "Pikanäppäimet",
"page.keyboard_shortcuts.toggle_bookmark_status": "Vaihda kirjanmerkki",
"page.keyboard_shortcuts.toggle_star_status": "Vaihda kirjanmerkki",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_read_status_next": "Vaihda luettu/lukematon, keskity seuraavaksi",
"page.keyboard_shortcuts.toggle_read_status_prev": "Vaihda luettu/lukematon, keskity edelliseen",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Votre compte externe est maintenant dissocié !",
"alert.background_feed_refresh": "Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.",
"alert.feed_error": "Il y a un problème avec cet abonnement",
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_starred": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
"alert.no_feed": "Vous n'avez aucun abonnement.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Réinitialiser la vitesse de lecture à 1x",
"enclosure_media_controls.speed.slower": "Ralentir",
"enclosure_media_controls.speed.slower.title": "Ralentir de %sx",
"entry.bookmark.toast.off": "Enlevé des favoris",
"entry.bookmark.toast.on": "Ajouté aux favoris",
"entry.bookmark.toggle.off": "Enlever favoris",
"entry.bookmark.toggle.on": "Favoris",
"entry.starred.toast.off": "Enlevé des favoris",
"entry.starred.toast.on": "Ajouté aux favoris",
"entry.starred.toggle.off": "Enlever favoris",
"entry.starred.toggle.on": "Favoris",
"entry.comments.label": "Commentaires",
"entry.comments.title": "Voir les commentaires",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
"error.user_already_exists": "Cet utilisateur existe déjà.",
"error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
"error.linktaco_missing_required_fields": "Le token API LinkTaco et le slug de l'organisation sont requis.",
"form.api_key.label.description": "Libellé de la clé d'API",
"form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
"form.category.label.title": "Titre",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
"form.integration.linkding_tags": "Libellés",
"form.integration.linktaco_activate": "Sauvegarder les entrées vers LinkTaco",
"form.integration.linktaco_api_token": "Token API LinkTaco",
"form.integration.linktaco_api_token_hint": "Obtenez votre token d'accès personnel sur",
"form.integration.linktaco_org_slug": "Slug de l'organisation",
"form.integration.linktaco_tags": "Tags (max. 10, séparés par des virgules)",
"form.integration.linktaco_tags_hint": "Maximum 10 tags, séparés par des virgules",
"form.integration.linktaco_visibility": "Visibilité",
"form.integration.linktaco_visibility_public": "Public",
"form.integration.linktaco_visibility_private": "Privé",
"form.integration.linktaco_visibility_hint": "La visibilité PRIVÉE nécessite un compte LinkTaco payant",
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.wallabag_password": "Mot de passe de Wallabag",
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
"form.integration.wallabag_tags": "Libellés Wallabag",
"form.integration.webhook_activate": "Activer le webhook",
"form.integration.webhook_secret": "Secret du webhook",
"form.integration.webhook_url": "URL du webhook",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Prochaine vérification :",
"page.feeds.read_counter": "Nombre d'entrées lues",
"page.feeds.title": "Abonnements",
"page.footer.elevator": "Retour en haut",
"page.history.title": "Historique",
"page.import.title": "Importation",
"page.integration.bookmarklet": "Bookmarklet",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navigation entre les pages",
"page.keyboard_shortcuts.subtitle.sections": "Navigation entre les sections",
"page.keyboard_shortcuts.title": "Raccourcis clavier",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ajouter/Enlever favoris",
"page.keyboard_shortcuts.toggle_star_status": "Ajouter/Enlever favoris",
"page.keyboard_shortcuts.toggle_entry_attachments": "Ouvrir/Fermer les pièces jointes de l'entrée",
"page.keyboard_shortcuts.toggle_read_status_next": "Basculer entre lu/non lu, et changer le focus sur l'élément suivant",
"page.keyboard_shortcuts.toggle_read_status_prev": "Basculer entre lu/non lu, et changer le focus sur l'élément précédent",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
"alert.background_feed_refresh": "सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।",
"alert.feed_error": "इस फ़ीड में एक समस्या है",
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_starred": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "गति 1x पर रीसेट करें",
"enclosure_media_controls.speed.slower": "धीमा",
"enclosure_media_controls.speed.slower.title": "%sx गुना धीमा",
"entry.bookmark.toast.off": "तारांकित न करे",
"entry.bookmark.toast.on": "तारांकित",
"entry.bookmark.toggle.off": "सितारा हटा दो",
"entry.bookmark.toggle.on": "सितारा दे",
"entry.starred.toast.off": "तारांकित न करे",
"entry.starred.toast.on": "तारांकित",
"entry.starred.toggle.off": "सितारा हटा दो",
"entry.starred.toggle.on": "सितारा दे",
"entry.comments.label": "टिप्पणियाँ",
"entry.comments.title": "टिप्पणियाँ देखे",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "आपको एक पासवर्ड परिभाषित करना होगा अन्यथा आप फिर से लॉगिन नहीं कर पाएंगे।",
"error.user_already_exists": "यह उपयोगकर्ता पहले से ही मौजूद है।",
"error.user_mandatory_fields": "उपयोगकर्ता नाम अनिवार्य है।",
"error.linktaco_missing_required_fields": "LinkTaco API Token और Organization Slug आवश्यक हैं",
"form.api_key.label.description": "एपीआई कुंजी लेबल",
"form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.category.label.title": "शीर्षक",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "LinkTaco में प्रविष्टियाँ सहेजें",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "अपना व्यक्तिगत पहुँच टोकन प्राप्त करें",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "टैग (अधिकतम 10, कॉमा से अलग किए गए)",
"form.integration.linktaco_tags_hint": "अधिकतम 10 टैग, कॉमा से अलग किए गए",
"form.integration.linktaco_visibility": "दृश्यता",
"form.integration.linktaco_visibility_public": "सार्वजनिक",
"form.integration.linktaco_visibility_private": "निजी",
"form.integration.linktaco_visibility_hint": "निजी दृश्यता के लिए भुगतान LinkTaco खाता आवश्यक है",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.wallabag_password": "वालाबैग पासवर्ड",
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
"page.feeds.title": "फ़ीड",
"page.footer.elevator": "Back to top",
"page.history.title": "इतिहास",
"page.import.title": "आयात",
"page.integration.bookmarklet": "बुकमार्कलेट",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "पेज नेविगेशन",
"page.keyboard_shortcuts.subtitle.sections": "अनुभाग नेविगेशन",
"page.keyboard_shortcuts.title": "कुंजीपटल अल्प मार्ग",
"page.keyboard_shortcuts.toggle_bookmark_status": "बुकमार्क टॉगल करें",
"page.keyboard_shortcuts.toggle_star_status": "बुकमार्क टॉगल करें",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_read_status_next": "पढ़ें/अपठित टॉगल करें, अगला फ़ोकस करें",
"page.keyboard_shortcuts.toggle_read_status_prev": "पढ़ें/अपठित टॉगल करें, पिछला फ़ोकस करें",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Akun eksternal Anda sudah terputus!",
"alert.background_feed_refresh": "Semua umpan sedang disegarkan di latar belakang. Anda bisa lanjut menggunakan Miniflux sembari proses ini berlanjut.",
"alert.feed_error": "Ada masalah dengan umpan ini",
"alert.no_bookmark": "Tidak ada markah.",
"alert.no_starred": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_feed": "Anda tidak memiliki langganan.",
@@ -45,10 +45,10 @@
"enclosure_media_controls.speed.reset.title": "Atur ulang ke 1x",
"enclosure_media_controls.speed.slower": "Lebih lambat",
"enclosure_media_controls.speed.slower.title": "Lebih lambat %sx",
"entry.bookmark.toast.off": "Batal Markahi",
"entry.bookmark.toast.on": "Markahi",
"entry.bookmark.toggle.off": "Batal Markahi",
"entry.bookmark.toggle.on": "Markahi",
"entry.starred.toast.off": "Batal Markahi",
"entry.starred.toast.on": "Markahi",
"entry.starred.toggle.off": "Batal Markahi",
"entry.starred.toggle.on": "Markahi",
"entry.comments.label": "Komentar",
"entry.comments.title": "Lihat Komentar",
"entry.estimated_reading_time": [
@@ -157,6 +157,7 @@
"error.unlink_account_without_password": "Anda harus mengatur kata sandi atau Anda tidak bisa masuk kembali.",
"error.user_already_exists": "Pengguna ini sudah ada.",
"error.user_mandatory_fields": "Harus ada nama pengguna.",
"error.linktaco_missing_required_fields": "LinkTaco API Token dan Organization Slug diperlukan",
"form.api_key.label.description": "Label Kunci API",
"form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.category.label.title": "Judul",
@@ -247,6 +248,16 @@
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.linkding_endpoint": "Titik URL API Linkding",
"form.integration.linkding_tags": "Tanda Linkding",
"form.integration.linktaco_activate": "Simpan entri ke LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Dapatkan token akses pribadi Anda di",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tag (maksimal 10, dipisahkan koma)",
"form.integration.linktaco_tags_hint": "Maksimal 10 tag, dipisahkan koma",
"form.integration.linktaco_visibility": "Visibilitas",
"form.integration.linktaco_visibility_public": "Publik",
"form.integration.linktaco_visibility_private": "Pribadi",
"form.integration.linktaco_visibility_hint": "Visibilitas PRIBADI membutuhkan akun LinkTaco berbayar",
"form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
"form.integration.linkwarden_api_key": "Kunci API Linkwarden",
"form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",
@@ -319,6 +330,7 @@
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.wallabag_password": "Kata Sandi Wallabag",
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Aktifkan Webhook",
"form.integration.webhook_secret": "Rahasia Webhook",
"form.integration.webhook_url": "URL Webhook baku",
@@ -456,6 +468,7 @@
"page.feeds.next_check": "Akan diperiksa kembali:",
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
"page.feeds.title": "Umpan",
"page.footer.elevator": "Back to top",
"page.history.title": "Riwayat",
"page.import.title": "Impor",
"page.integration.bookmarklet": "Bookmarklet",
@@ -500,7 +513,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navigasi Halaman",
"page.keyboard_shortcuts.subtitle.sections": "Navigasi Bagian",
"page.keyboard_shortcuts.title": "Pintasan Papan Tik",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ubah status markah",
"page.keyboard_shortcuts.toggle_star_status": "Ubah status markah",
"page.keyboard_shortcuts.toggle_entry_attachments": "Buka/tutup lampiran entri",
"page.keyboard_shortcuts.toggle_read_status_next": "Ubah status baca, fokus ke selanjutnya",
"page.keyboard_shortcuts.toggle_read_status_prev": "Ubah status baca, fokus ke sebelumnya",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Il tuo account esterno ora è scollegato!",
"alert.background_feed_refresh": "Tutti i feed vengono aggiornati in background. Puoi continuare a usare Miniflux mentre questo processo è in esecuzione.",
"alert.feed_error": "Sembra ci sia un problema con questo feed",
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_starred": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Reimposta velocità a 1x",
"enclosure_media_controls.speed.slower": "Più lento",
"enclosure_media_controls.speed.slower.title": "Più lento di %sx",
"entry.bookmark.toast.off": "Non preferito",
"entry.bookmark.toast.on": "Preferito",
"entry.bookmark.toggle.off": "Rimuovi dai preferiti",
"entry.bookmark.toggle.on": "Aggiungi ai preferiti",
"entry.starred.toast.off": "Non preferito",
"entry.starred.toast.on": "Preferito",
"entry.starred.toggle.off": "Rimuovi dai preferiti",
"entry.starred.toggle.on": "Aggiungi ai preferiti",
"entry.comments.label": "Commenti",
"entry.comments.title": "Mostra i commenti",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
"error.user_already_exists": "Questo utente esiste già.",
"error.user_mandatory_fields": "Il nome utente è obbligatorio.",
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug sono richiesti",
"form.api_key.label.description": "Etichetta chiave API",
"form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
"form.category.label.title": "Titolo",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Segna i preferiti come non letti",
"form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "Salva le voci in LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Ottieni il tuo token di accesso personale su",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tag (massimo 10, separati da virgola)",
"form.integration.linktaco_tags_hint": "Massimo 10 tag, separati da virgola",
"form.integration.linktaco_visibility": "Visibilità",
"form.integration.linktaco_visibility_public": "Pubblico",
"form.integration.linktaco_visibility_private": "Privato",
"form.integration.linktaco_visibility_hint": "La visibilità PRIVATA richiede un account LinkTaco a pagamento",
"form.integration.linkwarden_activate": "Salva gli articoli su Linkwarden",
"form.integration.linkwarden_api_key": "API key dell'account Linkwarden",
"form.integration.linkwarden_endpoint": "URL di base di Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.wallabag_password": "Password dell'account Wallabag",
"form.integration.wallabag_username": "Nome utente dell'account Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Numero di voci lette",
"page.feeds.title": "Feed",
"page.footer.elevator": "Back to top",
"page.history.title": "Cronologia",
"page.import.title": "Importa",
"page.integration.bookmarklet": "Segnalibro",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navigazione pagine",
"page.keyboard_shortcuts.subtitle.sections": "Navigazione sezioni",
"page.keyboard_shortcuts.title": "Scorciatoie da tastiera",
"page.keyboard_shortcuts.toggle_bookmark_status": "Aggiungi/rimuovi dai preferiti",
"page.keyboard_shortcuts.toggle_star_status": "Aggiungi/rimuovi dai preferiti",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_read_status_next": "Cambia lo stato di lettura (letto/da leggere), concentrati dopo",
"page.keyboard_shortcuts.toggle_read_status_prev": "Cambia lo stato di lettura (letto/da leggere), focus precedente",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "外部アカウントとのリンクが解除されました!",
"alert.background_feed_refresh": "すべてのフィードがバックグラウンドで更新されています。この処理中も Miniflux を使い続けることができます。",
"alert.feed_error": "このフィードには問題があります。",
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_starred": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_feed": "何も購読していません。",
@@ -45,10 +45,10 @@
"enclosure_media_controls.speed.reset.title": "速度を1xにリセット",
"enclosure_media_controls.speed.slower": "遅く",
"enclosure_media_controls.speed.slower.title": "%sx 遅く",
"entry.bookmark.toast.off": "星を外しました",
"entry.bookmark.toast.on": "星を付けました",
"entry.bookmark.toggle.off": "星を外す",
"entry.bookmark.toggle.on": "星を付ける",
"entry.starred.toast.off": "星を外しました",
"entry.starred.toast.on": "星を付けました",
"entry.starred.toggle.off": "星を外す",
"entry.starred.toggle.on": "星を付ける",
"entry.comments.label": "コメント",
"entry.comments.title": "コメントを見る",
"entry.estimated_reading_time": [
@@ -157,6 +157,7 @@
"error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
"error.user_already_exists": "このユーザーは既に存在します。",
"error.user_mandatory_fields": "ユーザー名が必要です。",
"error.linktaco_missing_required_fields": "LinkTaco API TokenとOrganization Slugが必要です",
"form.api_key.label.description": "API キーラベル",
"form.category.hide_globally": "未読一覧に記事を表示しない",
"form.category.label.title": "タイトル",
@@ -247,6 +248,16 @@
"form.integration.linkding_bookmark": "ブックマークを未読にする",
"form.integration.linkding_endpoint": "Linkding の API Endpoint",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "LinkTacoでエントリを保存する",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "パーソナルアクセストークンを取得する",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "タグ (最大10件、カンマ区切り)",
"form.integration.linktaco_tags_hint": "最大10件のタグ、カンマ区切り",
"form.integration.linktaco_visibility": "公開設定",
"form.integration.linktaco_visibility_public": "公開",
"form.integration.linktaco_visibility_private": "非公開",
"form.integration.linktaco_visibility_hint": "非公開設定には有料のLinkTacoアカウントが必要です",
"form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
"form.integration.linkwarden_api_key": "Linkwarden の API key",
"form.integration.linkwarden_endpoint": "リンクワーデン ベース URL",
@@ -319,6 +330,7 @@
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.wallabag_password": "Wallabag のパスワード",
"form.integration.wallabag_username": "Wallabag のユーザー名",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -456,6 +468,7 @@
"page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "既読記事の数",
"page.feeds.title": "フィード一覧",
"page.footer.elevator": "Back to top",
"page.history.title": "履歴",
"page.import.title": "インポート",
"page.integration.bookmarklet": "ブックマークレット",
@@ -500,7 +513,7 @@
"page.keyboard_shortcuts.subtitle.pages": "ページ間を移動する",
"page.keyboard_shortcuts.subtitle.sections": "セクションを移動する",
"page.keyboard_shortcuts.title": "キーボードショートカット",
"page.keyboard_shortcuts.toggle_bookmark_status": "星を付ける/外す",
"page.keyboard_shortcuts.toggle_star_status": "星を付ける/外す",
"page.keyboard_shortcuts.toggle_entry_attachments": "添付ファイルを開く/閉じる",
"page.keyboard_shortcuts.toggle_read_status_next": "既読/未読を切り替えて次のアイテムに移動",
"page.keyboard_shortcuts.toggle_read_status_prev": "既読/未読を切り替えて前のアイテムに移動",
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
"alert.no_bookmark": "Chit-má ah bô siu-chông",
"alert.no_starred": "Chit-má ah bô siu-chông",
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
@@ -45,10 +45,10 @@
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
"enclosure_media_controls.speed.slower": "Pàng bān",
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
"entry.bookmark.toast.off": "Chhú-siau siu-chông chòe soah",
"entry.bookmark.toast.on": "Sin cheng-ka siu-chông chòe soah",
"entry.bookmark.toggle.off": "Chhú-siau siu-chông",
"entry.bookmark.toggle.on": "Siu-chông khí-lâi",
"entry.starred.toast.off": "Chhú-siau siu-chông chòe soah",
"entry.starred.toast.on": "Sin cheng-ka siu-chông chòe soah",
"entry.starred.toggle.off": "Chhú-siau siu-chông",
"entry.starred.toggle.on": "Siu-chông khí-lâi",
"entry.comments.label": "Hôe-èng",
"entry.comments.title": "Khòaⁿ hôe-èng",
"entry.estimated_reading_time": [
@@ -157,6 +157,7 @@
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
"error.linktaco_missing_required_fields": "LinkTaco API Token kâh Organization Slug sio̍kêi",
"form.api_key.label.description": "API só-sîkhan-á",
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
"form.category.label.title": "Piau-tôe",
@@ -247,6 +248,16 @@
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
"form.integration.linkding_endpoint": "Linkding API thâu",
"form.integration.linkding_tags": "Linkding khan-á",
"form.integration.linktaco_activate": "Pó-chûn siau-sit kàu LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Chhú-tek lí ê kò-jîn chún-chhú token tī",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "khan-á (siōn-koân 10, iōng tō͘-tiám keh khui)",
"form.integration.linktaco_tags_hint": "Siōn-koân 10 khan-á, iōng tō͘-tiám keh khui",
"form.integration.linktaco_visibility": "Kò-chhiah-kì sìa?",
"form.integration.linktaco_visibility_public": "Kò-chhiah-kì",
"form.integration.linktaco_visibility_private": "Su-lîn",
"form.integration.linktaco_visibility_hint": "Su-lîn sìa tík tio̍h-ài chù-hêng LinkTaco kháu-chō",
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
@@ -319,6 +330,7 @@
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Khai-sí iōng Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook bāng-chí",
@@ -456,6 +468,7 @@
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
"page.feeds.title": "Siau-sit lâi-goân",
"page.footer.elevator": "Back to top",
"page.history.title": "Kì-lo̍k",
"page.import.title": "Hōe-li̍p",
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
@@ -500,7 +513,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
"page.keyboard_shortcuts.title": "Khoài-sok khí",
"page.keyboard_shortcuts.toggle_bookmark_status": "Chhet-li̍p siu-chông chōng-thài",
"page.keyboard_shortcuts.toggle_star_status": "Chhet-li̍p siu-chông chōng-thài",
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
"alert.feed_error": "Er is een probleem met deze feed",
"alert.no_bookmark": "Er zijn geen favorieten.",
"alert.no_starred": "Er zijn geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Reset snelheid naar 1x",
"enclosure_media_controls.speed.slower": "Vertraag",
"enclosure_media_controls.speed.slower.title": "Vertraag met %sx",
"entry.bookmark.toast.off": "Favoriet verwijderd",
"entry.bookmark.toast.on": "Favoriet toegevoegd",
"entry.bookmark.toggle.off": "Favoriet verwijderen",
"entry.bookmark.toggle.on": "Favoriet",
"entry.starred.toast.off": "Favoriet verwijderd",
"entry.starred.toast.on": "Favoriet toegevoegd",
"entry.starred.toggle.off": "Favoriet verwijderen",
"entry.starred.toggle.on": "Favoriet",
"entry.comments.label": "Reacties",
"entry.comments.title": "Bekijk reacties",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
"error.user_already_exists": "Deze gebruiker bestaat al.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.linktaco_missing_required_fields": "LinkTaco API Token en Organization Slug zijn verplicht",
"form.api_key.label.description": "API-sleutel omschrijving",
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
"form.category.label.title": "Titel",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_tags": "Linkding tags",
"form.integration.linktaco_activate": "Artikelen opslaan in LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Verkrijg uw persoonlijke toegangstoken op",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tags (max 10, kommagescheiden)",
"form.integration.linktaco_tags_hint": "Maximaal 10 tags, kommagescheiden",
"form.integration.linktaco_visibility": "Zichtbaarheid",
"form.integration.linktaco_visibility_public": "Openbaar",
"form.integration.linktaco_visibility_private": "Privé",
"form.integration.linktaco_visibility_hint": "PRIVÉ zichtbaarheid vereist een betaald LinkTaco account",
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
"form.integration.linkwarden_endpoint": "Linkwarden Basis URL",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.wallabag_password": "Wallabag wachtwoord",
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Webhooks activeren",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Standard Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Volgende controle:",
"page.feeds.read_counter": "Aantal gelezen artikelen",
"page.feeds.title": "Feeds",
"page.footer.elevator": "Back to top",
"page.history.title": "Geschiedenis",
"page.import.title": "Importeren",
"page.integration.bookmarklet": "Bookmarklet",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
"page.keyboard_shortcuts.title": "Sneltoetsen",
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
"page.keyboard_shortcuts.toggle_star_status": "Favoriet toevoegen/verwijderen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
+20 -7
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
"alert.feed_error": "Z tym kanałem jest problem",
"alert.no_bookmark": "Brak ulubionych w tej chwili.",
"alert.no_starred": "Brak ulubionych w tej chwili.",
"alert.no_category": "Brak kategorii!",
"alert.no_category_entry": "Brak wpisów w tej kategorii",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
@@ -47,10 +47,10 @@
"enclosure_media_controls.speed.reset.title": "Przywróć szybkość do 1x",
"enclosure_media_controls.speed.slower": "Wolniej",
"enclosure_media_controls.speed.slower.title": "Wolniej o %sx",
"entry.bookmark.toast.off": "Usunięto z ulubionych",
"entry.bookmark.toast.on": "Dodano do ulubionych",
"entry.bookmark.toggle.off": "Usuń z ulubionych",
"entry.bookmark.toggle.on": "Dodaj do ulubionych",
"entry.starred.toast.off": "Usunięto z ulubionych",
"entry.starred.toast.on": "Dodano do ulubionych",
"entry.starred.toggle.off": "Usuń z ulubionych",
"entry.starred.toggle.on": "Dodaj do ulubionych",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.estimated_reading_time": [
@@ -163,6 +163,7 @@
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
"error.user_already_exists": "Ten użytkownik już istnieje.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.linktaco_missing_required_fields": "Token API LinkTaco i ślimak organizacji są wymagane",
"form.api_key.label.description": "Etykieta klucza API",
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.category.label.title": "Tytuł",
@@ -253,9 +254,19 @@
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
"form.integration.linkding_tags": "Znaczniki Linkding",
"form.integration.linktaco_activate": "Zapisuj wpisy w LinkTaco",
"form.integration.linktaco_api_token": "Token API LinkTaco",
"form.integration.linktaco_api_token_hint": "Uzyskaj osobisty token dostępu na",
"form.integration.linktaco_org_slug": "Ślimak organizacji",
"form.integration.linktaco_tags": "Znaczniki (maks. 10, oddzielone przecinkami)",
"form.integration.linktaco_tags_hint": "Maksymalnie 10 znaczników, oddzielone przecinkami",
"form.integration.linktaco_visibility": "Widoczność",
"form.integration.linktaco_visibility_public": "Publiczne",
"form.integration.linktaco_visibility_private": "Prywatne",
"form.integration.linktaco_visibility_hint": "Widoczność PRYWATNE wymaga płatnego konta LinkTaco",
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwardena",
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwarden",
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.matrix_bot_password": "Hasło do Matrix",
@@ -322,6 +333,7 @@
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
"form.integration.wallabag_endpoint": "Podstawowy adres URL Wallabag",
"form.integration.wallabag_tags": "Znaczniki Wallabag",
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_password": "Hasło do Wallabag",
"form.integration.wallabag_username": "Login do Wallabag",
@@ -468,6 +480,7 @@
"page.feeds.next_check": "Następna aktualizacja:",
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
"page.feeds.title": "Kanały",
"page.footer.elevator": "Wróć do góry",
"page.history.title": "Historia",
"page.import.title": "Importuj",
"page.integration.bookmarklet": "Skryptozakładka",
@@ -512,7 +525,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
"page.keyboard_shortcuts.toggle_bookmark_status": "Przełącz dodanie do ulubionych",
"page.keyboard_shortcuts.toggle_star_status": "Przełącz dodanie do ulubionych",
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
"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",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Sua conta externa está desvinculada!",
"alert.background_feed_refresh": "Todas as fontes estão sendo atualizadas em segundo plano. Você pode continuar usando o Miniflux enquanto este processo está em execução.",
"alert.feed_error": "Ocorreu um problema com esta fonte.",
"alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_starred": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta categoria.",
"alert.no_feed": "Não há inscrições.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Resetar velocidade para 1x",
"enclosure_media_controls.speed.slower": "Mais Lento",
"enclosure_media_controls.speed.slower.title": "Mais lento em %sx",
"entry.bookmark.toast.off": "Desfavoritado",
"entry.bookmark.toast.on": "Favoritado",
"entry.bookmark.toggle.off": "Remover dos Favoritos",
"entry.bookmark.toggle.on": "Favoritar",
"entry.starred.toast.off": "Desfavoritado",
"entry.starred.toast.on": "Favoritado",
"entry.starred.toggle.off": "Remover dos Favoritos",
"entry.starred.toggle.on": "Favoritar",
"entry.comments.label": "Comentários",
"entry.comments.title": "Ver comentários",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Você deve definir uma senha, senão não será possível efetuar a sessão novamente.",
"error.user_already_exists": "Esse usuário já existe.",
"error.user_mandatory_fields": "O nome de usuário é obrigatório.",
"error.linktaco_missing_required_fields": "LinkTaco API Token e Organization Slug são obrigatórios",
"form.api_key.label.description": "Etiqueta da chave de API",
"form.category.hide_globally": "Ocultar entradas na lista global não lida",
"form.category.label.title": "Título",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Salvar marcador como não lido",
"form.integration.linkding_endpoint": "Endpoint de API do Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "Salvar itens no LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Obtenha seu token de acesso pessoal em",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tags (máx 10, separadas por vírgula)",
"form.integration.linktaco_tags_hint": "Máximo 10 tags, separadas por vírgula",
"form.integration.linktaco_visibility": "Visibilidade",
"form.integration.linktaco_visibility_public": "Público",
"form.integration.linktaco_visibility_private": "Privado",
"form.integration.linktaco_visibility_hint": "Visibilidade PRIVADA requer uma conta LinkTaco paga",
"form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
"form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
"form.integration.linkwarden_endpoint": "URL base do Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.wallabag_password": "Senha do Wallabag",
"form.integration.wallabag_username": "Nome de usuário do Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Próxima verificação:",
"page.feeds.read_counter": "Número de itens lidos",
"page.feeds.title": "Fontes",
"page.footer.elevator": "Back to top",
"page.history.title": "Histórico",
"page.import.title": "Importar",
"page.integration.bookmarklet": "Bookmarklet",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navegação de páginas",
"page.keyboard_shortcuts.subtitle.sections": "Navegação de seções",
"page.keyboard_shortcuts.title": "Atalhos de teclado",
"page.keyboard_shortcuts.toggle_bookmark_status": "Marcar ou desmarcar como favorito",
"page.keyboard_shortcuts.toggle_star_status": "Marcar ou desmarcar como favorito",
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/fechar anexos do item",
"page.keyboard_shortcuts.toggle_read_status_next": "Inverter estado de leitura do item, focar próximo item",
"page.keyboard_shortcuts.toggle_read_status_prev": "Inverter estado de leitura do item, focar item anterior",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Am decuplat contul dvs. extern!",
"alert.background_feed_refresh": "Toate fluxurile sunt actualizate în fundal. Puteți să continuați utilizarea Miniflux în timp ce procesul rulează.",
"alert.feed_error": "Este o problemă cu acest flux",
"alert.no_bookmark": "Nu sunt înregistrări marcate.",
"alert.no_starred": "Nu sunt înregistrări marcate.",
"alert.no_category": "Nu sunt categorii.",
"alert.no_category_entry": "Nu sunt înregistrări în această categorie.",
"alert.no_feed": "Nu aveți fluxuri.",
@@ -47,10 +47,10 @@
"enclosure_media_controls.speed.reset.title": "Resetare viteză la 1x",
"enclosure_media_controls.speed.slower": "Mai încet",
"enclosure_media_controls.speed.slower.title": "Mai încet cu %sx",
"entry.bookmark.toast.off": "Fără stea",
"entry.bookmark.toast.on": "Cu stea",
"entry.bookmark.toggle.off": "Fără stea",
"entry.bookmark.toggle.on": "Stea",
"entry.starred.toast.off": "Fără stea",
"entry.starred.toast.on": "Cu stea",
"entry.starred.toggle.off": "Fără stea",
"entry.starred.toggle.on": "Stea",
"entry.comments.label": "Comentarii",
"entry.comments.title": "Vizualizare Comentarii",
"entry.estimated_reading_time": [
@@ -163,6 +163,7 @@
"error.unlink_account_without_password": "Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.",
"error.user_already_exists": "Acest utilizator există deja.",
"error.user_mandatory_fields": "Numele utilizatorului este obligatoriu.",
"error.linktaco_missing_required_fields": "LinkTaco API Token și Organization Slug sunt necesare",
"form.api_key.label.description": "Etichetă Cheie API",
"form.category.hide_globally": "Ascunde intrările în lista globală de articole necitite",
"form.category.label.title": "Titlu",
@@ -253,6 +254,16 @@
"form.integration.linkding_bookmark": "Marchează semnele de carte ca necitite",
"form.integration.linkding_endpoint": "Endpoint API Linkding",
"form.integration.linkding_tags": "TAG-uri Linkding",
"form.integration.linktaco_activate": "Salvează înregistrările în LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Obțineți jetonul de acces personal la",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Tag-uri (maxim 10, separate prin virgule)",
"form.integration.linktaco_tags_hint": "Maxim 10 tag-uri, separate prin virgule",
"form.integration.linktaco_visibility": "Vizibilitate",
"form.integration.linktaco_visibility_public": "Public",
"form.integration.linktaco_visibility_private": "Privat",
"form.integration.linktaco_visibility_hint": "Vizibilitatea PRIVATĂ necesită un cont LinkTaco plătit",
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
"form.integration.linkwarden_endpoint": "URL-ul de bază Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_client_id": "ID Client Wallabag",
"form.integration.wallabag_client_secret": "Secret Client Wallabag",
"form.integration.wallabag_endpoint": "URL Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.wallabag_only_url": "Trimite numai URL-ul (fără conținut complet)",
"form.integration.wallabag_password": "Parolă Wallabag",
"form.integration.wallabag_username": "Utilizator Wallabag",
@@ -468,6 +480,7 @@
"page.feeds.next_check": "Următoarea verificare:",
"page.feeds.read_counter": "Numărul de intrări citite",
"page.feeds.title": "Fluxuri",
"page.footer.elevator": "Back to top",
"page.history.title": "Istoric",
"page.import.title": "Import",
"page.integration.bookmarklet": "Marcaje",
@@ -512,7 +525,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Navigare Pagini",
"page.keyboard_shortcuts.subtitle.sections": "Navigare Secțiuni",
"page.keyboard_shortcuts.title": "Scurtături Tastatură",
"page.keyboard_shortcuts.toggle_bookmark_status": "Comută marcate",
"page.keyboard_shortcuts.toggle_star_status": "Comută marcate",
"page.keyboard_shortcuts.toggle_entry_attachments": "Comută deschis/închis pe atașamentele înregistrării",
"page.keyboard_shortcuts.toggle_read_status_next": "Comută citit/necitit focus următor",
"page.keyboard_shortcuts.toggle_read_status_prev": "Comută citit/necitit, focus anterior",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Ваш внешний аккаунт теперь отвязан!",
"alert.background_feed_refresh": "Все подписки обновляются в фоновом режиме. Вы можете продолжать использовать Miniflux пока идёт этот процесс.",
"alert.feed_error": "С этой подпиской есть проблема",
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_starred": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
"alert.no_feed": "У вас нет ни одной подписки.",
@@ -47,10 +47,10 @@
"enclosure_media_controls.speed.reset.title": "Сбросить скорость до 1x",
"enclosure_media_controls.speed.slower": "Медленнее",
"enclosure_media_controls.speed.slower.title": "Замедлить в %s раз",
"entry.bookmark.toast.off": "Без пометок",
"entry.bookmark.toast.on": "Помеченные",
"entry.bookmark.toggle.off": "Удалить из Избранного",
"entry.bookmark.toggle.on": "Добавить в Избранное",
"entry.starred.toast.off": "Без пометок",
"entry.starred.toast.on": "Помеченные",
"entry.starred.toggle.off": "Удалить из Избранного",
"entry.starred.toggle.on": "Добавить в Избранное",
"entry.comments.label": "Комментарии",
"entry.comments.title": "Показать комментарии",
"entry.estimated_reading_time": [
@@ -163,6 +163,7 @@
"error.unlink_account_without_password": "Вы должны установить пароль, иначе вы не сможете войти снова.",
"error.user_already_exists": "Этот пользователь уже существует.",
"error.user_mandatory_fields": "Имя пользователя обязательно.",
"error.linktaco_missing_required_fields": "LinkTaco API Token и Organization Slug обязательны",
"form.api_key.label.description": "Описание API-ключа",
"form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.category.label.title": "Название",
@@ -253,6 +254,16 @@
"form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
"form.integration.linkding_endpoint": "Конечная точка Linkding API",
"form.integration.linkding_tags": "Теги Linkding",
"form.integration.linktaco_activate": "Сохранять статьи в LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Получить ваш персональный токен доступа на",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Теги (макс. 10, через запятую)",
"form.integration.linktaco_tags_hint": "Максимум 10 тегов, через запятую",
"form.integration.linktaco_visibility": "Видимость",
"form.integration.linktaco_visibility_public": "Публично",
"form.integration.linktaco_visibility_private": "Приватно",
"form.integration.linktaco_visibility_hint": "ПРИВАТНАЯ видимость требует платного аккаунта LinkTaco",
"form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
"form.integration.linkwarden_api_key": "API-ключ Linkwarden",
"form.integration.linkwarden_endpoint": "Базовый URL-адрес Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
"form.integration.wallabag_endpoint": "URL-адрес базы Валлабаг",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.wallabag_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.wallabag_password": "Пароль Wallabag",
"form.integration.wallabag_username": "Имя пользователя Wallabag",
@@ -468,6 +480,7 @@
"page.feeds.next_check": "Следующее обновление:",
"page.feeds.read_counter": "Количество прочитанных статей",
"page.feeds.title": "Подписки",
"page.footer.elevator": "Back to top",
"page.history.title": "История",
"page.import.title": "Импорт",
"page.integration.bookmarklet": "Букмарклет",
@@ -512,7 +525,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Навигация по страницам",
"page.keyboard_shortcuts.subtitle.sections": "Навигация по секциям",
"page.keyboard_shortcuts.title": "Горячие клавиши",
"page.keyboard_shortcuts.toggle_bookmark_status": "Переключатель избранного",
"page.keyboard_shortcuts.toggle_star_status": "Переключатель избранного",
"page.keyboard_shortcuts.toggle_entry_attachments": "Переключатель показать/скрыть вложения",
"page.keyboard_shortcuts.toggle_read_status_next": "Переключатель прочитанного, сосредоточиться на следующем",
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключатель прочитанного, фокус предыдущий",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Harici hesabınızın bağlantısı kaldırıldı!",
"alert.background_feed_refresh": "Tüm beslemeler arkaplanda yenileniyor. Bu süreç devam ederken Miniflux'ı kullanmaya devam edebilirsiniz.",
"alert.feed_error": "Bu beslemeyle ilgili bir problem var",
"alert.no_bookmark": "Yıldızlanmış makale yok.",
"alert.no_starred": "Yıldızlanmış makale yok.",
"alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
"alert.no_feed": "Hiç beslemeniz yok.",
@@ -46,10 +46,10 @@
"enclosure_media_controls.speed.reset.title": "Hızı 1x'e sıfırla",
"enclosure_media_controls.speed.slower": "Daha yavaş",
"enclosure_media_controls.speed.slower.title": "%sx kat daha yavaş",
"entry.bookmark.toast.off": "Yıldızsız",
"entry.bookmark.toast.on": "Yıldızlı",
"entry.bookmark.toggle.off": "Yıldızı kaldır",
"entry.bookmark.toggle.on": "Yıldız ekle",
"entry.starred.toast.off": "Yıldızsız",
"entry.starred.toast.on": "Yıldızlı",
"entry.starred.toggle.off": "Yıldızı kaldır",
"entry.starred.toggle.on": "Yıldız ekle",
"entry.comments.label": "Yorumlar",
"entry.comments.title": "Yorumları Göster",
"entry.estimated_reading_time": [
@@ -160,6 +160,7 @@
"error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
"error.user_already_exists": "Bu kullanıcı zaten mevcut.",
"error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
"error.linktaco_missing_required_fields": "LinkTaco API Token ve Organization Slug gereklidir",
"form.api_key.label.description": "API Anahtar Etiketi",
"form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.category.label.title": "Başlık",
@@ -250,6 +251,16 @@
"form.integration.linkding_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.linkding_endpoint": "Linkding API Uç Noktası",
"form.integration.linkding_tags": "Linkding Etiketleri",
"form.integration.linktaco_activate": "Makaleleri LinkTaco'ya kaydet",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Kişisel erişim tokenınızı edinin",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Etiketler (maks 10, virgülle ayrılmış)",
"form.integration.linktaco_tags_hint": "Maksimum 10 etiket, virgülle ayrılmış",
"form.integration.linktaco_visibility": "Görünürlük",
"form.integration.linktaco_visibility_public": "Genel",
"form.integration.linktaco_visibility_private": "Özel",
"form.integration.linktaco_visibility_hint": "ÖZEL görünürlük ücretli bir LinkTaco hesabı gerektirir",
"form.integration.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
"form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
"form.integration.linkwarden_endpoint": "Linkwarden Temel URL'si",
@@ -322,6 +333,7 @@
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam makale yerine)",
"form.integration.wallabag_password": "Wallabag Parolası",
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "Webhook'u etkinleştir",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
@@ -462,6 +474,7 @@
"page.feeds.next_check": "Sonraki kontrol:",
"page.feeds.read_counter": "Okunmuş makalelerin sayısı",
"page.feeds.title": "Beslemeler",
"page.footer.elevator": "Back to top",
"page.history.title": "Geçmiş",
"page.import.title": "İçeri Aktar",
"page.integration.bookmarklet": "Bookmarklet",
@@ -506,7 +519,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Sayfalarda Gezinme",
"page.keyboard_shortcuts.subtitle.sections": "Bölümlerde Gezinme",
"page.keyboard_shortcuts.title": "Klavye Kısayolları",
"page.keyboard_shortcuts.toggle_bookmark_status": "Yıldız ekle/kaldır",
"page.keyboard_shortcuts.toggle_star_status": "Yıldız ekle/kaldır",
"page.keyboard_shortcuts.toggle_entry_attachments": "Makele eklerini açma/kapama arasında geçiş yap",
"page.keyboard_shortcuts.toggle_read_status_next": "Okundu/okunmadı arasında geçiş yap, sonrakine odaklan",
"page.keyboard_shortcuts.toggle_read_status_prev": "Okundu/okunmadı arasında geçiş yap, öncekine odaklan",
+20 -7
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "Тепер ваш зовнішній обліковий запис підключено!",
"alert.background_feed_refresh": "Всі стрічки оновлюються у фоновому режимі. Ви можете продовжувати користуватися Miniflux, поки триває цей процес.",
"alert.feed_error": "З цією стрічкою трапилась помилка",
"alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_starred": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_feed": "У вас немає підписок.",
@@ -47,10 +47,10 @@
"enclosure_media_controls.speed.reset.title": "Скинути швидкість до 1x",
"enclosure_media_controls.speed.slower": "Повільніше",
"enclosure_media_controls.speed.slower.title": "Повільніше на %sx",
"entry.bookmark.toast.off": "Без зірочки",
"entry.bookmark.toast.on": "З зірочкою",
"entry.bookmark.toggle.off": "Прибрати зірочку",
"entry.bookmark.toggle.on": "Поставити зірочку",
"entry.starred.toast.off": "Без зірочки",
"entry.starred.toast.on": "З зірочкою",
"entry.starred.toggle.off": "Прибрати зірочку",
"entry.starred.toggle.on": "Поставити зірочку",
"entry.comments.label": "Коментарі",
"entry.comments.title": "Дивитися коментарі",
"entry.estimated_reading_time": [
@@ -162,7 +162,8 @@
"error.unable_to_update_user": "Не вдається оновити користувача.",
"error.unlink_account_without_password": "Ви маєте встановити пароль, щоб мати можливість увійти наступного разу",
"error.user_already_exists": "Такий користувач вже існує.",
"error.user_mandatory_fields": "Імя користувача є обовязковим.",
"error.user_mandatory_fields": "Ім'я користувача є обов'язковим.",
"error.linktaco_missing_required_fields": "LinkTaco API Token і Organization Slug є обов'язковими",
"form.api_key.label.description": "Назва ключа API",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.category.label.title": "Назва",
@@ -253,6 +254,16 @@
"form.integration.linkding_bookmark": "Відмічати закладку як непрочитану",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linktaco_activate": "Зберігати статті в LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "Отримайте ваш персональний токен доступу на",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "Теги (макс. 10, через кому)",
"form.integration.linktaco_tags_hint": "Максимум 10 тегів, через кому",
"form.integration.linktaco_visibility": "Видимість",
"form.integration.linktaco_visibility_public": "Публічно",
"form.integration.linktaco_visibility_private": "Приватно",
"form.integration.linktaco_visibility_hint": "ПРИВАТНА видимість потребує платного акаунта LinkTaco",
"form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
"form.integration.linkwarden_api_key": "Ключ API Linkwarden",
"form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",
@@ -322,6 +333,7 @@
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_endpoint": "Базова URL-адреса Wallabag",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.wallabag_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.wallabag_password": "Пароль Wallabag",
"form.integration.wallabag_username": "Ім’я користувача Wallabag",
@@ -468,6 +480,7 @@
"page.feeds.next_check": "Наступна перевірка:",
"page.feeds.read_counter": "Кількість прочитаних записів",
"page.feeds.title": "Стрічки",
"page.footer.elevator": "Back to top",
"page.history.title": "Історія",
"page.import.title": "Імпорт",
"page.integration.bookmarklet": "Букмарклет",
@@ -512,7 +525,7 @@
"page.keyboard_shortcuts.subtitle.pages": "Навігація по сторінках",
"page.keyboard_shortcuts.subtitle.sections": "Навігація по розділах",
"page.keyboard_shortcuts.title": "Комбінації клавиш",
"page.keyboard_shortcuts.toggle_bookmark_status": "Переключити статус закладки",
"page.keyboard_shortcuts.toggle_star_status": "Переключити статус закладки",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_read_status_next": "Переключити статус читання, перейти до наступного",
"page.keyboard_shortcuts.toggle_read_status_prev": "Переключити статус читання, перейти до попереднього",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "您的外部帐户已解除关联!",
"alert.background_feed_refresh": "所有订阅源正在后台刷新。您可以在刷新过程中继续使用 Miniflux。",
"alert.feed_error": "此订阅源存在问题",
"alert.no_bookmark": "没有收藏的条目。",
"alert.no_starred": "没有收藏的条目。",
"alert.no_category": "没有分类。",
"alert.no_category_entry": "此分类下没有条目。",
"alert.no_feed": "你没有任何订阅源。",
@@ -45,10 +45,10 @@
"enclosure_media_controls.speed.reset.title": "重置速度到 1x",
"enclosure_media_controls.speed.slower": "减慢",
"enclosure_media_controls.speed.slower.title": "速度减慢到 %sx",
"entry.bookmark.toast.off": "已取消收藏",
"entry.bookmark.toast.on": "已添加收藏",
"entry.bookmark.toggle.off": "取消收藏",
"entry.bookmark.toggle.on": "添加收藏",
"entry.starred.toast.off": "已取消收藏",
"entry.starred.toast.on": "已添加收藏",
"entry.starred.toggle.off": "取消收藏",
"entry.starred.toggle.on": "添加收藏",
"entry.comments.label": "评论",
"entry.comments.title": "查看评论",
"entry.estimated_reading_time": [
@@ -157,6 +157,7 @@
"error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
"error.user_already_exists": "此用户已存在。",
"error.user_mandatory_fields": "必须填写用户名。",
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
"form.api_key.label.description": "API 密钥标签",
"form.category.hide_globally": "在全局未读列表中隐藏条目",
"form.category.label.title": "标题",
@@ -247,6 +248,16 @@
"form.integration.linkding_bookmark": "将书签标记为未读",
"form.integration.linkding_endpoint": "Linkding API 端点",
"form.integration.linkding_tags": "Linkding 标签",
"form.integration.linktaco_activate": "保存条目到 LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "在此获取您的个人访问令牌",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "标签(最多10个,逗号分隔)",
"form.integration.linktaco_tags_hint": "最多10个标签,逗号分隔",
"form.integration.linktaco_visibility": "可见性",
"form.integration.linktaco_visibility_public": "公开",
"form.integration.linktaco_visibility_private": "私人",
"form.integration.linktaco_visibility_hint": "私人可见性需要付费的 LinkTaco 帐户",
"form.integration.linkwarden_activate": "保存条目到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 密钥",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
@@ -319,6 +330,7 @@
"form.integration.wallabag_only_url": "仅发送 URL(而非完整内容)",
"form.integration.wallabag_password": "Wallabag 密码",
"form.integration.wallabag_username": "Wallabag 用户名",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "启用 Webhooks",
"form.integration.webhook_secret": "Webhooks 密钥",
"form.integration.webhook_url": "默认 Webhook URL",
@@ -456,6 +468,7 @@
"page.feeds.next_check": "下次检查:",
"page.feeds.read_counter": "已读条目数",
"page.feeds.title": "订阅源",
"page.footer.elevator": "Back to top",
"page.history.title": "历史记录",
"page.import.title": "导入",
"page.integration.bookmarklet": "书签小应用",
@@ -500,7 +513,7 @@
"page.keyboard_shortcuts.subtitle.pages": "页面导航",
"page.keyboard_shortcuts.subtitle.sections": "区域导航",
"page.keyboard_shortcuts.title": "键盘快捷键",
"page.keyboard_shortcuts.toggle_bookmark_status": "切换收藏状态",
"page.keyboard_shortcuts.toggle_star_status": "切换收藏状态",
"page.keyboard_shortcuts.toggle_entry_attachments": "切换展开/折叠条目附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态,并切换到下一项",
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态,并切换到上一项",
+19 -6
View File
@@ -15,7 +15,7 @@
"alert.account_unlinked": "您的外部帳戶已解除關聯!",
"alert.background_feed_refresh": "所有 Feed 正在背景中更新,您可以繼續使用 Miniflux。",
"alert.feed_error": "該 Feed 存在問題",
"alert.no_bookmark": "目前沒有收藏",
"alert.no_starred": "目前沒有收藏",
"alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章",
"alert.no_feed": "目前沒有 Feed",
@@ -45,10 +45,10 @@
"enclosure_media_controls.speed.reset.title": "重設播放速度為 1x",
"enclosure_media_controls.speed.slower": "放慢",
"enclosure_media_controls.speed.slower.title": "放慢 %sx",
"entry.bookmark.toast.off": "已取消收藏",
"entry.bookmark.toast.on": "已新增收藏",
"entry.bookmark.toggle.off": "取消收藏",
"entry.bookmark.toggle.on": "新增收藏",
"entry.starred.toast.off": "已取消收藏",
"entry.starred.toast.on": "已新增收藏",
"entry.starred.toggle.off": "取消收藏",
"entry.starred.toggle.on": "新增收藏",
"entry.comments.label": "評論",
"entry.comments.title": "檢視評論",
"entry.estimated_reading_time": [
@@ -157,6 +157,7 @@
"error.unlink_account_without_password": "您必須設定密碼,否則您將無法再次登入。",
"error.user_already_exists": "使用者已存在",
"error.user_mandatory_fields": "必須填寫使用者名稱",
"error.linktaco_missing_required_fields": "LinkTaco API Token 和 Organization Slug 是必需的",
"form.api_key.label.description": "API 金鑰標籤",
"form.category.hide_globally": "在全域未讀列表中隱藏文章",
"form.category.label.title": "標題",
@@ -247,6 +248,16 @@
"form.integration.linkding_bookmark": "標記為未讀",
"form.integration.linkding_endpoint": "Linkding API 端點",
"form.integration.linkding_tags": "Linkding 標籤",
"form.integration.linktaco_activate": "儲存文章到 LinkTaco",
"form.integration.linktaco_api_token": "LinkTaco API Token",
"form.integration.linktaco_api_token_hint": "在此取得您的個人存取權杖",
"form.integration.linktaco_org_slug": "Organization Slug",
"form.integration.linktaco_tags": "標籤(最多10個,逗號分隔)",
"form.integration.linktaco_tags_hint": "最多10個標籤,逗號分隔",
"form.integration.linktaco_visibility": "可見性",
"form.integration.linktaco_visibility_public": "公開",
"form.integration.linktaco_visibility_private": "私人",
"form.integration.linktaco_visibility_hint": "私人可見性需要付費的 LinkTaco 帳戶",
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
@@ -319,6 +330,7 @@
"form.integration.wallabag_only_url": "僅傳送網址(而不是完整內容)",
"form.integration.wallabag_password": "Wallabag 密碼",
"form.integration.wallabag_username": "Wallabag 使用者名稱",
"form.integration.wallabag_tags": "Wallabag Tags",
"form.integration.webhook_activate": "啟用 Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook 網址",
@@ -456,6 +468,7 @@
"page.feeds.next_check": "下次檢查時間:",
"page.feeds.read_counter": "已讀文章數",
"page.feeds.title": "Feeds",
"page.footer.elevator": "Back to top",
"page.history.title": "歷史",
"page.import.title": "匯入",
"page.integration.bookmarklet": "書籤小工具",
@@ -500,7 +513,7 @@
"page.keyboard_shortcuts.subtitle.pages": "頁面導覽",
"page.keyboard_shortcuts.subtitle.sections": "分欄導覽",
"page.keyboard_shortcuts.title": "快捷鍵",
"page.keyboard_shortcuts.toggle_bookmark_status": "切換收藏狀態",
"page.keyboard_shortcuts.toggle_star_status": "切換收藏狀態",
"page.keyboard_shortcuts.toggle_entry_attachments": "展開/折疊文章附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切換已讀/未讀狀態,並聚焦到下一個",
"page.keyboard_shortcuts.toggle_read_status_prev": "切換已讀/未讀狀態,並聚焦到上一個",
+24 -62
View File
@@ -19,7 +19,7 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -43,7 +43,7 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -66,7 +66,7 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -89,7 +89,7 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -114,7 +114,7 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -139,7 +139,7 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -164,7 +164,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -188,7 +188,7 @@ func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -226,7 +226,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -251,7 +251,7 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -276,7 +276,7 @@ func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://:8080example.com")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err == nil {
t.Fatalf(`Incorrect proxy URL silently accepted (MEDIA_PROXY_CUSTOM_URL=%q): %q`, os.Getenv("MEDIA_PROXY_CUSTOM_URL"), config.Opts.MediaCustomProxyURL())
@@ -290,7 +290,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -313,46 +313,8 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.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 := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsInvalid(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
if _, err := config.NewConfigParser().ParseEnvironmentVariables(); err == nil {
t.Fatalf(`Parsing should have failed (MEDIA_PROXY_MODE=%q): %q`, os.Getenv("MEDIA_PROXY_MODE"), config.Opts.MediaProxyMode())
}
}
@@ -363,7 +325,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -388,7 +350,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -413,7 +375,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -433,12 +395,12 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "https")
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -462,7 +424,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -486,7 +448,7 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -511,7 +473,7 @@ func TestProxyFilterWithVideo(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -536,7 +498,7 @@ func TestProxyFilterVideoPoster(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -561,7 +523,7 @@ func TestProxyFilterVideoPosterOnce(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
+3 -3
View File
@@ -138,11 +138,11 @@ var (
// collector represents a metric collector.
type collector struct {
store *storage.Storage
refreshInterval int
refreshInterval time.Duration
}
// NewCollector initializes a new metric collector.
func NewCollector(store *storage.Storage, refreshInterval int) *collector {
func NewCollector(store *storage.Storage, refreshInterval time.Duration) *collector {
prometheus.MustRegister(BackgroundFeedRefreshDuration)
prometheus.MustRegister(ScraperRequestDuration)
prometheus.MustRegister(ArchiveEntriesDuration)
@@ -163,7 +163,7 @@ func NewCollector(store *storage.Storage, refreshInterval int) *collector {
// GatherStorageMetrics polls the database to fetch metrics.
func (c *collector) GatherStorageMetrics() {
for range time.Tick(time.Duration(c.refreshInterval) * time.Second) {
for range time.Tick(c.refreshInterval) {
slog.Debug("Collecting metrics from the database")
usersGauge.Set(float64(c.store.CountUsers()))
+3 -3
View File
@@ -290,7 +290,7 @@ func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
@@ -420,7 +420,7 @@ func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
@@ -535,7 +535,7 @@ func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
+16 -19
View File
@@ -6,7 +6,6 @@ package model // import "miniflux.app/v2/internal/model"
import (
"fmt"
"io"
"math"
"time"
"miniflux.app/v2/internal/config"
@@ -69,11 +68,11 @@ type Feed struct {
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:"-"`
ReadCount int `json:"-"`
NumberOfVisibleEntries int `json:"-"`
TTL time.Duration `json:"-"`
IconURL string `json:"-"`
UnreadCount int `json:"-"`
ReadCount int `json:"-"`
NumberOfVisibleEntries int `json:"-"`
}
type FeedCounters struct {
@@ -119,35 +118,33 @@ 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, refreshDelayInMinutes int) int {
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration {
// Default to the global config Polling Frequency.
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval()
interval := config.Opts.SchedulerRoundRobinMinInterval()
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
if weeklyCount <= 0 {
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
interval = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval())
interval = (7 * 24 * time.Hour) / time.Duration(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
interval = max(interval, config.Opts.SchedulerEntryFrequencyMinInterval())
}
}
// Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined.
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes {
intervalMinutes = refreshDelayInMinutes
}
interval = max(interval, refreshDelay)
// Limit the max interval value for misconfigured feeds.
switch config.Opts.PollingScheduler() {
case SchedulerRoundRobin:
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerRoundRobinMaxInterval())
interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval())
case SchedulerEntryFrequency:
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval())
}
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
return intervalMinutes
f.NextCheckAt = time.Now().Add(interval)
return interval
}
// FeedCreationRequest represents the request to create a feed.
+36 -36
View File
@@ -4,8 +4,8 @@
package model // import "miniflux.app/v2/internal/model"
import (
"fmt"
"os"
"strconv"
"testing"
"time"
@@ -67,11 +67,11 @@ func TestFeedCheckedNow(t *testing.T) {
}
}
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefore time.Time, message string) {
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(targetInterval))) {
func checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) {
if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) {
t.Errorf(`The next_check_at should be after timeBefore + %s`, message)
}
if feed.NextCheckAt.After(time.Now().Add(time.Minute * time.Duration(targetInterval))) {
if feed.NextCheckAt.After(time.Now().Add(targetInterval)) {
t.Errorf(`The next_check_at should be before now + %s`, message)
}
}
@@ -80,7 +80,7 @@ func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -102,7 +102,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *test
os.Clearenv()
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -125,7 +125,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *test
os.Clearenv()
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -148,7 +148,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval(t *test
os.Clearenv()
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -171,10 +171,10 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "round_robin")
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -188,7 +188,7 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
t.Error(`The next_check_at must be set`)
}
expectedInterval := minInterval
expectedInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
}
@@ -197,11 +197,11 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -217,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
t.Error(`The next_check_at must be set`)
}
targetInterval := maxInterval
targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
}
@@ -226,11 +226,11 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
minInterval := 1
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -246,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
t.Error(`The next_check_at must be set`)
}
targetInterval := maxInterval
targetInterval := time.Duration(maxInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency max interval")
}
@@ -255,11 +255,11 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -275,7 +275,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
}
@@ -283,10 +283,10 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
factor := 2
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", fmt.Sprintf("%d", factor))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", strconv.Itoa(factor))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -301,7 +301,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / factor
targetInterval := config.Opts.SchedulerEntryFrequencyMaxInterval() / time.Duration(factor)
checkTargetInterval(t, feed, targetInterval, timeBefore, "factor * count")
}
@@ -311,11 +311,11 @@ func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -326,17 +326,17 @@ func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is smaller than minInterval.
newTTL := minInterval / 2
newTTL := time.Duration(minInterval) * time.Minute / 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
targetInterval := time.Duration(minInterval) * time.Minute
checkTargetInterval(t, feed, targetInterval, timeBefore, "entry frequency min interval")
if feed.NextCheckAt.Before(timeBefore.Add(time.Minute * time.Duration(newTTL))) {
if feed.NextCheckAt.Before(timeBefore.Add(newTTL)) {
t.Error(`The next_check_at should be after timeBefore + TTL`)
}
}
@@ -347,11 +347,11 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
minInterval := 100
os.Clearenv()
os.Setenv("POLLING_SCHEDULER", "entry_frequency")
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", fmt.Sprintf("%d", maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", fmt.Sprintf("%d", minInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", strconv.Itoa(maxInterval))
os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
@@ -362,7 +362,7 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
// TTL is larger than minInterval.
newTTL := minInterval * 2
newTTL := time.Duration(minInterval) * time.Minute * 2
feed.ScheduleNextCheck(weeklyCount, newTTL)
if feed.NextCheckAt.IsZero() {
+6
View File
@@ -29,6 +29,7 @@ type Integration struct {
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
WallabagTags string
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
@@ -59,6 +60,11 @@ type Integration struct {
LinkdingAPIKey string
LinkdingTags string
LinkdingMarkAsUnread bool
LinktacoEnabled bool
LinktacoAPIToken string
LinktacoOrgSlug string
LinktacoTags string
LinktacoVisibility string
LinkwardenEnabled bool
LinkwardenURL string
LinkwardenAPIKey string
-10
View File
@@ -28,13 +28,3 @@ func (u *UserSession) String() string {
func (u *UserSession) UseTimezone(tz string) {
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
}
// UserSessions represents a list of sessions.
type UserSessions []UserSession
// UseTimezone converts creation date of all sessions to the given timezone.
func (u UserSessions) UseTimezone(tz string) {
for _, session := range u {
session.UseTimezone(tz)
}
}
+2 -2
View File
@@ -12,14 +12,14 @@ import (
)
// RFC822, RFC850, and RFC1123 formats should be applied only to local times.
var dateFormatsLocalTimesOnly = []string{
var dateFormatsLocalTimesOnly = [...]string{
time.RFC822, // RSS
time.RFC850,
time.RFC1123,
}
// dateFormats taken from github.com/mjibson/goread
var dateFormats = []string{
var dateFormats = [...]string{
time.RFC822Z, // RSS
time.RFC3339, // Atom
time.UnixDate,
+4 -4
View File
@@ -18,14 +18,14 @@ import (
)
const (
defaultHTTPClientTimeout = 20
defaultHTTPClientTimeout = 20 * time.Second
defaultAcceptHeader = "application/xml, application/atom+xml, application/rss+xml, application/rdf+xml, application/feed+json, text/html, */*;q=0.9"
)
type RequestBuilder struct {
headers http.Header
clientProxyURL *url.URL
clientTimeout int
clientTimeout time.Duration
useClientProxy bool
withoutRedirects bool
ignoreTLSErrors bool
@@ -104,7 +104,7 @@ func (r *RequestBuilder) WithCustomFeedProxyURL(proxyURL string) *RequestBuilder
return r
}
func (r *RequestBuilder) WithTimeout(timeout int) *RequestBuilder {
func (r *RequestBuilder) WithTimeout(timeout time.Duration) *RequestBuilder {
r.clientTimeout = timeout
return r
}
@@ -185,7 +185,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
}
client := &http.Client{
Timeout: time.Duration(r.clientTimeout) * time.Second,
Timeout: r.clientTimeout,
}
if r.withoutRedirects {
@@ -232,9 +232,9 @@ func TestRequestBuilder_CustomAcceptHeaderNotOverridden(t *testing.T) {
func TestRequestBuilder_WithTimeout(t *testing.T) {
builder := NewRequestBuilder()
builder = builder.WithTimeout(30)
builder = builder.WithTimeout(30 * time.Second)
if builder.clientTimeout != 30 {
if builder.clientTimeout != 30*time.Second {
t.Errorf("Expected timeout to be 30, got %d", builder.clientTimeout)
}
}
@@ -382,9 +382,8 @@ func TestRequestBuilder_ChainedMethods(t *testing.T) {
WithUserAgent("TestAgent/1.0", "DefaultAgent/1.0").
WithCookie("test=value").
WithETag("etag123").
WithTimeout(10).
WithTimeout(10 * time.Second).
ExecuteRequest(server.URL)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
@@ -409,7 +408,7 @@ func TestRequestBuilder_TimeoutConfiguration(t *testing.T) {
builder := NewRequestBuilder()
start := time.Now()
_, err := builder.WithTimeout(1).ExecuteRequest(server.URL)
_, err := builder.WithTimeout(1 * time.Second).ExecuteRequest(server.URL)
duration := time.Since(start)
if err == nil {
+8 -8
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"io"
"log/slog"
"math"
"net"
"net/http"
"net/url"
@@ -54,18 +53,19 @@ func (r *ResponseHandler) ETag() string {
return r.httpResponse.Header.Get("ETag")
}
func (r *ResponseHandler) ExpiresInMinutes() int {
func (r *ResponseHandler) Expires() time.Duration {
expiresHeaderValue := r.httpResponse.Header.Get("Expires")
if expiresHeaderValue != "" {
t, err := time.Parse(time.RFC1123, expiresHeaderValue)
if err == nil {
return int(math.Ceil(time.Until(t).Minutes()))
// This rounds up to the next minute by rounding down and just adding a minute.
return time.Until(t).Truncate(time.Minute) + time.Minute
}
}
return 0
}
func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
func (r *ResponseHandler) CacheControlMaxAge() time.Duration {
cacheControlHeaderValue := r.httpResponse.Header.Get("Cache-Control")
if cacheControlHeaderValue != "" {
for _, directive := range strings.Split(cacheControlHeaderValue, ",") {
@@ -73,7 +73,7 @@ func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
if strings.HasPrefix(directive, "max-age=") {
maxAge, err := strconv.Atoi(strings.TrimPrefix(directive, "max-age="))
if err == nil {
return int(math.Ceil(float64(maxAge) / 60))
return time.Duration(maxAge) * time.Second
}
}
}
@@ -81,17 +81,17 @@ func (r *ResponseHandler) CacheControlMaxAgeInMinutes() int {
return 0
}
func (r *ResponseHandler) ParseRetryDelay() int {
func (r *ResponseHandler) ParseRetryDelay() time.Duration {
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
return time.Duration(seconds) * time.Second
}
// 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 time.Until(t).Truncate(time.Second)
}
}
return 0
@@ -72,7 +72,7 @@ func TestIsModified(t *testing.T) {
func TestRetryDelay(t *testing.T) {
var testCases = map[string]struct {
RetryAfterHeader string
ExpectedDelay int
ExpectedDelay time.Duration
}{
"Empty header": {
RetryAfterHeader: "",
@@ -80,11 +80,11 @@ func TestRetryDelay(t *testing.T) {
},
"Integer value": {
RetryAfterHeader: "42",
ExpectedDelay: 42,
ExpectedDelay: 42 * time.Second,
},
"HTTP-date": {
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
ExpectedDelay: 41,
ExpectedDelay: 41 * time.Second,
},
}
for name, tc := range testCases {
@@ -105,20 +105,20 @@ func TestRetryDelay(t *testing.T) {
func TestExpiresInMinutes(t *testing.T) {
var testCases = map[string]struct {
ExpiresHeader string
ExpectedMinutes int
ExpiresHeader string
Expected time.Duration
}{
"Empty header": {
ExpiresHeader: "",
ExpectedMinutes: 0,
ExpiresHeader: "",
Expected: 0,
},
"Valid Expires header": {
ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),
ExpectedMinutes: 10,
ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),
Expected: 10 * time.Minute,
},
"Invalid Expires header": {
ExpiresHeader: "invalid-date",
ExpectedMinutes: 0,
ExpiresHeader: "invalid-date",
Expected: 0,
},
}
for name, tc := range testCases {
@@ -130,8 +130,8 @@ func TestExpiresInMinutes(t *testing.T) {
Header: header,
},
}
if tc.ExpectedMinutes != rh.ExpiresInMinutes() {
t.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedMinutes, rh.ExpiresInMinutes(), name)
if tc.Expected != rh.Expires() {
t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.Expires(), name)
}
})
}
@@ -140,23 +140,23 @@ func TestExpiresInMinutes(t *testing.T) {
func TestCacheControlMaxAgeInMinutes(t *testing.T) {
var testCases = map[string]struct {
CacheControlHeader string
ExpectedMinutes int
Expected time.Duration
}{
"Empty header": {
CacheControlHeader: "",
ExpectedMinutes: 0,
Expected: 0,
},
"Valid max-age": {
CacheControlHeader: "max-age=600",
ExpectedMinutes: 10,
Expected: 10 * time.Minute,
},
"Invalid max-age": {
CacheControlHeader: "max-age=invalid",
ExpectedMinutes: 0,
Expected: 0,
},
"Multiple directives": {
CacheControlHeader: "no-cache, max-age=300",
ExpectedMinutes: 5,
Expected: 5 * time.Minute,
},
}
for name, tc := range testCases {
@@ -168,8 +168,8 @@ func TestCacheControlMaxAgeInMinutes(t *testing.T) {
Header: header,
},
}
if tc.ExpectedMinutes != rh.CacheControlMaxAgeInMinutes() {
t.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedMinutes, rh.CacheControlMaxAgeInMinutes(), name)
if tc.Expected != rh.CacheControlMaxAge() {
t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.CacheControlMaxAge(), name)
}
})
}
+2 -2
View File
@@ -414,7 +414,7 @@ func TestKeeplistRulesBehavior(t *testing.T) {
// Tests for isBlockedGlobally function
func TestIsBlockedGlobally(t *testing.T) {
var err error
config.Opts, err = config.NewParser().ParseEnvironmentVariables()
config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
@@ -429,7 +429,7 @@ func TestIsBlockedGlobally(t *testing.T) {
os.Setenv("FILTER_ENTRY_MAX_AGE_DAYS", "30")
defer os.Clearenv()
config.Opts, err = config.NewParser().ParseEnvironmentVariables()
config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
+17 -17
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"errors"
"log/slog"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration"
@@ -208,7 +209,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
weeklyEntryCount := 0
refreshDelayInMinutes := 0
var refreshDelay time.Duration
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
var weeklyCountErr error
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
@@ -218,7 +219,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
@@ -242,15 +243,14 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
defer responseHandler.Close()
if responseHandler.IsRateLimited() {
retryDelayInSeconds := responseHandler.ParseRetryDelay()
refreshDelayInMinutes = retryDelayInSeconds / 60
calculatedNextCheckIntervalInMinutes := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
retryDelay := responseHandler.ParseRetryDelay()
calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, retryDelay)
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.Int("calculated_next_check_interval_in_minutes", calculatedNextCheckIntervalInMinutes),
slog.Int("retry_delay_in_seconds", int(retryDelay.Seconds())),
slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
}
@@ -316,22 +316,22 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
// Use the RSS TTL value, or the Cache-Control or Expires HTTP headers if available.
// Otherwise, we use the default value from the configuration (min interval parameter).
feedTTLValue := updatedFeed.TTL
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAgeInMinutes()
expiresValue := responseHandler.ExpiresInMinutes()
refreshDelayInMinutes = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
cacheControlMaxAgeValue := responseHandler.CacheControlMaxAge()
expiresValue := responseHandler.Expires()
refreshDelay = max(feedTTLValue, cacheControlMaxAgeValue, expiresValue)
// Set the next check at with updated arguments.
calculatedNextCheckIntervalInMinutes := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
calculatedNextCheckInterval := originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelay)
slog.Debug("Updated next check date",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.String("feed_url", originalFeed.FeedURL),
slog.Int("feed_ttl_minutes", feedTTLValue),
slog.Int("cache_control_max_age_in_minutes", cacheControlMaxAgeValue),
slog.Int("expires_in_minutes", expiresValue),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
slog.Int("calculated_next_check_interval_in_minutes", calculatedNextCheckIntervalInMinutes),
slog.Int("feed_ttl_minutes", int(feedTTLValue.Minutes())),
slog.Int("cache_control_max_age_in_minutes", int(cacheControlMaxAgeValue.Minutes())),
slog.Int("expires_in_minutes", int(expiresValue.Minutes())),
slog.Int("refresh_delay_in_minutes", int(refreshDelay.Minutes())),
slog.Int("calculated_next_check_interval_in_minutes", int(calculatedNextCheckInterval.Minutes())),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
+35 -42
View File
@@ -53,8 +53,8 @@ func (f *iconFinder) findIcon() (*model.Icon, error) {
)
if f.feedIconURL != "" {
if icon, err := f.fetchFeedIcon(); err != nil {
slog.Debug("Unable to download icon from feed",
if icon, err := f.downloadIcon(f.feedIconURL); err != nil {
slog.Debug("Unable to fetch the feed's icon",
slog.String("website_url", f.websiteURL),
slog.String("feed_icon_url", f.feedIconURL),
slog.Any("error", err),
@@ -64,13 +64,17 @@ func (f *iconFinder) findIcon() (*model.Icon, error) {
}
}
if icon, err := f.fetchIconsFromHTMLDocument(); err != nil {
slog.Debug("Unable to fetch icons from HTML document",
slog.String("website_url", f.websiteURL),
slog.Any("error", err),
)
} else if icon != nil {
return icon, nil
// Try the website URL first, then fall back to the root URL if no icon is found.
// The website URL may include a subdirectory (e.g., https://example.org/subfolder/), and icons can be referenced relative to that path.
for _, documentURL := range []string{f.websiteURL, urllib.RootURL(f.websiteURL)} {
if icon, err := f.fetchIconsFromHTMLDocument(documentURL); err != nil {
slog.Debug("Unable to fetch icons from HTML document",
slog.String("document_url", documentURL),
slog.Any("error", err),
)
} else if icon != nil {
return icon, nil
}
}
return f.fetchDefaultIcon()
@@ -94,28 +98,12 @@ func (f *iconFinder) fetchDefaultIcon() (*model.Icon, error) {
return icon, nil
}
func (f *iconFinder) fetchFeedIcon() (*model.Icon, error) {
slog.Debug("Fetching feed icon",
slog.String("website_url", f.websiteURL),
slog.String("feed_icon_url", f.feedIconURL),
)
iconURL, err := urllib.AbsoluteURL(f.websiteURL, f.feedIconURL)
if err != nil {
return nil, fmt.Errorf(`icon: unable to convert icon URL to absolute URL: %w`, err)
}
return f.downloadIcon(iconURL)
}
func (f *iconFinder) fetchIconsFromHTMLDocument() (*model.Icon, error) {
func (f *iconFinder) fetchIconsFromHTMLDocument(documentURL string) (*model.Icon, error) {
slog.Debug("Searching icons from HTML document",
slog.String("website_url", f.websiteURL),
slog.String("document_url", documentURL),
)
rootURL := urllib.RootURL(f.websiteURL)
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(rootURL))
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(documentURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
@@ -123,6 +111,7 @@ func (f *iconFinder) fetchIconsFromHTMLDocument() (*model.Icon, error) {
}
iconURLs, err := findIconURLsFromHTMLDocument(
documentURL,
responseHandler.Body(config.Opts.HTTPClientMaxBodySize()),
responseHandler.ContentType(),
)
@@ -131,32 +120,27 @@ func (f *iconFinder) fetchIconsFromHTMLDocument() (*model.Icon, error) {
}
slog.Debug("Searched icon from HTML document",
slog.String("website_url", f.websiteURL),
slog.String("document_url", documentURL),
slog.String("icon_urls", strings.Join(iconURLs, ",")),
)
for _, iconURL := range iconURLs {
if strings.HasPrefix(iconURL, "data:") {
slog.Debug("Found icon with data URL",
slog.String("website_url", f.websiteURL),
slog.String("document_url", documentURL),
)
return parseImageDataURL(iconURL)
}
iconURL, err = urllib.AbsoluteURL(f.websiteURL, iconURL)
if err != nil {
return nil, fmt.Errorf(`icon: unable to convert icon URL to absolute URL: %w`, err)
}
if icon, err := f.downloadIcon(iconURL); err != nil {
slog.Debug("Unable to download icon from HTML document",
slog.String("website_url", f.websiteURL),
slog.String("document_url", documentURL),
slog.String("icon_url", iconURL),
slog.Any("error", err),
)
} else if icon != nil {
slog.Debug("Downloaded icon from HTML document",
slog.String("website_url", f.websiteURL),
slog.String("document_url", documentURL),
slog.String("icon_url", iconURL),
)
return icon, nil
@@ -209,7 +193,7 @@ func resizeIcon(icon *model.Icon) *model.Icon {
}
if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif", "image/webp"}, icon.MimeType) {
slog.Info("Icon resize skipped: unsupported MIME type", slog.String("mime_type", icon.MimeType))
slog.Debug("Icon resize skipped: unsupported MIME type", slog.String("mime_type", icon.MimeType))
return icon
}
@@ -258,7 +242,7 @@ func resizeIcon(icon *model.Icon) *model.Icon {
return icon
}
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
func findIconURLsFromHTMLDocument(documentURL string, body io.Reader, contentType string) ([]string, error) {
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
if err != nil {
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
@@ -282,11 +266,20 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
for _, s := range doc.Find(query).EachIter() {
href, _ := s.Attr("href")
if iconURL := strings.TrimSpace(href); iconURL != "" {
iconURLs = append(iconURLs, iconURL)
href = strings.TrimSpace(href)
if href == "" {
continue
}
if absoluteIconURL, err := urllib.AbsoluteURL(documentURL, href); err != nil {
slog.Warn("Unable to convert icon URL to absolute URL", slog.Any("error", err), slog.String("icon_href", href))
} else {
iconURLs = append(iconURLs, absoluteIconURL)
slog.Debug("Found icon URL in HTML document",
slog.String("query", query),
slog.String("icon_url", iconURL))
slog.String("icon_href", href),
slog.String("absolute_icon_url", absoluteIconURL),
)
}
}
}
+26 -26
View File
@@ -123,16 +123,16 @@ func TestFindIconURLsFromHTMLDocument_MultipleIcons(t *testing.T) {
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
expected := []string{
"/favicon.ico",
"/shortcut-favicon.ico",
"/icon-shortcut.ico",
"/apple-touch-icon.png",
"https://example.org/favicon.ico",
"https://example.org/shortcut-favicon.ico",
"https://example.org/icon-shortcut.ico",
"https://example.org/apple-touch-icon.png",
}
if len(iconURLs) != len(expected) {
@@ -155,22 +155,22 @@ func TestFindIconURLsFromHTMLDocument_CaseInsensitiveRel(t *testing.T) {
<link rel="SHORTCUT ICON" href="/favicon3.ico">
<link rel="Shortcut Icon" href="/favicon4.ico">
<link rel="ICON SHORTCUT" href="/favicon5.ico">
<link rel="Icon Shortcut" href="/favicon6.ico">
<link rel="Icon Shortcut" href="favicon6.ico">
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org/folder/", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
expected := []string{
"/favicon1.ico",
"/favicon2.ico",
"/favicon3.ico",
"/favicon4.ico",
"/favicon5.ico",
"/favicon6.ico",
"https://example.org/favicon1.ico",
"https://example.org/favicon2.ico",
"https://example.org/favicon3.ico",
"https://example.org/favicon4.ico",
"https://example.org/favicon5.ico",
"https://example.org/folder/favicon6.ico",
}
if len(iconURLs) != len(expected) {
@@ -194,7 +194,7 @@ func TestFindIconURLsFromHTMLDocument_NoIcons(t *testing.T) {
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
@@ -215,12 +215,12 @@ func TestFindIconURLsFromHTMLDocument_EmptyHref(t *testing.T) {
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
expected := []string{"/valid-icon.ico"}
expected := []string{"https://example.org/valid-icon.ico"}
if len(iconURLs) != len(expected) {
t.Fatalf("Expected %d icon URLs, got %d", len(expected), len(iconURLs))
@@ -241,7 +241,7 @@ func TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org/folder", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
@@ -250,7 +250,7 @@ func TestFindIconURLsFromHTMLDocument_DataURLs(t *testing.T) {
// So both rel="icon" links are found first, then the rel="shortcut icon" link
expected := []string{
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAhGAQ+QAAAABJRU5ErkJggg==",
"/regular-icon.ico",
"https://example.org/regular-icon.ico",
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>",
}
@@ -277,17 +277,17 @@ func TestFindIconURLsFromHTMLDocument_RelativeAndAbsoluteURLs(t *testing.T) {
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org/folder/", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
expected := []string{
"/absolute-path.ico",
"relative-path.ico",
"../parent-dir.ico",
"https://example.org/absolute-path.ico",
"https://example.org/folder/relative-path.ico",
"https://example.org/parent-dir.ico",
"https://example.com/external.ico",
"//cdn.example.com/protocol-relative.ico",
"https://cdn.example.com/protocol-relative.ico",
}
if len(iconURLs) != len(expected) {
@@ -311,7 +311,7 @@ func TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {
</head>
</html>`
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(html), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(html), "text/html")
if err != nil {
t.Fatal(err)
}
@@ -324,7 +324,7 @@ func TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {
// Should at least find the valid ones
foundValidIcon := false
for _, url := range iconURLs {
if url == "/valid-before-error.ico" || url == "/valid-after-error.ico" {
if url == "https://example.org/valid-before-error.ico" || url == "https://example.org/valid-after-error.ico" {
foundValidIcon = true
break
}
@@ -336,7 +336,7 @@ func TestFindIconURLsFromHTMLDocument_InvalidHTML(t *testing.T) {
}
func TestFindIconURLsFromHTMLDocument_EmptyDocument(t *testing.T) {
iconURLs, err := findIconURLsFromHTMLDocument(strings.NewReader(""), "text/html")
iconURLs, err := findIconURLsFromHTMLDocument("https://example.org", strings.NewReader(""), "text/html")
if err != nil {
t.Fatal(err)
}
+3 -3
View File
@@ -23,7 +23,7 @@ type MediaItemElement struct {
// AllMediaThumbnails returns all thumbnail elements merged together.
func (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {
var items []Thumbnail
items := make([]Thumbnail, 0, len(e.MediaThumbnails)+len(e.MediaGroups))
items = append(items, e.MediaThumbnails...)
for _, mediaGroup := range e.MediaGroups {
items = append(items, mediaGroup.MediaThumbnails...)
@@ -33,7 +33,7 @@ func (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {
// AllMediaContents returns all content elements merged together.
func (e *MediaItemElement) AllMediaContents() []Content {
var items []Content
items := make([]Content, 0, len(e.MediaContents)+len(e.MediaGroups))
items = append(items, e.MediaContents...)
for _, mediaGroup := range e.MediaGroups {
items = append(items, mediaGroup.MediaContents...)
@@ -43,7 +43,7 @@ func (e *MediaItemElement) AllMediaContents() []Content {
// AllMediaPeerLinks returns all peer link elements merged together.
func (e *MediaItemElement) AllMediaPeerLinks() []PeerLink {
var items []PeerLink
items := make([]PeerLink, 0, len(e.MediaPeerLinks)+len(e.MediaGroups))
items = append(items, e.MediaPeerLinks...)
for _, mediaGroup := range e.MediaGroups {
items = append(items, mediaGroup.MediaPeerLinks...)
+3 -3
View File
@@ -31,11 +31,11 @@ func getVideoIDFromYouTubeURL(websiteURL string) string {
}
func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() == "" && isYouTubeVideoURL(entry.URL)
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() == "" && isYouTubeVideoURL(entry.URL)
}
func shouldFetchYouTubeWatchTimeInBulk() bool {
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() != ""
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() != ""
}
func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
@@ -82,7 +82,7 @@ func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Dura
apiQuery := url.Values{}
apiQuery.Set("id", strings.Join(videoIDs, ","))
apiQuery.Set("key", config.Opts.YouTubeApiKey())
apiQuery.Set("key", config.Opts.YouTubeAPIKey())
apiQuery.Set("part", "contentDetails")
apiURL := url.URL{
@@ -67,7 +67,7 @@ func TestRewriteWithNoMatchingRule(t *testing.T) {
}
func TestRewriteYoutubeVideoLink(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
controlEntry := &model.Entry{
URL: "https://www.youtube.com/watch?v=1234",
@@ -87,7 +87,7 @@ func TestRewriteYoutubeVideoLink(t *testing.T) {
}
func TestRewriteYoutubeShortLink(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
controlEntry := &model.Entry{
URL: "https://www.youtube.com/shorts/1LUWKWZkPjo",
@@ -107,7 +107,7 @@ func TestRewriteYoutubeShortLink(t *testing.T) {
}
func TestRewriteIncorrectYoutubeLink(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
controlEntry := &model.Entry{
URL: "https://www.youtube.com/some-page",
@@ -131,7 +131,7 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
@@ -156,7 +156,7 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
}
func TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
controlEntry := &model.Entry{
URL: "https://www.youtube.com/watch?v=1234",
Title: `A title`,
@@ -176,7 +176,7 @@ func TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {
}
func TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
controlEntry := &model.Entry{
URL: "https://www.youtube.com/shorts/1LUWKWZkPjo",
Title: `A title`,
@@ -196,7 +196,7 @@ func TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {
}
func TestAddYoutubeVideoFromId(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
scenarios := map[string]string{
// Test with single YouTube ID
@@ -239,7 +239,7 @@ func TestAddYoutubeVideoFromIdWithCustomEmbedURL(t *testing.T) {
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
var err error
parser := config.NewParser()
parser := config.NewConfigParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
+1 -1
View File
@@ -55,7 +55,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
// Get TTL if defined.
if r.rss.Channel.TTL != "" {
if ttl, err := strconv.Atoi(r.rss.Channel.TTL); err == nil {
feed.TTL = ttl
feed.TTL = time.Duration(ttl) * time.Minute
}
}
+1 -1
View File
@@ -2151,7 +2151,7 @@ func TestParseFeedWithTTLField(t *testing.T) {
t.Fatal(err)
}
if feed.TTL != 60 {
if feed.TTL != 60*time.Minute {
t.Errorf("Incorrect TTL, got: %d", feed.TTL)
}
}
+28 -29
View File
@@ -18,71 +18,71 @@ import (
)
var (
allowedHTMLTagsAndAttributes = map[string]map[string]struct{}{
"a": {"href": {}, "title": {}, "id": {}},
"abbr": {"title": {}},
"acronym": {"title": {}},
allowedHTMLTagsAndAttributes = map[string][]string{
"a": {"href", "title", "id"},
"abbr": {"title"},
"acronym": {"title"},
"aside": {},
"audio": {"src": {}},
"audio": {"src"},
"blockquote": {},
"b": {},
"br": {},
"caption": {},
"cite": {},
"code": {},
"dd": {"id": {}},
"dd": {"id"},
"del": {},
"dfn": {},
"dl": {"id": {}},
"dt": {"id": {}},
"dl": {"id"},
"dt": {"id"},
"em": {},
"figcaption": {},
"figure": {},
"h1": {"id": {}},
"h2": {"id": {}},
"h3": {"id": {}},
"h4": {"id": {}},
"h5": {"id": {}},
"h6": {"id": {}},
"h1": {"id"},
"h2": {"id"},
"h3": {"id"},
"h4": {"id"},
"h5": {"id"},
"h6": {"id"},
"hr": {},
"iframe": {"width": {}, "height": {}, "frameborder": {}, "src": {}, "allowfullscreen": {}},
"img": {"alt": {}, "title": {}, "src": {}, "srcset": {}, "sizes": {}, "width": {}, "height": {}, "fetchpriority": {}, "decoding": {}},
"iframe": {"width", "height", "frameborder", "src", "allowfullscreen"},
"img": {"alt", "title", "src", "srcset", "sizes", "width", "height", "fetchpriority", "decoding"},
"ins": {},
"kbd": {},
"li": {"id": {}},
"ol": {"id": {}},
"li": {"id"},
"ol": {"id"},
"p": {},
"picture": {},
"pre": {},
"q": {"cite": {}},
"q": {"cite"},
"rp": {},
"rt": {},
"rtc": {},
"ruby": {},
"s": {},
"samp": {},
"source": {"src": {}, "type": {}, "srcset": {}, "sizes": {}, "media": {}},
"source": {"src", "type", "srcset", "sizes", "media"},
"strong": {},
"sub": {},
"sup": {"id": {}},
"sup": {"id"},
"table": {},
"td": {"rowspan": {}, "colspan": {}},
"td": {"rowspan", "colspan"},
"tfoot": {},
"th": {"rowspan": {}, "colspan": {}},
"th": {"rowspan", "colspan"},
"thead": {},
"time": {"datetime": {}},
"time": {"datetime"},
"tr": {},
"u": {},
"ul": {"id": {}},
"ul": {"id"},
"var": {},
"video": {"poster": {}, "height": {}, "width": {}, "src": {}},
"video": {"poster", "height", "width", "src"},
"wbr": {},
// MathML: https://w3c.github.io/mathml-core/ and https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element
"annotation": {},
"annotation-xml": {},
"maction": {},
"math": {"xmlns": {}},
"math": {"xmlns"},
"merror": {},
"mfrac": {},
"mi": {},
@@ -419,8 +419,7 @@ func isValidTag(tagName string) bool {
func isValidAttribute(tagName, attributeName string) bool {
if attributes, ok := allowedHTMLTagsAndAttributes[tagName]; ok {
_, allowed := attributes[attributeName]
return allowed
return slices.Contains(attributes, attributeName)
}
return false
}
+6 -6
View File
@@ -392,7 +392,7 @@ func TestInvalidNestedTag(t *testing.T) {
}
func TestInvalidIFrame(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
input := `<iframe src="http://example.org/"></iframe>`
expected := ``
@@ -404,7 +404,7 @@ func TestInvalidIFrame(t *testing.T) {
}
func TestSameDomainIFrame(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
input := `<iframe src="http://example.com/test"></iframe>`
expected := ``
@@ -416,7 +416,7 @@ func TestSameDomainIFrame(t *testing.T) {
}
func TestInvidiousIFrame(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
input := `<iframe src="https://yewtu.be/watch?v=video_id"></iframe>`
expected := `<iframe src="https://yewtu.be/watch?v=video_id" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
@@ -432,7 +432,7 @@ func TestCustomYoutubeEmbedURL(t *testing.T) {
defer os.Clearenv()
var err error
if config.Opts, err = config.NewParser().ParseEnvironmentVariables(); err != nil {
if config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables(); err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
@@ -446,7 +446,7 @@ func TestCustomYoutubeEmbedURL(t *testing.T) {
}
func TestIFrameWithChildElements(t *testing.T) {
config.Opts = config.NewOptions()
config.Opts = config.NewConfigOptions()
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
expected := `<iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
@@ -850,7 +850,7 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
var err error
config.Opts, err = config.NewParser().ParseEnvironmentVariables()
config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
+32 -56
View File
@@ -70,24 +70,15 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
}
// Step 2) Check if the website URL is a YouTube channel.
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
if subscriptions, localizedError := f.findSubscriptionsFromYouTubeChannelPage(websiteURL); localizedError != nil {
slog.Debug("Try to detect feeds for a YouTube page", slog.String("website_url", websiteURL))
if subscriptions, localizedError := f.findSubscriptionsFromYouTube(websiteURL); localizedError != nil {
return nil, localizedError
} else if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube channel page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
slog.Debug("Subscriptions found from YouTube page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
// Step 3) Check if the website URL is a YouTube playlist.
slog.Debug("Try to detect feeds from YouTube playlist page", slog.String("website_url", websiteURL))
if subscriptions, localizedError := f.findSubscriptionsFromYouTubePlaylistPage(websiteURL); localizedError != nil {
return nil, localizedError
} else if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube playlist page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
// Step 4) Parse web page to find feeds from HTML meta tags.
// Step 3) Parse web page to find feeds from HTML meta tags.
slog.Debug("Try to detect feeds from HTML meta tags",
slog.String("website_url", websiteURL),
slog.String("content_type", responseHandler.ContentType()),
@@ -99,7 +90,7 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
return subscriptions, nil
}
// Step 5) Check if the website URL can use RSS-Bridge.
// Step 4) Check if the website URL can use RSS-Bridge.
if rssBridgeURL != "" {
slog.Debug("Try to detect feeds with RSS-Bridge", slog.String("website_url", websiteURL))
if subscriptions, localizedError := f.findSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL, rssBridgeToken); localizedError != nil {
@@ -110,7 +101,7 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
}
}
// Step 6) Check if the website has a known feed URL.
// Step 5) Check if the website has a known feed URL.
slog.Debug("Try to detect feeds from well-known URLs", slog.String("website_url", websiteURL))
if subscriptions, localizedError := f.findSubscriptionsFromWellKnownURLs(websiteURL); localizedError != nil {
return nil, localizedError
@@ -124,10 +115,9 @@ func (f *subscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string,
func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentType string, body io.Reader) (Subscriptions, *locale.LocalizedErrorWrapper) {
queries := map[string]string{
"link[type='application/rss+xml']": parser.FormatRSS,
"link[type='application/atom+xml']": parser.FormatAtom,
"link[type='application/json']": parser.FormatJSON,
"link[type='application/feed+json']": parser.FormatJSON,
"link[type='application/rss+xml']": parser.FormatRSS,
"link[type='application/atom+xml']": parser.FormatAtom,
"link[type='application/json'], link[type='application/feed+json']": parser.FormatJSON,
}
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
@@ -154,24 +144,24 @@ func (f *subscriptionFinder) findSubscriptionsFromWebPage(websiteURL, contentTyp
subscription := new(subscription)
subscription.Type = kind
if title, exists := s.Attr("title"); exists {
subscription.Title = title
if feedURL, exists := s.Attr("href"); exists && feedURL != "" {
subscription.URL, err = urllib.AbsoluteURL(websiteURL, feedURL)
if err != nil {
return
}
} else {
return // without an url, there can be no subscription.
}
if feedURL, exists := s.Attr("href"); exists {
if feedURL != "" {
subscription.URL, err = urllib.AbsoluteURL(websiteURL, feedURL)
if err != nil {
return
}
}
if title, exists := s.Attr("title"); exists {
subscription.Title = title
}
if subscription.Title == "" {
subscription.Title = subscription.URL
}
if subscription.URL != "" && !subscriptionURLs[subscription.URL] {
if !subscriptionURLs[subscription.URL] {
subscriptionURLs[subscription.URL] = true
subscriptions = append(subscriptions, subscription)
}
@@ -283,40 +273,26 @@ func (f *subscriptionFinder) findSubscriptionsFromRSSBridge(websiteURL, rssBridg
return subscriptions, nil
}
func (f *subscriptionFinder) findSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
decodedUrl, err := url.Parse(websiteURL)
func (f *subscriptionFinder) findSubscriptionsFromYouTube(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
decodedURL, err := url.Parse(websiteURL)
if err != nil {
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
}
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))
if !strings.HasSuffix(decodedURL.Host, "youtube.com") {
slog.Debug("YouTube feed discovery skipped: not a YouTube domain", slog.String("website_url", websiteURL))
return nil, nil
}
if _, channelID, found := strings.Cut(decodedUrl.Path, "channel/"); found {
if _, channelID, found := strings.Cut(decodedURL.Path, "channel/"); found {
feedURL := "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelID
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
}
return nil, nil
}
func (f *subscriptionFinder) findSubscriptionsFromYouTubePlaylistPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
decodedUrl, err := url.Parse(websiteURL)
if err != nil {
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
}
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
}
if (strings.HasPrefix(decodedUrl.Path, "/watch") && decodedUrl.Query().Has("list")) || strings.HasPrefix(decodedUrl.Path, "/playlist") {
playlistID := decodedUrl.Query().Get("list")
feedURL := "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistID
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
return Subscriptions{NewSubscription(decodedURL.String(), feedURL, parser.FormatAtom)}, nil
}
if strings.HasPrefix(decodedURL.Path, "/watch") || strings.HasPrefix(decodedURL.Path, "/playlist") {
if playlistID := decodedURL.Query().Get("list"); playlistID != "" {
feedURL := "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistID
return Subscriptions{NewSubscription(decodedURL.String(), feedURL, parser.FormatAtom)}, nil
}
}
return nil, nil
+13 -97
View File
@@ -8,96 +8,7 @@ import (
"testing"
)
func TestFindYoutubePlaylistFeed(t *testing.T) {
type testResult struct {
websiteURL string
feedURL string
discoveryError bool
}
scenarios := []testResult{
// Video URL
{
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
feedURL: "",
},
// Video URL with position argument
{
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1",
feedURL: "",
},
// Video URL with position argument
{
websiteURL: "https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ",
feedURL: "",
},
// Channel URL
{
websiteURL: "https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
feedURL: "",
},
// Channel URL with name
{
websiteURL: "https://www.youtube.com/@ABCDEFG",
feedURL: "",
},
// Playlist URL
{
websiteURL: "https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
},
// Playlist URL with video ID
{
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
},
// Playlist URL with video ID and index argument
{
websiteURL: "https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4",
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
},
// Non-Youtube URL
{
websiteURL: "https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
feedURL: "",
},
// Invalid URL
{
websiteURL: "https://example|org/",
feedURL: "",
discoveryError: true,
},
}
for _, scenario := range scenarios {
subscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTubePlaylistPage(scenario.websiteURL)
if scenario.discoveryError {
if localizedError == nil {
t.Fatalf(`Parsing an invalid URL should return an error`)
}
}
if scenario.feedURL == "" {
if len(subscriptions) > 0 {
t.Fatalf(`Parsing a non-playlist URL should not return any subscription: %q`, scenario.websiteURL)
}
} else {
if localizedError != nil {
t.Fatalf(`Parsing a correctly formatted YouTube playlist page should not return any error: %v`, localizedError)
}
if len(subscriptions) != 1 {
t.Fatalf(`Incorrect number of subscriptions returned`)
}
if subscriptions[0].URL != scenario.feedURL {
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
}
}
}
}
func TestFindYoutubeChannelFeed(t *testing.T) {
func TestFindYoutubeFeed(t *testing.T) {
type testResult struct {
websiteURL string
feedURL string
@@ -133,16 +44,21 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
// Playlist URL
{
websiteURL: "https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
feedURL: "",
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
},
// Playlist URL with video ID
{
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
feedURL: "",
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
},
// Playlist URL with video ID and index argument
{
websiteURL: "https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4",
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
},
// Empty playlist ID parameter
{
websiteURL: "https://www.youtube.com/playlist?list=",
feedURL: "",
},
// Non-Youtube URL
@@ -159,7 +75,7 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
}
for _, scenario := range scenarios {
subscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTubeChannelPage(scenario.websiteURL)
subscriptions, localizedError := NewSubscriptionFinder(nil).findSubscriptionsFromYouTube(scenario.websiteURL)
if scenario.discoveryError {
if localizedError == nil {
t.Fatalf(`Parsing an invalid URL should return an error`)
@@ -168,11 +84,11 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
if scenario.feedURL == "" {
if len(subscriptions) > 0 {
t.Fatalf(`Parsing a non-channel URL should not return any subscription: %q`, scenario.websiteURL)
t.Fatalf(`Parsing an invalid URL should not return any subscription: %q -> %v`, scenario.websiteURL, subscriptions)
}
} else {
if localizedError != nil {
t.Fatalf(`Parsing a correctly formatted YouTube channel page should not return any error: %v`, localizedError)
t.Fatalf(`Parsing a correctly formatted YouTube playlist or channel page should not return any error: %v`, localizedError)
}
if len(subscriptions) != 1 {
@@ -180,7 +96,7 @@ func TestFindYoutubeChannelFeed(t *testing.T) {
}
if subscriptions[0].URL != scenario.feedURL {
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
t.Errorf(`Unexpected feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
}
}
}
@@ -390,7 +306,7 @@ func TestParseWebPageWithMultipleFeeds(t *testing.T) {
<html>
<head>
<link href="http://example.org/atom.xml" rel="alternate" type="application/atom+xml" title="Atom Feed">
<link href="http://example.org/feed.json" rel="alternate" type="application/feed+json" title="JSON Feed">
<link href="http://example.org/feed.json" rel="alternate" type="application/json" title="JSON Feed">
</head>
<body>
</body>
+5 -3
View File
@@ -54,8 +54,10 @@ func NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {
// filterValidXMLChars filters inplace invalid XML characters.
// This function is inspired from bytes.Map
func filterValidXMLChars(s []byte) []byte {
j := 0
for i := 0; i < len(s); {
var i uint // declaring it as an uint removes a bound check in the loop.
var j int
for i = 0; i < uint(len(s)); {
wid := 1
r := rune(s[i])
if r >= utf8.RuneSelf {
@@ -67,7 +69,7 @@ func filterValidXMLChars(s []byte) []byte {
j += wid
}
}
i += wid
i += uint(wid)
}
return s[:j]
}
+12 -10
View File
@@ -392,9 +392,9 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
return newEntries, nil
}
// ArchiveEntries changes the status of entries to "removed" after the given number of days.
func (s *Storage) ArchiveEntries(status string, days, limit int) (int64, error) {
if days < 0 || limit <= 0 {
// ArchiveEntries changes the status of entries to "removed" after the interval (24h minimum).
func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
if interval < 0 || limit <= 0 {
return 0, nil
}
@@ -419,6 +419,8 @@ func (s *Storage) ArchiveEntries(status string, days, limit int) (int64, error)
)
`
days := max(int(interval/(24*time.Hour)), 1)
result, err := s.db.Exec(query, model.EntryStatusRemoved, status, fmt.Sprintf("%d days", days), limit)
if err != nil {
return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
@@ -477,12 +479,12 @@ func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status s
return visible, nil
}
// SetEntriesBookmarkedState updates the bookmarked state for the given list of entries.
func (s *Storage) SetEntriesBookmarkedState(userID int64, entryIDs []int64, starred bool) error {
// SetEntriesStarredState updates the starred state for the given list of entries.
func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
if err != nil {
return fmt.Errorf(`store: unable to update the bookmarked state %v: %v`, entryIDs, err)
return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
}
count, err := result.RowsAffected()
@@ -497,17 +499,17 @@ func (s *Storage) SetEntriesBookmarkedState(userID int64, entryIDs []int64, star
return nil
}
// ToggleBookmark toggles entry bookmark value.
func (s *Storage) ToggleBookmark(userID int64, entryID int64) error {
// ToggleStarred toggles entry starred value.
func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
query := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2`
result, err := s.db.Exec(query, userID, entryID)
if err != nil {
return fmt.Errorf(`store: unable to toggle bookmark flag for entry #%d: %v`, entryID, err)
return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
}
count, err := result.RowsAffected()
if err != nil {
return fmt.Errorf(`store: unable to toggle bookmark flag for entry #%d: %v`, entryID, err)
return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
}
if count == 0 {
+16 -8
View File
@@ -111,10 +111,14 @@ func (s *Storage) CountAllFeeds() map[string]int64 {
// CountUserFeedsWithErrors returns the number of feeds with parsing errors that belong to the given user.
func (s *Storage) CountUserFeedsWithErrors(userID int64) int {
pollingParsingErrorLimit := min(config.Opts.PollingParsingErrorLimit(), 1)
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
if pollingParsingErrorLimit <= 0 {
pollingParsingErrorLimit = 1
}
query := `SELECT count(*) FROM feeds WHERE user_id=$1 AND parsing_error_count >= $2`
var result int
if s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result) != nil {
err := s.db.QueryRow(query, userID, pollingParsingErrorLimit).Scan(&result)
if err != nil {
return 0
}
@@ -123,10 +127,14 @@ func (s *Storage) CountUserFeedsWithErrors(userID int64) int {
// CountAllFeedsWithErrors returns the number of feeds with parsing errors.
func (s *Storage) CountAllFeedsWithErrors() int {
pollingParsingErrorLimit := min(config.Opts.PollingParsingErrorLimit(), 1)
pollingParsingErrorLimit := config.Opts.PollingParsingErrorLimit()
if pollingParsingErrorLimit <= 0 {
pollingParsingErrorLimit = 1
}
query := `SELECT count(*) FROM feeds WHERE parsing_error_count >= $1`
var result int
if s.db.QueryRow(query, pollingParsingErrorLimit).Scan(&result) != nil {
err := s.db.QueryRow(query, pollingParsingErrorLimit).Scan(&result)
if err != nil {
return 0
}
@@ -142,11 +150,11 @@ func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
func getFeedsSorted(builder *FeedQueryBuilder) (model.Feeds, error) {
result, err := builder.GetFeeds()
if err != nil {
return nil, err
if err == nil {
sort.Sort(byStateAndName{result})
return result, nil
}
sort.Sort(byStateAndName{result})
return result, nil
return result, err
}
// FeedsWithCounters returns all feeds of the given user with counters of read and unread entries.
+121 -96
View File
@@ -130,6 +130,7 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
wallabag_client_secret,
wallabag_username,
wallabag_password,
wallabag_tags,
notion_enabled,
notion_token,
notion_page_id,
@@ -220,7 +221,12 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
rssbridge_token,
karakeep_enabled,
karakeep_api_key,
karakeep_url
karakeep_url,
linktaco_enabled,
linktaco_api_token,
linktaco_org_slug,
linktaco_tags,
linktaco_visibility
FROM
integrations
WHERE
@@ -249,6 +255,7 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.WallabagClientSecret,
&integration.WallabagUsername,
&integration.WallabagPassword,
&integration.WallabagTags,
&integration.NotionEnabled,
&integration.NotionToken,
&integration.NotionPageID,
@@ -340,6 +347,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.KarakeepEnabled,
&integration.KarakeepAPIKey,
&integration.KarakeepURL,
&integration.LinktacoEnabled,
&integration.LinktacoAPIToken,
&integration.LinktacoOrgSlug,
&integration.LinktacoTags,
&integration.LinktacoVisibility,
)
switch {
case err == sql.ErrNoRows:
@@ -374,102 +386,108 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
wallabag_client_secret=$15,
wallabag_username=$16,
wallabag_password=$17,
nunux_keeper_enabled=$18,
nunux_keeper_url=$19,
nunux_keeper_api_key=$20,
googlereader_enabled=$21,
googlereader_username=$22,
googlereader_password=$23,
telegram_bot_enabled=$24,
telegram_bot_token=$25,
telegram_bot_chat_id=$26,
telegram_bot_topic_id=$27,
telegram_bot_disable_web_page_preview=$28,
telegram_bot_disable_notification=$29,
telegram_bot_disable_buttons=$30,
espial_enabled=$31,
espial_url=$32,
espial_api_key=$33,
espial_tags=$34,
linkace_enabled=$35,
linkace_url=$36,
linkace_api_key=$37,
linkace_tags=$38,
linkace_is_private=$39,
linkace_check_disabled=$40,
linkding_enabled=$41,
linkding_url=$42,
linkding_api_key=$43,
linkding_tags=$44,
linkding_mark_as_unread=$45,
matrix_bot_enabled=$46,
matrix_bot_user=$47,
matrix_bot_password=$48,
matrix_bot_url=$49,
matrix_bot_chat_id=$50,
notion_enabled=$51,
notion_token=$52,
notion_page_id=$53,
readwise_enabled=$54,
readwise_api_key=$55,
apprise_enabled=$56,
apprise_url=$57,
apprise_services_url=$58,
readeck_enabled=$59,
readeck_url=$60,
readeck_api_key=$61,
readeck_labels=$62,
readeck_only_url=$63,
shiori_enabled=$64,
shiori_url=$65,
shiori_username=$66,
shiori_password=$67,
shaarli_enabled=$68,
shaarli_url=$69,
shaarli_api_secret=$70,
webhook_enabled=$71,
webhook_url=$72,
webhook_secret=$73,
rssbridge_enabled=$74,
rssbridge_url=$75,
omnivore_enabled=$76,
omnivore_api_key=$77,
omnivore_url=$78,
linkwarden_enabled=$79,
linkwarden_url=$80,
linkwarden_api_key=$81,
raindrop_enabled=$82,
raindrop_token=$83,
raindrop_collection_id=$84,
raindrop_tags=$85,
betula_enabled=$86,
betula_url=$87,
betula_token=$88,
ntfy_enabled=$89,
ntfy_topic=$90,
ntfy_url=$91,
ntfy_api_token=$92,
ntfy_username=$93,
ntfy_password=$94,
ntfy_icon_url=$95,
ntfy_internal_links=$96,
cubox_enabled=$97,
cubox_api_link=$98,
discord_enabled=$99,
discord_webhook_link=$100,
slack_enabled=$101,
slack_webhook_link=$102,
pushover_enabled=$103,
pushover_user=$104,
pushover_token=$105,
pushover_device=$106,
pushover_prefix=$107,
rssbridge_token=$108,
karakeep_enabled=$109,
karakeep_api_key=$110,
karakeep_url=$111
wallabag_tags=$18,
nunux_keeper_enabled=$19,
nunux_keeper_url=$20,
nunux_keeper_api_key=$21,
googlereader_enabled=$22,
googlereader_username=$23,
googlereader_password=$24,
telegram_bot_enabled=$25,
telegram_bot_token=$26,
telegram_bot_chat_id=$27,
telegram_bot_topic_id=$28,
telegram_bot_disable_web_page_preview=$29,
telegram_bot_disable_notification=$30,
telegram_bot_disable_buttons=$31,
espial_enabled=$32,
espial_url=$33,
espial_api_key=$34,
espial_tags=$35,
linkace_enabled=$36,
linkace_url=$37,
linkace_api_key=$38,
linkace_tags=$39,
linkace_is_private=$40,
linkace_check_disabled=$41,
linkding_enabled=$42,
linkding_url=$43,
linkding_api_key=$44,
linkding_tags=$45,
linkding_mark_as_unread=$46,
matrix_bot_enabled=$47,
matrix_bot_user=$48,
matrix_bot_password=$49,
matrix_bot_url=$50,
matrix_bot_chat_id=$51,
notion_enabled=$52,
notion_token=$53,
notion_page_id=$54,
readwise_enabled=$55,
readwise_api_key=$56,
apprise_enabled=$57,
apprise_url=$58,
apprise_services_url=$59,
readeck_enabled=$60,
readeck_url=$61,
readeck_api_key=$62,
readeck_labels=$63,
readeck_only_url=$64,
shiori_enabled=$65,
shiori_url=$66,
shiori_username=$67,
shiori_password=$68,
shaarli_enabled=$69,
shaarli_url=$70,
shaarli_api_secret=$71,
webhook_enabled=$72,
webhook_url=$73,
webhook_secret=$74,
rssbridge_enabled=$75,
rssbridge_url=$76,
omnivore_enabled=$77,
omnivore_api_key=$78,
omnivore_url=$79,
linkwarden_enabled=$80,
linkwarden_url=$81,
linkwarden_api_key=$82,
raindrop_enabled=$83,
raindrop_token=$84,
raindrop_collection_id=$85,
raindrop_tags=$86,
betula_enabled=$87,
betula_url=$88,
betula_token=$89,
ntfy_enabled=$90,
ntfy_topic=$91,
ntfy_url=$92,
ntfy_api_token=$93,
ntfy_username=$94,
ntfy_password=$95,
ntfy_icon_url=$96,
ntfy_internal_links=$97,
cubox_enabled=$98,
cubox_api_link=$99,
discord_enabled=$100,
discord_webhook_link=$101,
slack_enabled=$102,
slack_webhook_link=$103,
pushover_enabled=$104,
pushover_user=$105,
pushover_token=$106,
pushover_device=$107,
pushover_prefix=$108,
rssbridge_token=$109,
karakeep_enabled=$110,
karakeep_api_key=$111,
karakeep_url=$112,
linktaco_enabled=$113,
linktaco_api_token=$114,
linktaco_org_slug=$115,
linktaco_tags=$116,
linktaco_visibility=$117
WHERE
user_id=$112
user_id=$118
`
_, err := s.db.Exec(
query,
@@ -490,6 +508,7 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.WallabagClientSecret,
integration.WallabagUsername,
integration.WallabagPassword,
integration.WallabagTags,
integration.NunuxKeeperEnabled,
integration.NunuxKeeperURL,
integration.NunuxKeeperAPIKey,
@@ -584,6 +603,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.KarakeepEnabled,
integration.KarakeepAPIKey,
integration.KarakeepURL,
integration.LinktacoEnabled,
integration.LinktacoAPIToken,
integration.LinktacoOrgSlug,
integration.LinktacoTags,
integration.LinktacoVisibility,
integration.UserID,
)
@@ -614,6 +638,7 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
readwise_enabled='t' OR
linkace_enabled='t' OR
linkding_enabled='t' OR
linktaco_enabled='t' OR
linkwarden_enabled='t' OR
apprise_enabled='t' OR
shiori_enabled='t' OR
+6 -2
View File
@@ -7,6 +7,7 @@ import (
"crypto/rand"
"database/sql"
"fmt"
"time"
"miniflux.app/v2/internal/model"
)
@@ -122,14 +123,17 @@ func (s *Storage) FlushAllSessions() (err error) {
return nil
}
// CleanOldSessions removes sessions older than specified days.
func (s *Storage) CleanOldSessions(days int) int64 {
// CleanOldSessions removes sessions older than specified interval (24h minimum).
func (s *Storage) CleanOldSessions(interval time.Duration) int64 {
query := `
DELETE FROM
sessions
WHERE
created_at < now() - $1::interval
`
days := max(int(interval/(24*time.Hour)), 1)
result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
if err != nil {
return 0
-32
View File
@@ -1,32 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package storage // import "miniflux.app/v2/internal/storage"
import (
"fmt"
"strings"
)
// 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`)
if err != nil {
return nil, fmt.Errorf(`store: unable to fetch timezones: %v`, err)
}
defer rows.Close()
for rows.Next() {
var timezone string
if err := rows.Scan(&timezone); err != nil {
return nil, fmt.Errorf(`store: unable to fetch timezones row: %v`, err)
}
if !strings.HasPrefix(timezone, "posix") && !strings.HasPrefix(timezone, "SystemV") && timezone != "localtime" {
timezones[timezone] = timezone
}
}
return timezones, nil
}
+8 -5
View File
@@ -7,12 +7,13 @@ import (
"crypto/rand"
"database/sql"
"fmt"
"time"
"miniflux.app/v2/internal/model"
)
// UserSessions returns the list of sessions for the given user.
func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
func (s *Storage) UserSessions(userID int64) ([]model.UserSession, error) {
query := `
SELECT
id,
@@ -32,7 +33,7 @@ func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
}
defer rows.Close()
var sessions model.UserSessions
var sessions []model.UserSession
for rows.Next() {
var session model.UserSession
err := rows.Scan(
@@ -43,7 +44,6 @@ func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
&session.UserAgent,
&session.IP,
)
if err != nil {
return nil, fmt.Errorf(`store: unable to fetch user session row: %v`, err)
}
@@ -164,14 +164,17 @@ func (s *Storage) RemoveUserSessionByID(userID, sessionID int64) error {
return nil
}
// CleanOldUserSessions removes user sessions older than specified days.
func (s *Storage) CleanOldUserSessions(days int) int64 {
// CleanOldUserSessions removes user sessions older than specified interval (24h minimum).
func (s *Storage) CleanOldUserSessions(interval time.Duration) int64 {
query := `
DELETE FROM
user_sessions
WHERE
created_at < now() - $1::interval
`
days := max(int(interval/(24*time.Hour)), 1)
result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
if err != nil {
return 0
+49 -31
View File
@@ -7,7 +7,6 @@ import (
"bytes"
"embed"
"html/template"
"log/slog"
"time"
"miniflux.app/v2/internal/locale"
@@ -21,9 +20,6 @@ var commonTemplateFiles embed.FS
//go:embed templates/views/*.html
var viewTemplateFiles embed.FS
//go:embed templates/standalone/*.html
var standaloneTemplateFiles embed.FS
// Engine handles the templating system.
type Engine struct {
templates map[string]*template.Template
@@ -38,43 +34,66 @@ func NewEngine(router *mux.Router) *Engine {
}
}
// ParseTemplates parses template files embed into the application.
func (e *Engine) ParseTemplates() error {
func (e *Engine) ParseTemplates() {
funcMap := e.funcMap.Map()
commonTemplates := template.Must(template.New("").Funcs(funcMap).ParseFS(commonTemplateFiles, "templates/common/*.html"))
dirEntries, err := viewTemplateFiles.ReadDir("templates/views")
if err != nil {
return err
templates := map[string][]string{ // this isn't a global variable so that it can be garbage-collected.
"about.html": {"layout.html", "settings_menu.html"},
"add_subscription.html": {"feed_menu.html", "layout.html", "settings_menu.html"},
"api_keys.html": {"layout.html", "settings_menu.html"},
"starred_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
"categories.html": {"layout.html"},
"category_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
"category_feeds.html": {"feed_list.html", "layout.html"},
"choose_subscription.html": {"feed_menu.html", "layout.html"},
"create_api_key.html": {"layout.html", "settings_menu.html"},
"create_category.html": {"layout.html"},
"create_user.html": {"layout.html", "settings_menu.html"},
"edit_category.html": {"layout.html", "settings_menu.html"},
"edit_feed.html": {"layout.html"},
"edit_user.html": {"layout.html", "settings_menu.html"},
"entry.html": {"layout.html"},
"feed_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
"feeds.html": {"feed_list.html", "feed_menu.html", "item_meta.html", "layout.html", "pagination.html"},
"history_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
"import.html": {"feed_menu.html", "layout.html"},
"integrations.html": {"layout.html", "settings_menu.html"},
"login.html": {"layout.html"},
"offline.html": {},
"search.html": {"item_meta.html", "layout.html", "pagination.html"},
"sessions.html": {"layout.html", "settings_menu.html"},
"settings.html": {"layout.html", "settings_menu.html"},
"shared_entries.html": {"layout.html", "pagination.html"},
"tag_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
"unread_entries.html": {"item_meta.html", "layout.html", "pagination.html"},
"users.html": {"layout.html", "settings_menu.html"},
"webauthn_rename.html": {"layout.html"},
}
for _, dirEntry := range dirEntries {
fullName := "templates/views/" + dirEntry.Name()
slog.Debug("Parsing template", slog.String("template_name", fullName))
commonTemplatesClone, err := commonTemplates.Clone()
if err != nil {
panic("Unable to clone the common template")
for name, dependencies := range templates {
tpl := template.New("").Funcs(funcMap)
for _, dependency := range dependencies {
template.Must(tpl.ParseFS(commonTemplateFiles, "templates/common/"+dependency))
}
e.templates[dirEntry.Name()] = template.Must(commonTemplatesClone.ParseFS(viewTemplateFiles, fullName))
e.templates[name] = template.Must(tpl.ParseFS(viewTemplateFiles, "templates/views/"+name))
}
dirEntries, err = standaloneTemplateFiles.ReadDir("templates/standalone")
if err != nil {
return err
// Sanity check to ensure that all templates are correctly declared in `templates`.
if entries, err := viewTemplateFiles.ReadDir("templates/views"); err == nil {
for _, entry := range entries {
if _, ok := e.templates[entry.Name()]; !ok {
panic("Template " + entry.Name() + " isn't declared in ParseTemplates")
}
}
} else {
panic("Unable to read all embedded views templates")
}
for _, dirEntry := range dirEntries {
fullName := "templates/standalone/" + dirEntry.Name()
slog.Debug("Parsing template", slog.String("template_name", fullName))
e.templates[dirEntry.Name()] = template.Must(template.New(dirEntry.Name()).Funcs(funcMap).ParseFS(standaloneTemplateFiles, fullName))
}
return nil
}
// Render process a template.
func (e *Engine) Render(name string, data map[string]any) []byte {
tpl, ok := e.templates[name]
if !ok {
panic("This template does not exists: " + name)
panic("The template " + name + " does not exists.")
}
printer := locale.NewPrinter(data["language"].(string))
@@ -89,8 +108,7 @@ func (e *Engine) Render(name string, data map[string]any) []byte {
})
var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil {
if err := tpl.ExecuteTemplate(&b, "base", data); err != nil {
panic(err)
}
+2 -2
View File
@@ -41,7 +41,7 @@ func (f *funcMap) Map() template.FuncMap {
"baseURL": config.Opts.BaseURL,
"rootURL": config.Opts.RootURL,
"disableLocalAuth": config.Opts.DisableLocalAuth,
"oidcProviderName": config.Opts.OIDCProviderName,
"oidcProviderName": config.Opts.OAuth2OIDCProviderName,
"hasOAuth2Provider": func(provider string) bool {
return config.Opts.OAuth2Provider() == provider
},
@@ -60,7 +60,7 @@ func (f *funcMap) Map() template.FuncMap {
"safeJS": func(str string) template.JS {
return template.JS(str)
},
"noescape": func(str string) template.HTML {
"safeHTML": func(str string) template.HTML {
return template.HTML(str)
},
"proxyFilter": func(data string) string {
@@ -28,13 +28,13 @@
<li class="item-meta-icons-star">
<button
aria-describedby="entry-title-{{ .entry.ID }}"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-toggle-starred="true"
data-star-url="{{ route "toggleStarred" "entryID" .entry.ID }}"
data-label-loading="{{ t "entry.state.saving" }}"
data-label-star="{{ t "entry.bookmark.toggle.on" }}"
data-label-unstar="{{ t "entry.bookmark.toggle.off" }}"
data-label-star="{{ t "entry.starred.toggle.on" }}"
data-label-unstar="{{ t "entry.starred.toggle.off" }}"
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.bookmark.toggle.off" }}{{ else }}{{ t "entry.bookmark.toggle.on" }}{{ end }}</span></button>
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.starred.toggle.off" }}{{ else }}{{ t "entry.starred.toggle.on" }}{{ end }}</span></button>
</li>
{{ if .entry.ShareCode }}
<li class="item-meta-icons-share">
+18 -23
View File
@@ -5,45 +5,42 @@
<meta charset="utf-8">
<title>{{template "title" .}} - Miniflux</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Miniflux">
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
<meta name="robots" content="noindex,nofollow">
<meta name="googlebot" content="notranslate">
<meta name="mobile-web-app-capable" content="yes">
<meta name="referrer" content="no-referrer">
<meta name="google" content="notranslate">
<meta name="robots" content="noindex,nofollow">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="{{ theme_color .theme "light" }}" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
<link rel="icon" type="image/png" sizes="16x16" href="{{ route "appIcon" "filename" "icon-16.png" }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ route "appIcon" "filename" "icon-32.png" }}">
<link rel="icon" type="image/png" sizes="128x128" href="{{ route "appIcon" "filename" "icon-128.png" }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ route "appIcon" "filename" "icon-192.png" }}">
<!-- iOS icons -->
<link rel="apple-touch-icon" sizes="120x120" href="{{ route "appIcon" "filename" "icon-120.png" }}">
<link rel="apple-touch-icon" sizes="152x152" href="{{ route "appIcon" "filename" "icon-152.png" }}">
<link rel="apple-touch-icon" sizes="167x167" href="{{ route "appIcon" "filename" "icon-167.png" }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ route "appIcon" "filename" "icon-180.png" }}">
<meta name="theme-color" content="{{ theme_color .theme "light" }}" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="{{ theme_color .theme "dark" }}" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
{{ 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;">
<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 html url;">
{{ if .user.Stylesheet }}
{{ if .user.Stylesheet -}}
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ end }}
{{ end -}}
{{ if .user.CustomJS }}
{{ 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;">
{{ end }}
{{ 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 html url;">
{{ end -}}
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
</head>
@@ -73,9 +70,7 @@
<a aria-label="{{ t "menu.home_page" }}" href="{{ route .user.DefaultHomePage }}">
Mini<span>flux</span>
</a>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
</svg>
{{ icon "chevron-down"}}
</div>
<ul id="header-menu">
<li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g u" }}">
@@ -181,7 +176,7 @@
<li>{{ t "page.keyboard_shortcuts.toggle_read_status_prev" }} = <strong>M</strong></li>
<li>{{ t "page.keyboard_shortcuts.mark_page_as_read" }} = <strong>A</strong></li>
<li>{{ t "page.keyboard_shortcuts.download_content" }} = <strong>d</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_bookmark_status" }} = <strong>f</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_star_status" }} = <strong>f</strong></li>
<li>{{ t "page.keyboard_shortcuts.save_article" }} = <strong>s</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_entry_attachments" }} = <strong>a</strong></li>
<li>{{ t "page.keyboard_shortcuts.scroll_item_to_top" }} = <strong>z + t</strong></li>
@@ -18,6 +18,8 @@
</div>
</div>
<a href="#" class="elevator">{{ icon "up" }}{{ t "page.footer.elevator" }}</a>
<div class="pagination-forward">
<div class="pagination-next {{ if not .ShowNext }}disabled{{end}}">
{{ if .ShowNext }}
@@ -82,6 +82,10 @@
</article>
{{ end }}
</div>
<footer>
<a href="#" class="elevator">{{ icon "up" }}{{ t "page.footer.elevator" }}</a>
</footer>
{{ end }}
{{ end }}
+11 -9
View File
@@ -10,6 +10,8 @@
{{ end }}
</div>
<a href="#" class="elevator">{{ icon "up" }}{{ t "page.footer.elevator" }}</a>
<div class="pagination-next {{ if not .nextEntry }}disabled{{end}}">
{{ if .nextEntry }}
<a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
@@ -64,15 +66,15 @@
<li>
<button
class="page-button"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-toggle-starred="true"
data-star-url="{{ route "toggleStarred" "entryID" .entry.ID }}"
data-label-loading="{{ t "entry.state.saving" }}"
data-label-star="{{ t "entry.bookmark.toggle.on" }}"
data-label-unstar="{{ t "entry.bookmark.toggle.off" }}"
data-toast-star="{{ t "entry.bookmark.toast.on" }}"
data-toast-unstar="{{ t "entry.bookmark.toast.off" }}"
data-label-star="{{ t "entry.starred.toggle.on" }}"
data-label-unstar="{{ t "entry.starred.toggle.off" }}"
data-toast-star="{{ t "entry.starred.toast.on" }}"
data-toast-unstar="{{ t "entry.starred.toast.off" }}"
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.bookmark.toggle.off" }}{{ else }}{{ t "entry.bookmark.toggle.on" }}{{ end }}</span></button>
>{{ if .entry.Starred }}{{ icon "unstar" }}{{ else }}{{ icon "star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.starred.toggle.off" }}{{ else }}{{ t "entry.starred.toggle.on" }}{{ end }}</span></button>
</li>
{{ if .hasSaveEntry }}
<li>
@@ -280,9 +282,9 @@
{{ end }}
{{ if .user }}
{{ noescape (proxyFilter .entry.Content) }}
{{ safeHTML (proxyFilter .entry.Content) }}
{{ else }}
{{ noescape .entry.Content }}
{{ safeHTML .entry.Content }}
{{ end }}
</article>
{{ if .entry.Enclosures }}
@@ -228,6 +228,37 @@
</div>
</details>
<details {{ if .form.LinktacoEnabled }}open{{ end }}>
<summary>LinkTaco</summary>
<div class="form-section">
<label>
<input type="checkbox" name="linktaco_enabled" value="1" {{ if .form.LinktacoEnabled }}checked{{ end }}> {{ t "form.integration.linktaco_activate" }}
</label>
<label for="form-linktaco-api-token">{{ t "form.integration.linktaco_api_token" }}</label>
<input type="password" name="linktaco_api_token" id="form-linktaco-api-token" value="{{ .form.LinktacoAPIToken }}" spellcheck="false">
<p class="hint">{{ t "form.integration.linktaco_api_token_hint" }} <a href="https://linktaco.com/oauth2/personal" target="_blank" rel="noopener noreferrer">https://linktaco.com/oauth2/personal</a></p>
<label for="form-linktaco-org-slug">{{ t "form.integration.linktaco_org_slug" }}</label>
<input type="text" name="linktaco_org_slug" id="form-linktaco-org-slug" value="{{ .form.LinktacoOrgSlug }}" placeholder="my-organization" spellcheck="false">
<label for="form-linktaco-tags">{{ t "form.integration.linktaco_tags" }}</label>
<input type="text" name="linktaco_tags" id="form-linktaco-tags" value="{{ .form.LinktacoTags }}" placeholder="miniflux, bookmarks" spellcheck="false">
<p class="hint">{{ t "form.integration.linktaco_tags_hint" }}</p>
<label for="form-linktaco-visibility">{{ t "form.integration.linktaco_visibility" }}</label>
<select name="linktaco_visibility" id="form-linktaco-visibility">
<option value="PUBLIC" {{ if eq .form.LinktacoVisibility "PUBLIC" }}selected{{ end }}>{{ t "form.integration.linktaco_visibility_public" }}</option>
<option value="PRIVATE" {{ if eq .form.LinktacoVisibility "PRIVATE" }}selected{{ end }}>{{ t "form.integration.linktaco_visibility_private" }}</option>
</select>
<p class="hint">{{ t "form.integration.linktaco_visibility_hint" }}</p>
<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.LinkwardenEnabled }}open{{ end }}>
<summary>Linkwarden</summary>
<div class="form-section">
@@ -633,6 +664,9 @@
<label for="form-wallabag-password">{{ t "form.integration.wallabag_password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}" autocomplete="new-password">
<label for="form-wallabag-tags">{{ t "form.integration.wallabag_tags" }}</label>
<input type="text" name="wallabag_tags" id="form-wallabag-tags" value="{{ .form.WallabagTags }}" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>

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