Compare commits

...

294 Commits

Author SHA1 Message Date
Ankit Pandey b68b05c64c reader/processor: error out for improper rewrite regexp
It's possible to specify a rewrite regex that validates but doesn't compile such
as:

    rewrite("(((unmatched-capture-group"|"rewrite)))")

In case we encounter one, exit early instead of letting the server panic.
2024-06-01 10:37:02 -07:00
Frédéric Guillot 5ce3f24838 googelreader: set CrawlTimeMsec at the correct precision
Fixes #2669

Fixes #2670
2024-05-29 21:54:02 -07:00
dependabot[bot] 48ddc02ba8 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.30 to 2.20.32
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.30 to 2.20.32.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.30...v2.20.32)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 15:46:53 -07:00
dependabot[bot] fe9f1bba16 build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.19 to 3.20.

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 15:44:57 -07:00
Krish Mamtora 740fa4a5d2 Add missing properties when reloading page after error 2024-05-27 15:37:53 -07:00
dependabot[bot] 8a38f54ef5 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.25 to 2.20.30
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.25 to 2.20.30.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.25...v2.20.30)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-22 19:16:11 -07:00
Zhizhen He ae432bc9c6
reader/readingtime: fix incorrect package name 2024-05-21 18:12:24 -07:00
dependabot[bot] 96f7e8bae0 ---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-21 17:48:00 -07:00
rootknight 1f35ed1675
ui: add `viewport-fit=cover` 2024-05-19 10:39:34 -07:00
dependabot[bot] d6deac1810 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.21 to 2.20.24
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.21 to 2.20.24.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.21...v2.20.24)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-18 08:39:43 -07:00
Frédéric Guillot b692768730 packaging: fix failed to solve: arm64v8/golang:1.22-bookworm 2024-05-17 21:07:40 -07:00
dependabot[bot] 2178580a75 build(deps): bump golangci/golangci-lint-action from 5 to 6
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 5 to 6.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:59:04 -07:00
dependabot[bot] b52f61cc77 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.20 to 2.20.21
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.20 to 2.20.21.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.20...v2.20.21)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:58:41 -07:00
dependabot[bot] 3388f8e376 Bump github.com/prometheus/client_golang from 1.19.0 to 1.19.1
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.0 to 1.19.1.
- [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.19.0...v1.19.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-09 21:37:53 -07:00
dependabot[bot] 83ceb20c1c Bump golang.org/x/net from 0.24.0 to 0.25.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/net/compare/v0.24.0...v0.25.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:45:02 -07:00
dependabot[bot] c06850ca34 Bump golang.org/x/oauth2 from 0.19.0 to 0.20.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.19.0...v0.20.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:22:00 -07:00
dependabot[bot] d856c02fbb Bump golang.org/x/crypto from 0.22.0 to 0.23.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/crypto/compare/v0.22.0...v0.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 15:42:12 -07:00
Jan-Lukas Else a33b1adf13 Add description field to feed settings
This adds a new "description" field to the feed settings. This allows to
save custom description regarding a feed. It is also exported and
imported as "description" in OPML.
2024-05-06 15:40:36 -07:00
fin444 a631bd527d options: add FETCH_NEBULA_WATCH_TIME 2024-05-02 16:30:01 -07:00
Alpha Chen ca62b0b36b integration/raindrop: initial draft implementation 2024-05-02 16:23:00 -07:00
Kioubit 7d6a4243c1 Make cookie duration dependent on configuration
This ensures that session cookies are not expiring before the session is cleaned up from the database as per CLEANUP_REMOVE_SESSIONS_DAYS.
As of now the usefulness of this configuration option is diminished as extending it has no effect on the actual browser session due to the cookie expiry.
Fixes: #2214
2024-05-01 19:34:13 -07:00
dependabot[bot] d056aa1f73 Bump github.com/PuerkitoBio/goquery from 1.9.1 to 1.9.2
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.9.1 to 1.9.2.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.9.1...v1.9.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-30 17:23:35 -07:00
dependabot[bot] 018e24404e Bump golangci/golangci-lint-action from 4 to 5
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 4 to 5.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-29 16:51:20 -07:00
Frédéric Guillot 4d3ee0d15d ci: fix docker workflow to add distroless suffix on latest tag 2024-04-27 15:26:16 -07:00
Frédéric Guillot 797450986b Update ChangeLog 2024-04-27 15:06:28 -07:00
Ztec 93bc9ce24d add seek and speed controls to media player
When listening to podcast, it is usual to want to speed up the playback.
https://github.com/miniflux/v2/pull/2521 was addressing the need globally, this PR
allow to address it for just the current open enclosure media. (no save) Some Browser
already include this control directly, but firefox does not (directly anyway).

Also, it is often useful to be able to skip chunk of a podcast, to skip commercials
for example, or get back a bit because we couldn't hear the last part. I added rudimentary
seek controls with the usual +/-10 and 30 seconds chuck size. This is pretty handy when podcast
are very long and using the seek bar is way too tricky to just skip 30s.

As always, I'm French and could only provide English and French translation for the few
text I added in the locale/translations files. Any help is welcome.

Tested mostly on Firefox (121.0) and quickly on Vivaldi(6.5.3206.53), chrome based.

Fixes: #1845 #1846
2024-04-26 13:44:26 -07:00
dependabot[bot] 9233568da3 Bump github.com/tdewolff/minify/v2 from 2.20.19 to 2.20.20
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.19 to 2.20.20.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.19...v2.20.20)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 19:09:18 -07:00
Frédéric Guillot fb075b60b5 reader/processor: minifier is breaking HTML entry content 2024-04-23 20:31:52 -07:00
Frédéric Guillot 2c4c845cd2 http/response: add brotli compression support 2024-04-19 12:16:49 -07:00
bo0tzz 2caabbe939 fix: Use `FORCE_REFRESH_INTERVAL` config for category refresh 2024-04-19 11:58:13 -07:00
Frédéric Guillot 771f9d2b5f reader/fetcher: add brotli content encoding support 2024-04-19 10:50:46 -07:00
Romain de Laage 647c66e70a ui: add tag entries page 2024-04-14 20:08:38 -07:00
jvoisin b205b5aad0 reader/processor: minimize the feed's entries html
Compress the html of feed entries before storing it. This should reduce the
size of the database a bit, but more importantly, reduce the amount of data
sent to clients

minify being [stupidly fast](https://github.com/tdewolff/minify/?tab=readme-ov-file#performance), the performance impact should be in the noise level.
2024-04-10 19:48:48 -07:00
goodfirm 4ab0d9422d chore: fix function name in comment
Signed-off-by: goodfirm <fanyishang@yeah.net>
2024-04-10 19:36:30 -07:00
Frédéric Guillot 38b80d96ea storage: change GetReadTime() function to use entries_feed_id_hash_key index 2024-04-09 20:37:30 -07:00
Michael Kuhn 35edd8ea92 Fix clicking unread counter
When clicking the unread counter, the following exception occurs:
```
Uncaught TypeError: Cannot read properties of null (reading 'getAttribute')
```

This is due to `onClickMainMenuListItem` not working correctly for the
unread counter `span`s, which return `null` when using `querySelector`.
2024-04-09 20:36:42 -07:00
Alexandros Kosiaris f0cb041885 Add back removed other repo owners in GH docker actions
In cf96ab45c1, support was added for using Docker related Github
actions in repositories of other owners. This was pretty helpful as it
allowed running modified forks off of main in a nightly fashion before
patches were pushed upstream. This was 6e870cdccc, add it
back
2024-04-06 11:31:29 -07:00
Frédéric Guillot fdd1b3f18e database: entry URLs can exceeds btree index size limit 2024-04-04 20:22:23 -07:00
Frédéric Guillot 6e870cdccc ci: use docker/metadata-action instead of deprecated shell-scripts 2024-04-04 18:04:32 -07:00
Michael Kuhn 194f517be8 Improve Dockerfiles
- Specify Docker registry explicitly (e.g., Podman does not use
  `docker.io` by default)
- Use `make miniflux` instead of duplicating `go build` arguments (this
  leverages Go's PIE build mode)
- Enable cgo to fix ARM containers (we need to make sure to use the same
  OS version for both container stages to avoid libc issues)
2024-04-04 17:36:28 -07:00
dependabot[bot] 11fd1c935e Bump golang.org/x/net from 0.23.0 to 0.24.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/net/compare/v0.23.0...v0.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-04 16:37:41 -07:00
dependabot[bot] 47e1111908 Bump golang.org/x/term from 0.18.0 to 0.19.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/term/compare/v0.18.0...v0.19.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-04 16:08:16 -07:00
dependabot[bot] c5b812eb7b Bump golang.org/x/oauth2 from 0.18.0 to 0.19.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.18.0...v0.19.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-04 15:46:40 -07:00
dependabot[bot] 53be550e8a Bump github.com/yuin/goldmark from 1.7.0 to 1.7.1
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.0...v1.7.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 17:30:47 -07:00
dependabot[bot] d0d693a6ef Bump golang.org/x/net from 0.22.0 to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.22.0...v0.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 17:30:26 -07:00
Evan Elias Young 1b8c45d162 finder: Find feed from YouTube playlist
The feed from a YouTube playlist page is derived in practically the same way as a feed from a YouTube channel page.
2024-04-01 21:16:32 -07:00
jvoisin 19ce519836 reader/rewrite: add a rule for oglaf.com
By default, Oglaf show some disclaimer/warning about its content, and this
doesn't play well with rss readers, so let's rewrite it to show the actual
comic instead of a placeholder.
2024-04-01 21:05:01 -07:00
Thomas J Faughnan Jr 3e0d5de7a3 api tests: use intSize-agnostic random integers
rand.Intn(math.MaxInt64) causes tests to fail on 32-bit architectures.
Use the simpler rand.Int() instead, which still provides plenty of room
for generating pseudo-random test usernames.
2024-04-01 21:02:48 -07:00
Frédéric Guillot 0336774e8c Update ChangeLog 2024-03-30 14:39:41 -07:00
Jean Khawand 756dd449cc
integration/webhook: add category title to request body 2024-03-29 16:37:05 -07:00
Taylan Tatlı a0b4665080 Turkish Translation Update 2024-03-28 19:09:24 -07:00
dependabot[bot] 6592c1ad6b Bump dominikh/staticcheck-action from 1.3.0 to 1.3.1
Bumps [dominikh/staticcheck-action](https://github.com/dominikh/staticcheck-action) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/dominikh/staticcheck-action/releases)
- [Changelog](https://github.com/dominikh/staticcheck-action/blob/master/CHANGES.md)
- [Commits](https://github.com/dominikh/staticcheck-action/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: dominikh/staticcheck-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 17:02:21 -07:00
jvoisin f109e3207c reader/rss: don't add empty tags to RSS items
This commit adds a bunch of checks to prevent reader/rss from adding empty tags
to rss items, as well as some minor refactors like nested conditions and loops
unrolling.
2024-03-24 19:46:56 -07:00
Romain de Laage b54fe66809 fix: do not store empty tags 2024-03-24 14:50:18 -07:00
jvoisin 93c9d43497 http/response: get rid of the X-XSS-Protection header
It's useless at best, dangerous at worst, and shouldn't be used anymore
anywhere. See the following resources for details:

- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
- https://chromestatus.com/feature/5021976655560704
- https://bugzilla.mozilla.org/show_bug.cgi?id=528661
- https://blogs.windows.com/windows-insider/2018/07/25/announcing-windows-10-insider-preview-build-17723-and-build-18204/
2024-03-24 13:45:38 -07:00
Frédéric Guillot e3b3c40c28 timezone: make sure the tests pass when the timezone database is not installed on the host 2024-03-24 13:25:02 -07:00
Frédéric Guillot 068790fc19 integration: fix rssbrige import 2024-03-24 12:42:29 -07:00
Frédéric Guillot 41d99c517f Update GitHub PR template 2024-03-23 14:34:03 -07:00
Frédéric Guillot 3db3f9884f cli: avoid misleading error message when creating an admin user 2024-03-23 14:32:55 -07:00
Frédéric Guillot ad1d349a0c rss: use Channel tags only if there is no Item tags 2024-03-23 13:46:48 -07:00
Jean Khawand 7ee4a731af Update miniflux.1 2024-03-21 19:59:02 -07:00
Jean Khawand 3c822a45ac Update miniflux.1
#2187  #2543
2024-03-21 19:59:02 -07:00
Frédéric Guillot c2311e316c Rename PROXY_* options to MEDIA_PROXY_* 2024-03-20 21:28:28 -07:00
jvoisin ed20771194 Enable trusted-types
This commit adds a policy, and make use of it in the Content-Security-Policy.

I've tested it the best I could, both on a modern browser supporting
trusted-types (Chrome) and on one that doesn't (firefox).

Thanks to @lweichselbaum for giving me a hand to wrap this up!
2024-03-20 17:50:37 -07:00
jvoisin beb8c80787 Replace a bunch of `let` with `const`
According to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const

> Many style guides (including MDN's) recommend using const over let whenever a
variable is not reassigned in its scope. This makes the intent clear that a
variable's type (or value, in the case of a primitive) can never change.
2024-03-20 17:36:01 -07:00
jvoisin fc4bdf3ab0 Inline a one-liner function
No need to expose a symbol for this.
2024-03-20 17:21:30 -07:00
Frédéric Guillot 6bc819e198 man page: sort config options in alphabetical order 2024-03-19 22:22:24 -07:00
Frédéric Guillot 08640b27d5 Ensure enclosure URLs are always absolute 2024-03-19 21:57:46 -07:00
jvoisin 4be993e055 Minor refactoring of internal/reader/atom/atom_10_adapter.go
- Move the population of the feed's entries into a new function, to make
  `BuildFeed` easier to understand/separate concerns/implementation details
- Use `sort+compact` instead of `compact+sort` to remove duplicates
- Change `if !a { a = } if !a {a = }` constructs into `if !a { a = ; if !a {a = }}`.
  This reduce the number of comparisons, but also improves a tad the
  control-flow readability.
2024-03-19 20:41:44 -07:00
jvoisin 9df12177eb Minor idiomatic pass on internal/http/request/context.go 2024-03-19 20:21:23 -07:00
Jean Khawand a78d1c79da
Add `FILTER_ENTRY_MAX_AGE_DAYS` config option to limit fetching all feed items 2024-03-20 02:58:53 +00:00
Matt Behrens 1ea3953271
Add keyboard shortcuts for scrolling to top/bottom of the item list 2024-03-19 19:30:38 -07:00
dependabot[bot] fe8b7a907e Bump github.com/coreos/go-oidc/v3 from 3.9.0 to 3.10.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.9.0...v3.10.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-19 19:09:08 -07:00
Frédéric Guillot a15cdb1655 Fix regression in AbsoluteProxifyURL()
Regression introduced in commit 66b8483791

PR #2499
2024-03-18 20:48:20 -07:00
Frédéric Guillot fa9697b972 Remove trailing space in SiteURL and FeedURL 2024-03-18 17:51:06 -07:00
jvoisin 8e28e41b02 Use struct embedding to reduce code duplication 2024-03-18 16:23:44 -07:00
jvoisin e2ee74428a Minor concatenation-related simplifications in internal/storage/
Use plain strings concatenation instead of
building an array and then joining it.
2024-03-18 16:20:55 -07:00
jvoisin 863a5b3648 Simplify removeDuplicates
Use a sort+compact construct instead of doing it by hand with a hashmap. The
time complexity is now O(nlogn+n) instead of O(n), and space complexity around
O(logn) instead of O(n+uniq(n)), but it shouldn't matter anyway, since
removeDuplicates is only called to deduplicate tags.
2024-03-18 16:13:32 -07:00
jvoisin 91f5522ce0 Minor simplification of internal/reader/media/media.go
- Simplify a switch-case by moving a common condition above it.
- Remove a superfluous error-check: `strconv.ParseInt` returns `0` when passed
  an empty string.
2024-03-18 16:09:32 -07:00
Frédéric Guillot 8212f16aa2 atom: avoid debug message when the date is empty 2024-03-17 15:29:50 -07:00
Frédéric Guillot b1e73fafdf Enable go-critic linter and fix various issues detected 2024-03-17 13:52:34 -07:00
Frédéric Guillot f6404290ba Replace Optional{Int,Int64,Float64} with a generic function OptionalNumber() 2024-03-17 12:25:55 -07:00
jvoisin c29ca0e313 Minor simplifications of the rewriter
- Online some one-line functions
- Transform a free-standing function into a method
- Massively simplify `removeClickbait`
- Use a proper constant instead of a magic number in `applyFuncOnTextContent`
2024-03-17 12:15:46 -07:00
jvoisin 02a074ed26 Compile block/keep regex only once per feed
No need to compile them once for matching on the url,
once per tag, once per title, once per author, … one time is enough.
It also simplify error handling, since while regexp compilation can fail,
matching can't.
2024-03-17 12:08:03 -07:00
Romain de Laage 00dabc1d3c feat: Media player: Conrol playback speed
fix  #1845
2024-03-17 11:53:30 -07:00
Frédéric Guillot b68ada396a Rewrite API integration tests without build tags 2024-03-16 21:29:07 -07:00
Frédéric Guillot e299e821a6 Update GitHub PR template 2024-03-15 20:59:17 -07:00
Frédéric Guillot 0f17dfc7d6 Fix regressions introduced by PR #2476
'Toast' messages are broken and v hotkey opens in the same tab

Commit d25c032171
2024-03-15 20:55:32 -07:00
Frédéric Guillot 7c80d6b86d Fix download button loading label 2024-03-15 20:40:14 -07:00
Frédéric Guillot f6f63b5282 Avoid warnings in ui package
Remove unused variables and improve JSON decoding in
saveEnclosureProgression()
2024-03-15 19:49:39 -07:00
Frédéric Guillot 309fdbb9fc Fix force refresh 2024-03-15 19:42:09 -07:00
Frédéric Guillot e2d862f2f6 Display an error message on edit feed page when the feed URL is not unique 2024-03-15 19:07:54 -07:00
Frédéric Guillot 4834e934f2 Remove some duplicated code in RSS parser 2024-03-15 18:40:06 -07:00
Frédéric Guillot dd4fb660c1 Refactor Atom parser to use an adapter 2024-03-15 17:27:16 -07:00
jvoisin 2ba893bc79 Bump the number of simultaneous workers
We're in 2024, I'm pretty sure we can afford to have 16 simultaneous open http
connections at the same time, instead of only 5.
2024-03-15 14:05:58 -07:00
Frédéric Guillot 7a307f8e74 Fix regression: Add to Home Screen button is unreadable
Regression introduced in commit ea58bac548
2024-03-14 17:37:50 -07:00
jvoisin 7310e13499 More trusted-types compatibility 2024-03-14 17:10:40 -07:00
dependabot[bot] bf6d286735 Bump github.com/go-webauthn/webauthn from 0.10.1 to 0.10.2
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.10.1 to 0.10.2.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.10.1...v0.10.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-14 17:03:54 -07:00
Frédéric Guillot ca919c2ff8 Fix JavaScript error on the login page 2024-03-13 21:47:23 -07:00
Frédéric Guillot 5948786b15 Add support for RSS <media:category> element 2024-03-13 21:35:39 -07:00
jvoisin f4746a7306 Fix and simplify shaarli's integration
- The jwt token was declared as using HS256 as algorithm, but was using HS512.
- No need to base64-encode then remove the padding when we can simply encode
  without padding.
- Factorize the header+payload concatenation as data

Odds are that this integration was broken from the start (HS512 vs HS256), so
I'm not sure if it's better to add tests or to simply get rid of it.
2024-03-13 21:34:57 -07:00
Frédéric Guillot 648b9a8f6f Refactor RSS Parser to use an adapter 2024-03-13 21:25:09 -07:00
jvoisin 66b8483791 Minor simplification of internal/proxy/proxy.go
- re-use ProxifiedUrl to implement AbsoluteProxifyURL, reducing the copy-pasta
- reduce the internal indentation of ProxifiedUrl by inverting some conditions
2024-03-13 19:42:01 -07:00
jvoisin e0ee28c013 More progress towards trusted-types
Create a new function `addIcon` and use it to add icons, instead of
operating on raw html.
2024-03-13 19:35:20 -07:00
dependabot[bot] d862d86f90 Bump google.golang.org/protobuf from 1.32.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-13 18:31:50 -07:00
jvoisin d25c032171 Simplify bootstrap.js
- Don't use lambdas to return a function, use directly the function instead.
- Remove a hack for "Chrome 67 and earlier" since it was released in 2018.
2024-03-13 18:26:27 -07:00
Frédéric Guillot 8429c6b0ab Refactor JSON Feed parser to use an adapter 2024-03-12 22:37:14 -07:00
Frédéric Guillot 6bc4b35e38 Refactor RDF parser to use an adapter
Avoid tight coupling between `model.Feed` and the original XML RDF feed.
2024-03-12 20:54:05 -07:00
mcnesium ee3486af66 align min-width with the other min-width values 2024-03-12 18:58:30 -07:00
jvoisin 45d486b919 When detecting the format, detect its version as well
There is no need to detect the format and then the version when both can be
done at the same time.

Add a benchmark as well, on large and small atom and rss files.
2024-03-12 18:56:56 -07:00
dependabot[bot] 688b73b7ae Bump github.com/tdewolff/minify/v2 from 2.20.18 to 2.20.19
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.18 to 2.20.19.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.18...v2.20.19)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-12 17:38:27 -07:00
Frédéric Guillot 6d97f8b458 Parse podcast categories 2024-03-11 22:30:27 -07:00
Frédéric Guillot f8e50947f2 Move iTunes and GooglePlay XML definitions to their own packages 2024-03-11 22:09:31 -07:00
Frédéric Guillot 9a637ce95e Refactor RSS parser to use default namespace
This change avoid some limitations of the Go XML parser regarding XML namespaces
2024-03-11 21:07:13 -07:00
Frédéric Guillot d3a85b049b jsminifier: set JavaScript version 2024-03-11 19:02:52 -07:00
jvoisin 5bcb37901c Use crypto.GenerateRandomBytes instead of doing it by hand
This makes the code a bit shorter, and properly handle
cryptographic error conditions.
2024-03-11 16:31:43 -07:00
jvoisin 9c8a7dfffe Make use of HashFromBytes everywhere
It feels a bit silly to have a function and to not make use of it.
2024-03-11 15:22:22 -07:00
jvoisin 74e4032ffc Small refactor of app.js
- replace a lot of `let` with `const`
- inline some `querySelectorAll` calls
- reduce the scope of some variables
- use some ternaries where it makes sense
- inline one-line functions
2024-03-11 15:18:57 -07:00
jvoisin fd1fee852c Simplify DomHelper.getVisibleElements
Use a `filter` instead of a loop with an index.
2024-03-11 15:03:00 -07:00
Frédéric Guillot c51a3270da GitHub Actions: Add basic ESLinter checks 2024-03-10 20:57:27 -07:00
Frédéric Guillot 45fa641d26 Fix JavaScript linter path in GitHub Actions 2024-03-10 20:37:18 -07:00
jvoisin fd8f25916b First steps towards trusted-types support
Refactor away some trival usages of `.innerHTML`. Unfortunately, there is no way to
enabled trusted-types in report-only mode via `<meta>` tags, see
https://github.com/w3c/webappsec-csp/issues/277
2024-03-10 20:14:30 -07:00
jvoisin 826e4d654f Replace DomHelper.findParent with .closest
See https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
2024-03-10 20:06:54 -07:00
jvoisin d9d17f0d69 Use a `Set` instead of an array in a KeyboardHandler's member
The variable `triggers` is only used to check if in contains a particular
value. Given that the number of keyboard shortcuts is starting to be
significant, let's future-proof the performances and use a `Set` instead of an
`Array` instead.
2024-03-10 19:41:13 -07:00
Frédéric Guillot eaaeb68474 Fix conditions to publish packages in GitHub workflows 2024-03-10 12:25:13 -07:00
Frédéric Guillot 382885f144 Update changeLog 2024-03-10 10:50:47 -07:00
dependabot[bot] 0f7b047b0a Bump github.com/go-jose/go-jose/v3 from 3.0.1 to 3.0.3
Bumps [github.com/go-jose/go-jose/v3](https://github.com/go-jose/go-jose) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/v3.0.3/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v3.0.1...v3.0.3)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-07 20:59:42 -08:00
jvoisin a074773e6c Use an io.ReadSeeker instead of an io.Reader to parse feeds
This will allow to make use of func (*Reader) Seek, instead of re-recreating a
new reader. It's a large commit for a small change, but anything to simply the
reader/buffer/ReadAll/… mess is a step in the right direction I think, and it
should enable more follow-up simplifications.
2024-03-06 20:13:39 -08:00
jvoisin 3d0126be0b Speed the sanitizer up a bit, again
- allow youtube urls to start with `www`
- use `strings.Builder` instead of a `bytes.Buffer`
- use a `strings.NewReader` instead of a `bytes.NewBufferString`
- sprinkles a couple of `continue` to make the code-flow more obvious
- inline calls to `inList`, and put their parameters in the right order
- simplify isPixelTracker
- simplify `isValidIframeSource`, by extracting the hostname and comparing it
  directly, instead of using the full url and checking if it starts with
  multiple variations of the same one (`//`, `http:`, `https://` multiplied by
  ``/`www.`)
- add a benchmark
2024-03-05 19:31:50 -08:00
dependabot[bot] eda2e2f3f5 Bump golang.org/x/oauth2 from 0.17.0 to 0.18.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.17.0 to 0.18.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.17.0...v0.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-05 15:39:07 -08:00
jvoisin 111e3f2106 Reuse a Reader instead of copying to a buffer when parsing an atom feed 2024-03-04 17:36:10 -08:00
dependabot[bot] c1ec77a42c Bump golang.org/x/net from 0.21.0 to 0.22.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.21.0 to 0.22.0.
- [Commits](https://github.com/golang/net/compare/v0.21.0...v0.22.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-04 16:48:02 -08:00
jvoisin 3339d9d3d7 Preallocate memory when exporting to OPML
This should marginally increase performance when export a large amount of feeds
to OPML.
2024-03-03 20:34:37 -08:00
jvoisin 8d80e9103f Delay call of `view.New` after logging the user in
There is no need to do extra work like creating a session and its associated
view until the user has been properly identified and as many possibly-failing sql request have been successfully run.
2024-03-03 20:32:15 -08:00
jvoisin d55b410800 Use constant-time comparison for anti-csrf tokens
This is probably completely overkill, but since anti-csrf tokens are secrets,
they should be compared against untrusted inputs in constant time.
2024-03-03 20:28:13 -08:00
jvoisin 9fe99ce7fa Simplify and optimize genericProxyRewriter
- Reduce the amount of nested loops: it's preferable to search the whole page
  once and filter on it (even with filters that should always be false),
  than searching it again for every element we're looking for.
- Factorize the proxying conditions into a `shouldProxy` function to reduce the
  copy-pasta.
2024-03-03 20:25:47 -08:00
Thiago Perrotta b8df6c31a0 sort integrations alphabetically 2024-03-03 20:19:42 -08:00
Frédéric Guillot abdd5876a1 Move search form to a dedicated page 2024-03-01 16:56:15 -08:00
Frédéric Guillot 1b5edfc00a Add unit test to ensure each translation has the correct number of plurals 2024-02-29 20:44:08 -08:00
jvoisin 347740dce1 Speed up removeUnlikelyCandidates
`.Not` returns a brand new Selection, copied element by element.
2024-02-29 19:38:43 -08:00
jvoisin ab85d4d678 Improve EstimateReadingTime's speed by a factor 7
- Refactorise the tests and add some
- Use 250 signs instead of the whole text
- Only check for Korean, Chinese and Japanese script
- Add a benchmark
- Use a more idiomatic control flow

```console
$ # main branch
$ go test -bench=.
goos: linux
goarch: amd64
pkg: miniflux.app/v2/internal/reader/readingtime
BenchmarkEstimateReadingTime-12              267           4821268 ns/op
PASS
ok      miniflux.app/v2/internal/reader/readingtime     1.754s
$ # speed_up_reading_time branch
$ go test -bench=.
goos: linux
goarch: amd64
pkg: miniflux.app/v2/internal/reader/readingtime
cpu: 12th Gen Intel(R) Core(TM) i7-1265U
BenchmarkEstimateReadingTime-12             1941            653312 ns/op
PASS
ok      miniflux.app/v2/internal/reader/readingtime     1.342s
$
```
2024-02-29 19:24:15 -08:00
jvoisin 31ac62f410 Don't compute reading-time when unused
If the user doesn't display reading times, there is no need to compute them.
This should speed things up a bit, since `whatlanggo.Detect` is abysmally slow.
2024-02-29 19:14:17 -08:00
Frédéric Guillot 97765b93a9 Revert "Minor internal/reader/readability/readability.go speedup"
This reverts commit 4db138d4b8.

```
panic: runtime error: index out of range [-1]

goroutine 49 [running]:
miniflux.app/v2/internal/reader/readability.getArticle.func1(0x8?, 0xc000b56570)
        /home/fred/repos/miniflux/v2/internal/reader/readability/readability.go:120 +0x2ac
github.com/PuerkitoBio/goquery.(*Selection).Each(0xc000b56510, 0xc000892fa8)
        /home/fred/go/pkg/mod/github.com/!puerkito!bio/goquery@v1.9.0/iteration.go:10 +0x62
miniflux.app/v2/internal/reader/readability.getArticle(0xc00044f1f0, 0xc000a04a50)
        /home/fred/repos/miniflux/v2/internal/reader/readability/readability.go:101 +0x15d
miniflux.app/v2/internal/reader/readability.ExtractContent({0x1005d00?, 0xc0001522d0?})
        /home/fred/repos/miniflux/v2/internal/reader/readability/readability.go:91 +0x211
miniflux.app/v2/internal/reader/scraper.ScrapeWebsite(0xc000893688?, {0xc0007ce720, 0x54}, {0x0, 0x0})
        /home/fred/repos/miniflux/v2/internal/reader/scraper/scraper.go:63 +0x859
miniflux.app/v2/internal/reader/processor.ProcessFeedEntries(0xc000133188, 0xc000502c40, 0xc0003e6360, 0x0)
        /home/fred/repos/miniflux/v2/internal/reader/processor/processor.go:77 +0x8ea
miniflux.app/v2/internal/reader/handler.RefreshFeed(0xc000133188, 0x10cf, 0x52d5c, 0x0)
        /home/fred/repos/miniflux/v2/internal/reader/handler/handler.go:301 +0x1485
miniflux.app/v2/internal/cli.refreshFeeds.func1(0x0)
        /home/fred/repos/miniflux/v2/internal/cli/refresh_feeds.go:59 +0x2d7
created by miniflux.app/v2/internal/cli.refreshFeeds in goroutine 1
        /home/fred/repos/miniflux/v2/internal/cli/refresh_feeds.go:50 +0x5d5
```
2024-02-29 19:06:03 -08:00
dependabot[bot] f858ad5f26 Bump github.com/PuerkitoBio/goquery from 1.9.0 to 1.9.1
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.9.0 to 1.9.1.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.9.0...v1.9.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 18:36:57 -08:00
jvoisin e6524f925f Simplify username generation for the tests
No need to generate random numbers 10 times, generate a single big-enough one.
A single int64 should be more than enough
2024-02-29 18:36:34 -08:00
Frédéric Guillot c493f8921e Add missing regex anchor detected by CodeQL 2024-02-28 20:50:17 -08:00
Frédéric Guillot b2ce98da87 Add missing plurals for some languages 2024-02-28 20:38:10 -08:00
jvoisin 4db138d4b8 Minor internal/reader/readability/readability.go speedup
- Don't use a capturing group in `divToPElementsRegexp`
- Remove a duplicate condition
- Replace a regex with a fixed-comparison and a `Contains`
2024-02-28 20:03:14 -08:00
jvoisin f12d5131b0 Divide the sanitization time by 3
Instead of having to allocate a ~100 keys map containing possibly dynamic
values (at least to the go compiler), allocate it once in a global variable.
This significantly speeds things up, by reducing the garbage
collector/allocator involvements.

Local synthetic benchmarks have shown a improvements from 38% of wall time to only
12%.
2024-02-28 20:00:13 -08:00
jvoisin 1f5c8ce353 Don't mix up capacity and length
- `make([]a, b)` create a slice of `b` elements `a`
- `make([]a, b, c)` create a slice of `0` elements `a`, but reserve space for `c` of them

When using `append` on the former, it will result on a slice with `b` leading
elements, which is unlikely to be what we want. This commit replaces the two
instances where this happens with the latter construct.
2024-02-28 19:57:30 -08:00
jvoisin 645a817685 Use modern for loops
Go 1.22 introduced a new [for-range](https://go.dev/ref/spec#For_range)
construct that looks a tad better than the usual `for i := 0; i < N; i++`
construct. I also tool the liberty of replacing some
`for i := 0; i < len(myitemsarray); i++ { … myitemsarray[i] …}`
with  `for item := range myitemsarray` when `myitemsarray` contains only pointers.
2024-02-28 19:55:28 -08:00
jvoisin f4f8342245 Remove a superfluous condition
No need to check if the length of `line` is positive since we're checking
afterwards that it contains the `=` sign.
2024-02-28 19:47:30 -08:00
jvoisin 543a690bfd Close resources as soon as possible, instead of using defer() in a loop
So that resources can be freed as soon as they're not used anymore, instead of
waiting for the two nested loops to finish.
2024-02-28 19:47:30 -08:00
jvoisin c4e5dad549 Remove superfluous escaping in a regex 2024-02-28 19:47:30 -08:00
jvoisin fa12c23d79 Use strings.ReplaceAll instead of strings.Replace(…, -1) 2024-02-28 19:47:30 -08:00
jvoisin 4fe902a5d2 Use `strings.EqualFold` instead of `strings.ToLower(…) ==` 2024-02-28 19:47:30 -08:00
jvoisin 61af08a721 Use .WriteString( instead of .Write([]byte(… 2024-02-28 19:47:30 -08:00
jvoisin b04550e2f2 Use `%q` instead of `"%s"` 2024-02-28 19:47:30 -08:00
jvoisin 5e5cb056c5 Make internal/worker/worker.go read-only
Since workers don't communicate anything back to the pool with the channel,
there is no need to have it bidirectional.
2024-02-28 19:39:03 -08:00
jvoisin 48fa64f8ec Use a switch-case construct in internal/locale/plural.go instead of an avalanche of if-if-if-if-if
Less lines or code and marginally greater readability, yay!
Oh and also preallocate a map in LoadCatalogMessages just because we can.
2024-02-28 19:36:38 -08:00
jvoisin f274394f0e Simplify formatFileSize
No need to use a loop with divisions and multiplications when we have logarithms.
2024-02-28 19:32:38 -08:00
jvoisin 9a4a942cc4 Simplify durationImpl 2024-02-28 19:32:38 -08:00
jvoisin 6b3b8e8c9b Inline some templating functions 2024-02-28 19:32:38 -08:00
jvoisin 5a7d6f8997 Make use of printer.Print when possible 2024-02-28 19:24:41 -08:00
jvoisin b4ed17fbac Add a printer.Print to internal/locale/printer.go
No need to use variadic functions with string format interpolation
to generate static strings.
2024-02-28 19:24:41 -08:00
dependabot[bot] 57476f4d59 Bump github.com/prometheus/client_golang from 1.18.0 to 1.19.0
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.18.0 to 1.19.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.19.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.18.0...v1.19.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-27 21:25:42 -08:00
jvoisin 7660910232 Use prepared statement for intervals 2024-02-27 21:25:25 -08:00
jvoisin b054506e3a Use proper prepared statements for ArchiveEntries 2024-02-27 21:25:25 -08:00
jvoisin c961c6db7d Use proper prepared statement for updateEnclosures 2024-02-27 21:25:25 -08:00
Frédéric Guillot 0f126d4d11 Fix CodeQL workflow 2024-02-27 21:01:38 -08:00
jvoisin b94756bbf0 Add a warning for StripTags 2024-02-27 20:41:47 -08:00
jvoisin db6ae707ef Add some tests for add_image_title
I'm not sure if the behaviour is expected, but I didn't manage to
get the content injection to work in my browser, so I guess it's alright?
2024-02-27 20:41:15 -08:00
Frédéric Guillot 97feec8ebf Add more URL validation in media proxy 2024-02-26 20:29:40 -08:00
jvoisin bce21a9f91 Remove github.com/google/uuid
Replace it with a hand-rolled implementation. Heck, an UUID isn't even a
requirement, according to [omnivore](https://docs.omnivore.app/integrations/api.html#saving-a-url-with-the-api)'s
documentation, any "unique id" would do.
2024-02-26 18:31:12 -08:00
jvoisin 06e256e5ef Simplify internal/reader/icon/finder.go
- Use a simple regex to parse data uri instead of a hand-rolled parser, and
  document what fields are considered mandatory.
- Use case-insensitive matching to find (fav)icons, instead of doing the same
  query twice with different letter cases
- Add 'apple-touch-icon-precomposed.png' as a fallback favicon
- Reorder the queries to have i`con` first, since it seems to be the most
  popular one. It used to be last, meaning that pages had to be parsed
  completely 4 times, instead of one now.
- Minor factorisation in findIconURLsFromHTMLDocument
2024-02-26 18:18:04 -08:00
jvoisin 040938ff6d Small refactoring of internal/reader/date/parser.go
- Split dates formats into those that require local times
  and those who don't, so that there is no need to have a switch-case in the
  for loop with around 250 iterations at most.
- Be more strict when it comes to timezones, previously invalid ones like -13
  were accepted. Also add a test for this.
- Bail out early if the date is an empty string.
2024-02-26 18:08:04 -08:00
dependabot[bot] 21da7f77f5 Bump golang.org/x/crypto from 0.19.0 to 0.20.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/crypto/compare/v0.19.0...v0.20.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-26 18:01:00 -08:00
jvoisin c2d2f31438 Improve a bit internal/reader/scraper/scraper.go
- make findContentUsingCustomRules' more idiomatic,
  since in golang a function returning an error might
  return garbage in other parameter. Moreover, ignoring
  errors is bad practise.
- getPredefinedScraperRules is now running in constant-time,
  instead of iterating on a list with around 50 items in it.
2024-02-26 18:00:23 -08:00
jvoisin 5b2558bf92 Miscellaneous improvements to internal/reader/subscription/finder.go
- Surface `localizedError` in FindSubscriptionsFromWellKnownURLs via slog
- Use an inline declaration for new subscriptions, like done elsewhere in the
  file, if only for consistency's sake
- Preallocate the `subscriptions` slice when using an RSS-bridge,
  it's a good practise, and it might even marginally improve
  performances when adding __a lot__ of feeds via an rss-bridge instance, wooo!
2024-02-26 17:52:21 -08:00
jvoisin ecd59009fb Add a couple of new possible locations for feeds
- Hugo likes to generate index.xml
- feed.atom and feed.rss are used by enterprise-scale/old-school gigantic CMS
2024-02-26 17:43:51 -08:00
jvoisin 4a943b722d Add a couple of fuzzers 2024-02-26 17:23:49 -08:00
Frédéric Guillot 9d1b1e19d4 Google Reader: Do not return a 500 error when no items is returned 2024-02-25 21:17:49 -08:00
Frédéric Guillot 7a8061fc72 Fix regression introduced in PR #2402 2024-02-25 20:45:34 -08:00
jvoisin bca84bac8b Use an update-where for MarkCategoryAsRead instead of a subquery 2024-02-25 17:50:30 -08:00
jvoisin 66e0eb1bd6 Reformat's ArchiveEntries's query for consistency's sake
And replace the `=ANY` with an `IN`
2024-02-25 17:50:30 -08:00
jvoisin 26d189917e Simplify cleanupEntries' query
- `NOT (hash=ANY(%4))` can be expressed as `hash NOT IN $4`
- There is no need for a subquery operating on the same table,
  moving the conditions out is equivalent.
2024-02-25 17:50:30 -08:00
jvoisin ccd3955bf4 Format GetReadTime's query for consistency's sake 2024-02-25 17:50:30 -08:00
jvoisin 8a2cc3a344 Reformat the query in GetEntryIDs
To make it more consistent with how all the other are formatted
2024-02-25 17:50:30 -08:00
jvoisin 647fa025f8 Simplify WeeklyFeedEntryCount
No need for a `BETWEEN`: we want to filter on entries published in the last
week, no need to express is as "entries published between now and last week",
"entries published after last week" is enough.
2024-02-25 17:50:30 -08:00
jvoisin 1955350318 Build the map inline in CountAllFeeds()
No need to build an empty map to then add more fields in it one by one.
2024-02-25 17:50:30 -08:00
jvoisin 04916a57d2 Simplify CleanOldUserSessions' query
No need for a subquery, filtering on `created_at` directly is enough.
2024-02-25 17:50:30 -08:00
jvoisin 0adac5c6f7 Minor code simplification in internal/ui/view/view.go
No need to create the map item by item when we
can create it in one go.
2024-02-25 17:31:44 -08:00
jvoisin 54b5be5e7d Significantly simplify/speed up the sanitizer
- Use constant time access for maps instead of iterating on them
- Build a ~large whitelist map inline instead of constructing it item by item
  (and remove a duplicate key/value pair)
- Use `slices` instead of hand-rolled loops
2024-02-25 17:29:46 -08:00
Frédéric Guillot eae4cb1417 Add feed option to disable HTTP/2 to avoid fingerprinting 2024-02-24 22:30:26 -08:00
Frédéric Guillot 420a3d4d95 Remove Golint
- Golint is deprecated
- Use staticcheck and golangci-lint instead
2024-02-24 21:17:56 -08:00
jvoisin b48ad6dbfb Make use of go≥1.21 slices package instead of hand-rolled loops
This makes the code a tad smaller, moderner,
and maybe even marginally faster, yay!
2024-02-24 20:22:53 -08:00
jvoisin 2be5051b19 Reorder the fields of the Entry struct to save some memory
Given that there is always a ton of `Entry` floating around, reordering its
field to take less space is a quick/simple way to reduce miniflux' memory
consumption.

I kept the `ID` field as the first member, as I think it's the most important
one, and moving it somewhere else would drown it in other fields.

Anyway, this still provides a reduction of 32 bytes per Entry:

```console
$ fieldalignment  ./client/model.go 2>&1 | grep 203
~/v2/client/model.go:203:12: struct with 280 pointer bytes could be 240
$ fieldalignment  ./client/model.go 2>&1 | grep 203
~/v2/client/model.go:203:12: struct with 248 pointer bytes could be 240
$
```

The same optimisation pass could be applied to other structs, but since they
aren't present in obviously great numbers during miniflux' life cycle, it would
likely require some profiling to see if it's worth doing it.
2024-02-24 20:08:27 -08:00
jvoisin c544dadd55 Fix categories import from Thunderbird's OPML
Thunderbird OPML exports are looking like this:

```xml
<opml version="1.0" xmlns:fz="urn:forumzilla:">
<head>
	<title>Thunderbird OPML Export - RSS</title>
    	<dateCreated>Sat, 24 Feb 2024 11:31:13 GMT</dateCreated>
</head>
<body>
	<outline title="News">
		<outline type="rss" ...>
		<outline type="rss" ...>
		...
	</outline>
	<outline title="Blogs">
		<outline type="rss" ...>
		<outline type="rss" ...>
		...
	</outline>
</body>
```

This commit make it so that categories are now correctly imported.
2024-02-24 19:43:33 -08:00
Frédéric Guillot 1da65d97d8 Proxify video poster attribute 2024-02-23 18:44:20 -08:00
Frédéric Guillot c595c80356 Handle RDF feeds with duplicated <title> elements 2024-02-23 17:40:58 -08:00
dependabot[bot] 20e5fbcd7a Bump github.com/PuerkitoBio/goquery from 1.8.1 to 1.9.0
Bumps [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery) from 1.8.1 to 1.9.0.
- [Release notes](https://github.com/PuerkitoBio/goquery/releases)
- [Commits](https://github.com/PuerkitoBio/goquery/compare/v1.8.1...v1.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-23 16:32:21 -08:00
dependabot[bot] ac77154907 Bump github.com/tdewolff/minify/v2 from 2.20.17 to 2.20.18
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.17 to 2.20.18.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.17...v2.20.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-23 16:32:01 -08:00
Thomas J Faughnan Jr 97ace53bc9 Makefile: quiet git describe and rev-parse stderr 2024-02-21 22:08:02 -08:00
Frédéric Guillot feb962f98a Build amd64/arm64 Debian packages with CGO disabled
That should ensure that the binary is compiled statically
2024-02-21 21:23:48 -08:00
Frédéric Guillot 8602089a1e Adjust GitHub Actions condition for manual pipeline execution 2024-02-21 21:19:07 -08:00
Frédéric Guillot 4b0648f3d7 Update go.mod and add .exe suffix to Windows binary 2024-02-21 21:16:43 -08:00
Frédéric Guillot 856b96cbf8 Add job to build packages on-demand 2024-02-21 21:11:00 -08:00
Robert Lützner facf38955c Add 'Enter' key as a hotkey to open selected item
There are a few things that need to be done, to make this work.

First, we need to register `Enter` as another hotkey that opens the
selected item.

However, by default the `KeyboardHandler` will override all default
actions. That might make sense for any other key, but for the `Enter`
key, we want to keep the default behavior (i.e. follow a selected link
or press a button). So for this single key event, we do not call
`preventDefault()`.

I see this as unproblematic for the following reasons.

1. With the changes from #2348, when we're in a list of items (articles,
   categories, feeds), there is no link selected. This is what made the
   `Enter` key work _implicitly_ in the past. With nothing selected, the
   `Enter` key will do nothing by default.
2. If we have **any** link selected (including when we are in a view
   with a list of selectable items), we'll get the default action of
   `Enter` (i.e. follow a link), which is exactly what we had before.

Lastly, we need to update the list of keyboard shortcuts displayed when
pressing `?`.

This fixes #2366.
2024-02-21 20:02:58 -08:00
MSTCL cfdb890eae
Add Readeck integration 2024-02-21 19:57:34 -08:00
Thomas J Faughnan Jr 2f8d3a7958 Makefile: do not force CGO_ENABLED=0 for miniflux target 2024-02-21 19:47:58 -08:00
Frédéric Guillot 59311deb57 Fix logo misalignment when using languages that are more verbose than English 2024-02-19 15:10:35 -08:00
dependabot[bot] d2541a173a Bump github.com/tdewolff/minify/v2 from 2.20.16 to 2.20.17
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.16 to 2.20.17.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.16...v2.20.17)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-19 14:58:10 -08:00
Frédéric Guillot b618c11b80 Fix typo in man page and Changelog 2024-02-17 13:28:17 -08:00
Frédéric Guillot 8b4675807a Update ChangeLog 2024-02-17 12:07:36 -08:00
Frédéric Guillot c0bca973d6 Update GitHub Actions to Go 1.12 2024-02-17 12:00:23 -08:00
krvpb024 5c97771e61 fix macOS VoiceOver didn't announce details and summary expand 2024-02-14 20:11:23 -08:00
dependabot[bot] c9cbe8afd5 Bump golangci/golangci-lint-action from 3 to 4
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 3 to 4.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-12 19:30:13 -08:00
knrdl 1d90ce9dd2
Add Linkwarden integration 2024-02-11 17:12:37 -08:00
knrdl ccb9eed573 fix wrong label on save
when saving an entry the label was reset on complete
so the desired done label was never shown
2024-02-11 12:49:08 -08:00
krvpb024 2221fd408d fix the page-button hover style not show 2024-02-09 19:37:10 -08:00
Tân Î-sîn ea58bac548
Replace link has button role with button tag
# Change HTML tag to button

Replace the link tag with an HTML button to prevent some screen readers from having confusing announcements. By using the HTML button, users can use the Enter and Space keys to activate actions by default, instead of implementing them in JavaScript.

# Differentiate links and buttons visually

When activating the link element, the user may expect the web page to navigate to the URL and the page will refresh; when activating the button element, the user may expect the web page to still be on the same page, so that their current state, such as: input value, won't disappear.

Links and buttons should have different styles visually, so that users can't expect what will happen when they activate a link or a button.

I added the underline to the links, because that is the common pattern. Buttons have border and background color in a common pattern. But I think that will change the current layout drastically. So I added the focus, hover and active classes to the buttons instead.
2024-02-09 17:09:30 -08:00
krvpb024 0f85c0511a remove item focus outline overlapped on current style 2024-02-09 16:54:29 -08:00
krvpb024 27749a2877 change focus target on items when using keyboard navigation 2024-02-09 16:54:29 -08:00
dependabot[bot] 0991c27f9d Bump golang.org/x/oauth2 from 0.16.0 to 0.17.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.16.0...v0.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-08 19:56:08 -08:00
dependabot[bot] 00eab03655 Bump golang.org/x/net from 0.20.0 to 0.21.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/net/compare/v0.20.0...v0.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-08 19:26:55 -08:00
dependabot[bot] e55377b204 Bump github.com/go-webauthn/webauthn from 0.10.0 to 0.10.1
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.10.0 to 0.10.1.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.10.0...v0.10.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-08 19:04:41 -08:00
dependabot[bot] 4ddc4ec002 Bump golang.org/x/crypto from 0.18.0 to 0.19.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/crypto/compare/v0.18.0...v0.19.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-08 18:52:52 -08:00
krvpb024 facf17db3f remove icon img alt text 2024-02-07 21:59:09 -08:00
dependabot[bot] 8663c7d031 Bump golang.org/x/term from 0.16.0 to 0.17.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/term/compare/v0.16.0...v0.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-07 21:58:05 -08:00
krvpb024 6eac968083 add keyboard shortcut and aria attribute to menu button 2024-02-07 21:56:24 -08:00
Frédéric Guillot bd573957e0 Debian packages are failing to build
Error seen on GitHub Actions:

```
-buildmode=pie requires external (cgo) linking, but cgo is not enabled
```
2024-02-07 21:35:06 -08:00
Frédéric Guillot 5ce5c47499 Remove translation key page.categories.unread_counter 2024-02-05 21:39:02 -08:00
Frédéric Guillot 9336891e67 Restore menu toggle when clicking on the logo
The caret icon is too small on smartphone to expand/collapse the menu
2024-02-05 21:18:06 -08:00
Frédéric Guillot aa30c35e7e Use numeric UID in Alpine Docker image
Same as PR #2332
2024-02-05 20:49:25 -08:00
krvpb024 39368ece9a add alert role to alert message element 2024-02-05 20:14:23 -08:00
krvpb024 4f57309380 remove button role on element which perform navigation 2024-02-05 20:10:38 -08:00
krvpb024 57e7bd5bc9 add button role to links with action 2024-02-05 20:10:38 -08:00
krvpb024 bf54222be7 hide menu button in desktop layout instead of icon 2024-02-04 21:36:31 -08:00
Sheogorath 552fb3e4cc Fix non-numeric UID
This patch adjusts the distroless image to use the predefined non-root UID, which uses explicit UID definitions. This allows orchestrators like Kubernetes to validate non-zero UIDs directly by checking the Image metadata.

The previous setup without an explicit `runAsUser` in the securityContext would produce the following error when enabling `runAsNonRoot`:

```
Error: container has runAsNonRoot and image has non-numeric user (nonroot), cannot verify user is non-root (pod: "miniflux-97cc5955f-pt7vf_miniflux(d1c56d29-ea0a-407c-b3f3-9821fbd7ee61)", container: miniflux)
```
2024-02-04 21:32:42 -08:00
Frédéric Guillot 7d9f174b3f Add missing label ID for custom CSS field 2024-02-04 13:41:23 -08:00
Frédéric Guillot bf4d31eebe Add styling to search button 2024-02-04 13:36:31 -08:00
Frédéric Guillot f203326a29 Improve translation of hidden aria elements 2024-02-04 13:12:54 -08:00
krvpb024 8367413e84 change links that could perform actions to buttons 2024-02-04 10:47:30 -08:00
krvpb024 9b6dbd422c change article html structure for accessibility 2024-02-04 10:47:30 -08:00
krvpb024 531e80f580 fix entry page layout has changed 2024-02-04 10:47:30 -08:00
krvpb024 890a34e1bd remove code for debug and comment 2024-02-04 10:47:30 -08:00
krvpb024 7413e383a8 fix search and star function 2024-02-04 10:47:30 -08:00
krvpb024 7496479380 change header tag usage to match landmark meaning 2024-02-04 10:47:30 -08:00
krvpb024 6c78a1d635 improve feed, entry, category a11y 2024-02-04 10:47:30 -08:00
krvpb024 6413c9f9f7 add nav landmark to settings and feed menu 2024-02-04 10:47:30 -08:00
krvpb024 352aeb0490 fix missing translation key 2024-02-04 10:47:30 -08:00
krvpb024 61f52d971a fix h1 font-size 2024-02-04 10:47:30 -08:00
krvpb024 fa7508e28d change search summary icon 2024-02-04 10:47:30 -08:00
krvpb024 c217a31444 fix search label and login view not define header 2024-02-04 10:47:30 -08:00
krvpb024 84576f2c29 fix menu responsive layout 2024-02-04 10:47:30 -08:00
krvpb024 da11416b39 change layout structure by moving header 2024-02-04 10:47:30 -08:00
krvpb024 6a9a590c7f add search landmark and disclosure pattern to menu 2024-02-04 10:47:30 -08:00
krvpb024 f23e6a3352 add skip to content link 2024-02-04 10:47:30 -08:00
krvpb024 b568b1d41d improve page-header a11y
add nav landmark for links
labeling the purpose of nav in page-header
labeling the meaning of total number in page-header title
2024-02-04 10:47:30 -08:00
dependabot[bot] 9980634e5d Bump github.com/yuin/goldmark from 1.6.0 to 1.7.0
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.6.0 to 1.7.0.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.6.0...v1.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-02 17:37:49 -08:00
Matt Stobo 4a50ca9122 Allow filtering feeds on entry.Author 2024-01-31 19:42:07 -08:00
dependabot[bot] 3be0d14d44 Bump github.com/tdewolff/minify/v2 from 2.20.15 to 2.20.16
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.15 to 2.20.16.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.15...v2.20.16)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-29 18:57:28 -08:00
dependabot[bot] ec9fd996b1 Bump github.com/tdewolff/minify/v2 from 2.20.14 to 2.20.15
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.14 to 2.20.15.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.14...v2.20.15)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-26 18:44:21 -08:00
MDeLuise 1e704468a5 feat: add linkace service integration 2024-01-25 18:04:14 -08:00
Frédéric Guillot e8147f26b9 Fix incorrect label `for` attribute 2024-01-24 20:37:12 -08:00
Andrew Gunnerson 6648e0af38 Revert "touch_handler: Fix scroll up behavior on Firefox Android"
This reverts commit 344a237af8.

The previous behavior is more correct due to the use of preventDefault()
and the commit was introduced only as a workaround. As of [1], the
underlying issue in Firefox has been fixed and downward swipes to scroll
up are no longer ignored every other attempt.

[1] https://bugzilla.mozilla.org/show_bug.cgi?id=1847305
[2] https://bugzilla.mozilla.org/show_bug.cgi?id=1853075
[3] https://bugzilla.mozilla.org/show_bug.cgi?id=1724755

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
2024-01-23 19:33:08 -08:00
dependabot[bot] fde84d55ba Bump github.com/google/uuid from 1.5.0 to 1.6.0
Bumps [github.com/google/uuid](https://github.com/google/uuid) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/google/uuid/releases)
- [Changelog](https://github.com/google/uuid/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/uuid/compare/v1.5.0...v1.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-23 19:32:19 -08:00
Dave 1159dd6982 Add `addDynamicIframe` rewrite function.
Add unit tests for `add_dynamic_iframe` rewrite.
2024-01-23 19:23:57 -08:00
Frédéric Guillot 50341759b6 Fix typo in log message 2024-01-22 20:15:38 -08:00
dzaikos d68f2306c6 Add attribute to add_dynamic_image rewrite candidates. 2024-01-21 14:27:06 -08:00
Christoffer Strömblad 578743de1f
Add `item-meta-info-reading-time` CSS class 2024-01-20 10:53:02 -08:00
Frédéric Guillot 8553188ae4 Add missing translation argument 2024-01-20 10:48:27 -08:00
Frédéric Guillot a3e2570df2
Update issue templates 2024-01-15 10:31:38 -08:00
Frédéric Guillot 87c9ef6b48 Rewrite relative RSS Bridge URL 2024-01-13 14:54:36 -08:00
Frédéric Guillot ce32d181d5 Change default Accept header 2024-01-13 13:53:57 -08:00
dependabot[bot] b8c6c64e9c Bump github.com/tdewolff/minify/v2 from 2.20.13 to 2.20.14
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.13 to 2.20.14.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.13...v2.20.14)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-12 15:11:41 -08:00
dependabot[bot] c51f092bda Bump github.com/tdewolff/minify/v2 from 2.20.12 to 2.20.13
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.12 to 2.20.13.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.12...v2.20.13)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-11 19:05:25 -08:00
Frédéric Guillot e2d33f680e Fix incorrect condition 2024-01-11 19:04:50 -08:00
Ryan Stafford 980c5c63df
Limit feed/category entry pagination to unread entries when coming from unread entry list 2024-01-09 21:44:25 -08:00
Filipe de Luna 1441dc7600
Update entry processor to allow blocking/keeping entries by tags 2024-01-09 21:15:11 -08:00
dependabot[bot] 6fc4e2f45e Bump golang.org/x/oauth2 from 0.15.0 to 0.16.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.15.0 to 0.16.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.15.0...v0.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-08 17:11:00 -08:00
dependabot[bot] 8c00dbcf38 Bump github.com/tdewolff/minify/v2 from 2.20.10 to 2.20.12
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.10 to 2.20.12.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.10...v2.20.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-08 17:10:33 -08:00
dependabot[bot] 803e160c70 Bump golang.org/x/term from 0.15.0 to 0.16.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.15.0 to 0.16.0.
- [Commits](https://github.com/golang/term/compare/v0.15.0...v0.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-04 19:16:28 -08:00
notsmarthuman 4590da2fc3
Add `FORCE_REFRESH_INTERVAL` config option 2024-01-02 18:33:15 -08:00
Dark Dragon a1879ea37c Create default miniflux db 2023-12-31 10:54:53 -08:00
dependabot[bot] 8fe289ca72 Bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.0.
- [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.17.0...v1.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-31 10:02:23 -08:00
Stephan Brauer eb9ac861ea Update German translation.
- Translate missing entries.
- Hiphenate some phrases.
- Improve some translation.
  - Some translations where seemingly done automatically.
  - Some translation could be phrased a bit better (subjectively).
2023-12-31 10:01:48 -08:00
Jan Tojnar 074393d3bf fix: Include type for OPML subscriptions
As per [OPML 2.0 specification]:

> Each sub-element of the body of the OPML document is a node of type rss or an outline element that contains nodes of type rss.

> Required attributes: type, text, xmlUrl.

[OPML 2.0 specification]: http://opml.org/spec2.opml#subscriptionLists
2023-12-31 10:00:50 -08:00
dependabot[bot] 538e5305d3 Bump github.com/go-webauthn/webauthn from 0.9.4 to 0.10.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.9.4 to 0.10.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.9.4...v0.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-30 20:59:03 -08:00
dependabot[bot] 917852bbb0 Bump golang.org/x/crypto from 0.16.0 to 0.17.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-30 20:56:13 -08:00
dependabot[bot] c4e0dc3f5e Bump github.com/tdewolff/minify/v2 from 2.20.9 to 2.20.10
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.9 to 2.20.10.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.9...v2.20.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-30 20:55:55 -08:00
dependabot[bot] 22ed3a3565 Bump github/codeql-action from 2 to 3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-30 20:55:41 -08:00
dependabot[bot] 80853d48f5 Bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-30 20:55:20 -08:00
Darwin d90667777f request_builder.go: fetcher: Force try HTTP/2 2023-12-15 16:27:00 -08:00
281 changed files with 21153 additions and 8179 deletions

View File

@ -1,7 +1,7 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/go
image: mcr.microsoft.com/devcontainers/go:1.22
volumes:
- ..:/workspace:cached
command: sleep infinity
@ -24,7 +24,7 @@ services:
ports:
- 5432:5432
apprise:
image: caronc/apprise:latest
image: caronc/apprise:1.0
restart: unless-stopped
hostname: apprise
volumes:

View File

@ -1,10 +0,0 @@
---
name: Improvement
about: Do you have an idea to improve an existing feature?
title: ''
labels: improvements
assignees: ''
---

View File

@ -1,4 +1,7 @@
Do you follow the guidelines?
- [ ] I have tested my changes
- [ ] There is no breaking changes
- [ ] I really tested my changes and there is no regression
- [ ] Ideally, my commit messages use the same convention as the Go project: https://go.dev/doc/contribute#commit_messages
- [ ] I read this document: https://miniflux.app/faq.html#pull-request

View File

@ -12,13 +12,16 @@ jobs:
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.22.x"
check-latest: true
- name: Checkout
uses: actions/checkout@v4
- name: Compile binaries
env:
CGO_ENABLED: 0
run: make build
- name: Upload binaries
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: binaries
path: miniflux-*

View File

@ -29,11 +29,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@ -1,6 +1,7 @@
name: Debian Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
@ -28,8 +29,34 @@ jobs:
run: make debian-packages
- name: List generated files
run: ls -l *.deb
build-packages-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Available Docker Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Build Debian Packages
run: make debian-packages
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: packages
path: "*.deb"
if-no-files-found: error
retention-days: 3
publish-packages:
if: ${{ ! github.event.pull_request }}
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:

View File

@ -8,35 +8,8 @@ on:
pull_request:
branches: [ main ]
jobs:
test-docker-images:
if: github.event.pull_request
name: Test Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Alpine image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/alpine/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
- name: Test Alpine Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
- name: Build Distroless image
uses: docker/build-push-action@v5
with:
context: .
file: ./packaging/docker/distroless/Dockerfile
push: false
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
- name: Test Distroless Docker image
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
publish-docker-images:
if: ${{ ! github.event.pull_request }}
name: Publish Images
docker-images:
name: Docker Images
permissions:
packages: write
runs-on: ubuntu-latest
@ -46,33 +19,33 @@ jobs:
with:
fetch-depth: 0
- name: Generate Alpine Docker tag
id: docker_alpine_tag
run: |
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
fi
echo ::set-output name=tags::${TAGS}
- name: Generate Alpine Docker tags
id: docker_alpine_tags
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
- name: Generate Distroless Docker tag
id: docker_distroless_tag
run: |
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
DOCKER_VERSION=dev-distroless
if [ "${{ github.event_name }}" = "schedule" ]; then
DOCKER_VERSION=nightly-distroless
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
elif [[ $GITHUB_REF == refs/tags/* ]]; then
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
fi
echo ::set-output name=tags::${TAGS}
- name: Generate Distroless Docker tags
id: docker_distroless_tags
uses: docker/metadata-action@v5
with:
images: |
docker.io/${{ github.repository_owner }}/miniflux
ghcr.io/${{ github.repository_owner }}/miniflux
quay.io/${{ github.repository_owner }}/miniflux
tags: |
type=ref,event=pr
type=schedule,pattern=nightly
type=semver,pattern={{raw}}
flavor: |
suffix=-distroless,onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -81,12 +54,14 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
@ -94,6 +69,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: quay.io
@ -106,8 +82,8 @@ jobs:
context: .
file: ./packaging/docker/alpine/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
push: true
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
- name: Build and Push Distroless images
uses: docker/build-push-action@v5
@ -115,5 +91,5 @@ jobs:
context: .
file: ./packaging/docker/distroless/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_distroless_tags.outputs.tags }}

View File

@ -13,20 +13,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install jshint
- name: Install linters
run: |
sudo npm install -g jshint@2.13.3
sudo npm install -g jshint@2.13.6 eslint@8.57.0
- name: Run jshint
run: jshint ui/static/js/*.js
run: jshint internal/ui/static/js/*.js
- name: Run ESLint
run: eslint internal/ui/static/js/*.js
golangci:
name: Golang Linter
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.21"
- uses: golangci/golangci-lint-action@v3
go-version: "1.22.x"
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v6
with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2023.1.7"
install-go: false

View File

@ -1,6 +1,7 @@
name: RPM Packages
permissions: read-all
on:
workflow_dispatch:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
@ -19,8 +20,25 @@ jobs:
run: make rpm
- name: List generated files
run: ls -l *.rpm
build-package-manually:
if: github.event_name != 'pull_request' && github.event_name != 'push'
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build RPM Package
run: make rpm
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: packages
path: "*.rpm"
if-no-files-found: error
retention-days: 3
publish-package:
if: ${{ ! github.event.pull_request }}
if: github.event_name == 'push'
name: Publish Packages
runs-on: ubuntu-latest
steps:

View File

@ -15,7 +15,7 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.21"]
go-version: ["1.22.x"]
steps:
- name: Set up Go
uses: actions/setup-go@v5
@ -43,7 +43,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.22.x"
- name: Checkout
uses: actions/checkout@v4
- name: Install Postgres client

272
ChangeLog
View File

@ -1,3 +1,273 @@
Version 2.1.3 (April 27, 2024)
------------------------------
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
* `finder`: find feeds from YouTube playlist
* `http/response`: add brotli compression support
* `integration/matrix`: fix function name in comment
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
* `reader/fetcher`: add brotli content encoding support
* `reader/processor`: minimize feed entries HTML content
* `reader/rewrite`: add a rule for `oglaf.com`
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
* `ui`: add seek and speed controls to media player
* `ui`: add tag entries page
* `ui`: fix JavaScript error when clicking on unread counter
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
Version 2.1.2 (March 30, 2024)
------------------------------
* `api`: rewrite API integration tests without build tags
* `ci`: add basic ESLinter checks
* `ci`: enable go-critic linter and fix various issues detected
* `ci`: fix JavaScript linter path in GitHub Actions
* `cli`: avoid misleading error message when creating an admin user automatically
* `config`: add `FILTER_ENTRY_MAX_AGE_DAYS` option
* `config`: bump the number of simultaneous workers
* `config`: rename `PROXY_*` options to `MEDIA_PROXY_*`
* `config`: use `crypto.GenerateRandomBytes` instead of doing it by hand
* `http/request`: refactor conditions to be more idiomatic
* `http/response`: remove legacy `X-XSS-Protection` header
* `integration/rssbrige`: fix rssbrige import
* `integration/shaarli`: factorize the header+payload concatenation as data
* `integration/shaarli`: no need to base64-encode then remove the padding when we can simply encode without padding
* `integration/shaarli`: the JWT token was declared as using HS256 as algorithm, but was using HS512
* `integration/webhook`: add category title to request body
* `locale`: update Turkish translations
* `man page`: sort config options in alphabetical order
* `mediaproxy`: reduce the internal indentation of `ProxifiedUrl` by inverting some conditions
* `mediaproxy`: simplify and refactor the package
* `model`: replace` Optional{Int,Int64,Float64}` with a generic function `OptionalNumber()`
* `model`: use struct embedding for `FeedCreationRequestFromSubscriptionDiscovery` to reduce code duplication
* `reader/atom`: avoid debug message when the date is empty
* `reader/atom`: change `if !a { a = } if !a {a = }` constructs into `if !a { a = ; if !a {a = }}` to reduce the number of comparisons and improve readability
* `reader/atom`: Move the population of the feed's entries into a new function, to make BuildFeed easier to understand/separate concerns/implementation details
* `reader/atom`: refactor Atom parser to use an adapter
* `reader/atom`: use `sort+compact` instead of `compact+sort` to remove duplicates
* `reader/atom`: when detecting the format, detect its version as well
* `reader/encoding`: inline a one-liner function
* `reader/handler`: fix force refresh feature
* `reader/json`: refactor JSON Feed parser to use an adapter
* `reader/media`: remove a superfluous error-check: `strconv.ParseInt` returns `0` when passed an empty string
* `reader/media`: simplify switch-case by moving a common condition above it
* `reader/processor`: compile block/keep regex only once per feed
* `reader/rdf`: refactor RDF parser to use an adapter
* `reader/rewrite`: inline some one-line functions
* `reader/rewrite`: simplify `removeClickbait`
* `reader/rewrite`: transform a free-standing function into a method
* `reader/rewrite`: use a proper constant instead of a magic number in `applyFuncOnTextContent`
* `reader/rss`: add support for `<media:category>` element
* `reader/rss`: don't add empty tags to RSS items
* `reader/rss`: refactor RSS parser to use a default namespace to avoid some limitations of the Go XML parser
* `reader/rss`: refactor RSS Parser to use an adapter
* `reader/rss`: remove some duplicated code in RSS parser
* `reader`: ensure that enclosure URLs are always absolute
* `reader`: move iTunes and GooglePlay XML definitions to their own packages
* `reader`: parse podcast categories
* `reader`: remove trailing space in `SiteURL` and `FeedURL`
* `storage`: do not store empty tags
* `storage`: simplify `removeDuplicates()` to use a `sort`+`compact` construct instead of doing it by hand with a hashmap
* `storage`: Use plain strings concatenation instead of building an array and then joining it
* `timezone`: make sure the tests pass when the timezone database is not installed on the host
* `ui/css`: align `min-width` with the other `min-width` values
* `ui/css`: fix regression: "Add to Home Screen" button is unreadable
* `ui/js`: don't use lambdas to return a function, use directly the function instead
* `ui/js`: enable trusted-types
* `ui/js`: fix download button loading label
* `ui/js`: fix JavaScript error on the login page when the user not authenticated
* `ui/js`: inline one-line functions
* `ui/js`: inline some `querySelectorAll` calls
* `ui/js`: reduce the scope of some variables
* `ui/js`: remove a hack for "Chrome 67 and earlier" since it was released in 2018
* `ui/js`: replace `DomHelper.findParent` with `.closest`
* `ui/js`: replace `let` with `const`
* `ui/js`: simplify `DomHelper.getVisibleElements` by using a `filter` instead of a loop with an index
* `ui/js`: use a `Set` instead of an array in a `KeyboardHandler`'s member
* `ui/js`: use some ternaries where it makes sense
* `ui/static`: make use of `HashFromBytes` everywhere
* `ui/static`: set minifier ECMAScript version
* `ui`: add keyboard shortcuts for scrolling to top/bottom of the item list
* `ui`: add media player control playback speed
* `ui`: remove unused variables and improve JSON decoding in `saveEnclosureProgression()`
* `validator`: display an error message on edit feed page when the feed URL is not unique
* Bump `github.com/coreos/go-oidc/v3` from `3.9.0` to `3.10.0`
* Bump `github.com/go-webauthn/webauthn` from `0.10.1` to `0.10.2`
* Bump `github.com/tdewolff/minify/v2` from `2.20.18` to `2.20.19`
* Bump `google.golang.org/protobuf` from `1.32.0` to `1.33.0`
Version 2.1.1 (March 10, 2024)
-----------------------------
* Move search form to a dedicated page
* Add Readeck integration
* Add feed option to disable HTTP/2 to avoid fingerprinting
* Add `Enter` key as a hotkey to open selected item
* Proxify `video` element `poster` attribute
* Add a couple of new possible locations for feeds
* Hugo likes to generate `index.xml`
* `feed.atom` and `feed.rss` are used by enterprise-scale/old-school gigantic CMS
* Fix categories import from Thunderbird's OPML
* Fix logo misalignment when using languages that are more verbose than English
* Google Reader: Do not return a 500 error when no items is returned
* Handle RDF feeds with duplicated `<title>` elements
* Sort integrations alphabetically
* Add more URL validation in media proxy
* Add unit test to ensure each translation has the correct number of plurals
* Add missing plurals for some languages
* Makefile: quiet `git describe` and `rev-parse` stderr: When building from a tarball instead of a cloned git repo, there would be two `fatal: not a git repository` errors emitted even though the build succeeds. This is because of how `VERSION` and `COMMIT` are set in the Makefile. This PR suppresses the stderr for these variable assignments.
* Makefile: do not force `CGO_ENABLED=0` for `miniflux` target
* Add GitHub Action pipeline to build packages on-demand
* Remove Golint (deprecated), use `staticcheck` and `golangci-lint` instead
* Build amd64/arm64 Debian packages with CGO disabled
* Update `go.mod` and add `.exe` suffix to Windows binary
* Add a couple of fuzzers
* Fix CodeQL workflow
* Code and performance improvements:
* Use an `io.ReadSeeker` instead of an `io.Reader` to parse feeds
* Speed up the sanitizer:
- Allow Youtube URLs to start with `www`
- Use `strings.Builder` instead of a `bytes.Buffer`
- Use a `strings.NewReader` instead of a `bytes.NewBufferString`
- Sprinkles a couple of `continue` to make the code-flow more obvious
- Inline calls to `inList`, and put their parameters in the right order
- Simplify `isPixelTracker`
- Simplify `isValidIframeSource`, by extracting the hostname and comparing it directly, instead of using the full url and checking if it starts with multiple variations of the same one (`//`, `http:`, `https://` multiplied by `/www.`)
- Add a benchmark
- Instead of having to allocate a ~100 keys map containing possibly dynamic values (at least to the go compiler), allocate it once in a global variable. This significantly speeds things up, by reducing the garbage
- Use constant time access for maps instead of iterating on them
- Build a ~large whitelist map inline instead of constructing it item by item (and remove a duplicate key/value pair)
- Use `slices` instead of hand-rolled loops
collector/allocator involvements.
* Reuse a `Reader` instead of copying to a buffer when parsing an Atom feed
* Preallocate memory when exporting to OPML: This should marginally increase performance when exporting a large amount of feeds to OPML
* Delay call of `view.New` after logging the user in: There is no need to do extra work like creating a session and its associated view until the user has been properly identified and as many possibly-failing sql request have been successfully run
* Use constant-time comparison for anti-csrf tokens: This is probably completely overkill, but since anti-csrf tokens are secrets, they should be compared against untrusted inputs in constant time
* Simplify and optimize `genericProxyRewriter`
- Reduce the amount of nested loops: it's preferable to search the whole page once and filter on it (even with filters that should always be false), than searching it again for every element we're looking for.
- Factorize the proxying conditions into a `shouldProxy` function to reduce the copy-pasta.
* Speed up `removeUnlikelyCandidates`: `.Not` returns a brand new `Selection`, copied element by element
* Improve `EstimateReadingTime`'s speed by a factor 7
- Refactorise the tests and add some
- Use 250 signs instead of the whole text
- Only check for Korean, Chinese and Japanese script
- Add a benchmark
- Use a more idiomatic control flow
* Don't compute reading-time when unused: If the user doesn't display reading times, there is no need to compute them. This should speed things up a bit, since `whatlanggo.Detect` is abysmally slow.
* Simplify `username` generation for the integration tests: No need to generate random numbers 10 times, generate a single big-enough one. A single int64 should be more than enough
* Add missing regex anchor detected by CodeQL
* Don't mix up slices capacity and length
* Use prepared statements for intervals, `ArchiveEntries` and `updateEnclosures`
* Use modern for-loops introduced with Go 1.22
* Remove a superfluous condition: No need to check if the length of `line` is positive since we're checking afterwards that it contains the `=` sign
* Close resources as soon as possible, instead of using `defer()` in a loop
* Remove superfluous escaping in a regex
* Use `strings.ReplaceAll` instead of `strings.Replace(…, -1)`
* Use `strings.EqualFold` instead of `strings.ToLower(…) ==`
* Use `.WriteString(` instead of `.Write([]byte(…`
* Use `%q` instead of `"%s"`
* Make `internal/worker/worker.go` read-only
* Use a switch-case construct in `internal/locale/plural.go` instead of an avalanche of `if`
* Template functions: simplify `formatFileSize` and `duration` implementation
* Inline some templating functions
* Make use of `printer.Print` when possible
* Add a `printer.Print` to `internal/locale/printer.go`: No need to use variadic functions with string format interpolation to generate static strings
* Minor code simplification in `internal/ui/view/view.go`: No need to create the map item by item when we can create it in one go
* Build the map inline in `CountAllFeeds()`: No need to build an empty map to then add more fields in it one by one
* Miscellaneous improvements to `internal/reader/subscription/finder.go`:
- Surface `localizedError` in `FindSubscriptionsFromWellKnownURLs` via `slog`
- Use an inline declaration for new subscriptions, like done elsewhere in the
file, if only for consistency's sake
- Preallocate the `subscriptions` slice when using an RSS-bridge,
* Use an update-where for `MarkCategoryAsRead` instead of a subquery
* Simplify `CleanOldUserSessions`' query: No need for a subquery, filtering on `created_at` directly is enough
* Simplify `cleanupEntries`' query
- `NOT (hash=ANY(%4))` can be expressed as `hash NOT IN $4`
- There is no need for a subquery operating on the same table, moving the conditions out is equivalent.
* Reformat `ArchiveEntries`'s query for consistency's sake and replace the `=ANY` with an `IN`
* Reformat the query in `GetEntryIDs` and `GetReadTime`'s query for consistency's sake
* Simplify `WeeklyFeedEntryCount`: No need for a `BETWEEN`: we want to filter on entries published in the last week, no need to express is as "entries published between now and last week", "entries published after last week" is enough
* Add some tests for `add_image_title`
* Remove `github.com/google/uuid` dependencies: Replace it with a hand-rolled implementation. Heck, an UUID isn't even a requirement according to Omnivore API docs
* Simplify `internal/reader/icon/finder.go`:
- Use a simple regex to parse data uri instead of a hand-rolled parser, and document what fields are considered mandatory.
- Use case-insensitive matching to find (fav)icons, instead of doing the same query twice with different letter cases
- Add `apple-touch-icon-precomposed.png` as a fallback `favicon`
- Reorder the queries to have `icon` first, since it seems to be the most popular one. It used to be last, meaning that pages had to be parsed completely 4 times, instead of one now.
- Minor factorisation in `findIconURLsFromHTMLDocument`
* Small refactoring of `internal/reader/date/parser.go`:
- Split dates formats into those that require local times and those who don't, so that there is no need to have a switch-case in the for loop with around 250 iterations at most.
- Be more strict when it comes to timezones, previously invalid ones like -13 were accepted. Also add a test for this.
- Bail out early if the date is an empty string.
* Make use of Go ≥ 1.21 slices package instead of hand-rolled loops
* Reorder the fields of the `Entry` struct to save some memory
* Dependencies update:
* Bump `golang.org/x/oauth2` from `0.17.0` to `0.18.0`
* Bump `github.com/prometheus/client_golang` from `1.18.0` to `1.19.0`
* Bump `github.com/tdewolff/minify/v2` from `2.20.16` to `2.20.18`
* Bump `github.com/PuerkitoBio/goquery` from `1.8.1` to `1.9.1`
* Bump `golang.org/x/crypto` from `0.19.0` to `0.20.0`
* Bump `github.com/go-jose/go-jose/v3` from `3.0.1` to `3.0.3`
Version 2.1.0 (February 17, 2024)
---------------------------------
* Add Linkwarden integration
* Add LinkAce integration
* Add `FORCE_REFRESH_INTERVAL` config option
* Add `item-meta-info-reading-time` CSS class
* Add `add_dynamic_iframe` rewrite function
* Add attribute `data-original-mos` to `add_dynamic_image` rewrite candidates
* Update entry processor to allow blocking/keeping entries by tags and/or authors
* Change default `Accept` header when fetching feeds
* Rewrite relative RSS Bridge URL to absolute URL
* Use numeric user ID in Alpine and distroless container image (avoid `securityContext` error in Kubernetes)
* Always try to use HTTP/2 when fetching feeds if available
* Add `type` attribute in OPML export as per OPML 2.0 specs
* Fix missing translation argument for the key `error.unable_to_parse_feed`
* Fix Debian package builder when using Go 1.22 and `armhf` architecture
* Fix typo in log message
* Fix incorrect label shown when saving an article
* Fix incorrect condition in refresh feeds cli
* Fix incorrect label `for` attribute
* Add missing label ID for custom CSS field
* Accessibility improvements:
* Add workaround for macOS VoiceOver that didn't announce `details` and `summary` when expanded
* Add `alert` role to alert message element
* Add a `h2` heading to the article element so that the screen reader users can navigate the article through the heading level
* Add an `aria-label` attribute for the article element for screen readers
* Remove the icon image `alt` attribute in feeds list to prevent screen reader to announce it before entry title
* Add `sr-only` CSS class for screen reader users (provides more context)
* Differentiate between buttons and links
* Change links that could perform actions to buttons
* Improve translation of hidden Aria elements
* Remove the redundant article role
* Add a search landmark for the search form so that the screen reader users can navigate to it
* Add skip to content link
* Add `nav` landmark to page header links
* Limit feed/category entry pagination to unread entries when coming from unread entry list
* Update German translation
* Update GitHub Actions to Go 1.22
* Bump `golang.org/x/term` from `0.16.0` to `0.17.0`
* Bump `github.com/google/uuid` from `1.5.0` to `1.6.0`
* Bump `github.com/yuin/goldmark` from `1.6.0` to `1.7.0`
* Bump `golang.org/x/oauth2` from `0.15.0` to `0.17.0`
* Bump `github.com/tdewolff/minify/v2` from `2.20.10` to `2.20.12`
* Bump `golang.org/x/term` from `0.15.0` to `0.16.0`
* Bump `github.com/prometheus/client_golang` from `1.17.0` to `1.18.0`
* Bump `github.com/tdewolff/minify/v2` from `2.20.9` to `2.20.16`
* Bump `golang.org/x/crypto` from `0.16.0` to `0.19.0`
* Bump `github.com/go-webauthn/webauthn` from `0.9.4` to` 0.10.1`
* Bump `golang.org/x/net` from `0.20.0` to `0.21.0`
Version 2.0.51 (December 13, 2023)
----------------------------------
@ -158,7 +428,7 @@ Version 2.0.47 (August 20, 2023)
* Add new API endpoint: `/entries/{entryID}/save`
* Trigger Docker and packages workflows only for semantic tags
* Go module versioning expect Git tags to start with the letter v.
* The goal is to keep the existing naming convention for generated artifacts and
* The goal is to keep the existing naming convention for generated artifacts and
have proper versioning for the Go module.
* Bump `golang.org/x/*` dependencies
* Bump `github.com/yuin/goldmark`

View File

@ -1,17 +1,18 @@
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DEB_IMG_ARCH := amd64
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
export PGPASSWORD := postgres
.PHONY: \
miniflux \
miniflux-no-pie \
linux-amd64 \
linux-arm64 \
linux-armv7 \
@ -43,7 +44,10 @@ export PGPASSWORD := postgres
debian-packages
miniflux:
@ CGO_ENABLED=0 go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
miniflux-no-pie:
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ -73,7 +77,7 @@ openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
@ -94,19 +98,21 @@ openbsd-x86:
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
run:
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
test:
go test -cover -race -count=1 ./...
lint:
golint -set_exit_status ${PKG_LIST}
go vet ./...
staticcheck ./...
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
@ -120,9 +126,13 @@ integration-test:
RUN_MIGRATIONS=1 \
DEBUG=1 \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
TEST_MINIFLUX_ADMIN_USERNAME=admin \
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`
@ -153,15 +163,15 @@ rpm: clean
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
debian:
@ docker build --load \
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
@ docker buildx build --load \
--platform linux/$(DOCKER_PLATFORM) \
-t miniflux-deb-builder \
-f packaging/debian/Dockerfile \
.
@ docker run --rm \
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
-v ${PWD}:/pkg miniflux-deb-builder
debian-packages: clean
$(MAKE) debian DEB_IMG_ARCH=amd64
$(MAKE) debian DEB_IMG_ARCH=arm64v8
$(MAKE) debian DEB_IMG_ARCH=arm32v7
$(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DOCKER_PLATFORM=arm/v7

View File

@ -18,16 +18,44 @@ type Client struct {
}
// New returns a new Miniflux client.
// Deprecated: use NewClient instead.
func New(endpoint string, credentials ...string) *Client {
// Web gives "API Endpoint = https://miniflux.app/v1/", it doesn't work (/v1/v1/me)
return NewClient(endpoint, credentials...)
}
// NewClient returns a new Miniflux client.
func NewClient(endpoint string, credentials ...string) *Client {
// Trim trailing slashes and /v1 from the endpoint.
endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1")
// trim to https://miniflux.app
if len(credentials) == 2 {
switch len(credentials) {
case 2:
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
case 1:
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
default:
return &Client{request: &request{endpoint: endpoint}}
}
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
}
// Healthcheck checks if the application is up and running.
func (c *Client) Healthcheck() error {
body, err := c.request.Get("/healthcheck")
if err != nil {
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
}
defer body.Close()
responseBodyContent, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("miniflux: unable to read healthcheck response: %w", err)
}
if string(responseBodyContent) != "OK" {
return fmt.Errorf("miniflux: invalid healthcheck response: %q", responseBodyContent)
}
return nil
}
// Version returns the version of the Miniflux instance.
@ -528,6 +556,25 @@ func (c *Client) SaveEntry(entryID int64) error {
return err
}
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
if err != nil {
return "", err
}
defer body.Close()
var response struct {
Content string `json:"content"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return "", fmt.Errorf("miniflux: response error (%v)", err)
}
return response.Content, nil
}
// FetchCounters fetches feed counters.
func (c *Client) FetchCounters() (*FeedCounters, error) {
body, err := c.request.Get("/v1/feeds/counters")

View File

@ -12,7 +12,7 @@ This code snippet fetch the list of users:
miniflux "miniflux.app/v2/client"
)
client := miniflux.New("https://api.example.org", "admin", "secret")
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
users, err := client.Users()
if err != nil {
fmt.Println(err)

View File

@ -41,6 +41,7 @@ type User struct {
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
}
func (u User) String() string {
@ -58,28 +59,29 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
}
// Users represents a list of users.
@ -107,7 +109,7 @@ type Subscription struct {
}
func (s Subscription) String() string {
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
}
// Subscriptions represents a list of subscriptions.
@ -140,6 +142,7 @@ type Feed struct {
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
}
// FeedCreationRequest represents the request to create a feed.
@ -160,6 +163,7 @@ type FeedCreationRequest struct {
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
}
// FeedModificationRequest represents the request to update a feed.
@ -182,6 +186,7 @@ type FeedModificationRequest struct {
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
}
// FeedIcon represents the feed icon.
@ -202,24 +207,24 @@ type Feeds []*Feed
// Entry represents a subscription item in the system.
type Entry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Date time.Time `json:"published_at"`
ChangedAt time.Time `json:"changed_at"`
CreatedAt time.Time `json:"created_at"`
Feed *Feed `json:"feed,omitempty"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
ChangedAt time.Time `json:"changed_at"`
Title string `json:"title"`
Status string `json:"status"`
Content string `json:"content"`
Author string `json:"author"`
ShareCode string `json:"share_code"`
Starred bool `json:"starred"`
ReadingTime int `json:"reading_time"`
Enclosures Enclosures `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Tags []string `json:"tags"`
ReadingTime int `json:"reading_time"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Starred bool `json:"starred"`
}
// EntryModificationRequest represents a request to modify an entry.
@ -287,3 +292,7 @@ type VersionResponse struct {
Arch string `json:"arch"`
OS string `json:"os"`
}
func SetOptionalField[T any](value T) *T {
return &value
}

View File

@ -26,6 +26,7 @@ var (
ErrForbidden = errors.New("miniflux: access forbidden")
ErrServerError = errors.New("miniflux: internal server error")
ErrNotFound = errors.New("miniflux: resource not found")
ErrBadRequest = errors.New("miniflux: bad request")
)
type errorResponse struct {
@ -124,10 +125,10 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
var resp errorResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&resp); err != nil {
return nil, fmt.Errorf("miniflux: bad request error (%v)", err)
return nil, fmt.Errorf("%w (%v)", ErrBadRequest, err)
}
return nil, fmt.Errorf("miniflux: bad request (%s)", resp.ErrorMessage)
return nil, fmt.Errorf("%w (%s)", ErrBadRequest, resp.ErrorMessage)
}
if response.StatusCode > 400 {

View File

@ -24,6 +24,7 @@ services:
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=miniflux
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:

52
go.mod
View File

@ -1,28 +1,29 @@
module miniflux.app/v2
// +heroku goVersion go1.21
// +heroku goVersion go1.22
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/PuerkitoBio/goquery v1.9.2
github.com/abadojack/whatlanggo v1.0.1
github.com/coreos/go-oidc/v3 v3.9.0
github.com/go-webauthn/webauthn v0.9.4
github.com/google/uuid v1.5.0
github.com/andybalholm/brotli v1.1.0
github.com/coreos/go-oidc/v3 v3.10.0
github.com/go-webauthn/webauthn v0.10.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.17.0
github.com/tdewolff/minify/v2 v2.20.9
github.com/yuin/goldmark v1.6.0
golang.org/x/crypto v0.16.0
golang.org/x/net v0.19.0
golang.org/x/oauth2 v0.15.0
golang.org/x/term v0.15.0
github.com/prometheus/client_golang v1.19.1
github.com/tdewolff/minify/v2 v2.20.32
github.com/yuin/goldmark v1.7.1
golang.org/x/crypto v0.23.0
golang.org/x/net v0.25.0
golang.org/x/oauth2 v0.20.0
golang.org/x/term v0.20.0
golang.org/x/text v0.15.0
mvdan.cc/xurls/v2 v2.5.0
)
require (
github.com/go-webauthn/x v0.1.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
github.com/go-webauthn/x v0.1.9 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
)
@ -30,20 +31,17 @@ require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/tdewolff/parse/v2 v2.7.6 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/tdewolff/parse/v2 v2.7.14 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/sys v0.20.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)
go 1.21
go 1.22

130
go.sum
View File

@ -1,138 +1,112 @@
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
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.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-webauthn/webauthn v0.9.4 h1:YxvHSqgUyc5AK2pZbqkWWR55qKeDPhP8zLDr6lpIc2g=
github.com/go-webauthn/webauthn v0.9.4/go.mod h1:LqupCtzSef38FcxzaklmOn7AykGKhAhr9xlRbdbgnTw=
github.com/go-webauthn/x v0.1.5 h1:V2TCzDU2TGLd0kSZOXdrqDVV5JB9ILnKxA9S53CSBw0=
github.com/go-webauthn/x v0.1.5/go.mod h1:qbzWwcFcv4rTwtCLOZd+icnr6B7oSsAGZJqlt8cukqY=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tdewolff/minify/v2 v2.20.9 h1:0RGsL+jBpm77obkuNCjNZ2eiN81CZzTnjeVmTqxCmYk=
github.com/tdewolff/minify/v2 v2.20.9/go.mod h1:hZnNtFqXVQ5QIAR05tdgvS7h6E80jyRwHSGVmM4jbzQ=
github.com/tdewolff/parse/v2 v2.7.6 h1:PGZH2b/itDSye9RatReRn4GBhsT+KFEMtAMjHRuY1h8=
github.com/tdewolff/parse/v2 v2.7.6/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.20.32 h1:rk4THvBPLEU+gGDKaJxyvFhF5+quSwCk3HKv1GpSVyE=
github.com/tdewolff/minify/v2 v2.20.32/go.mod h1:1TJni7+mATKu24cBQQpgwakrYRD27uC1/rdJOgdv8ns=
github.com/tdewolff/parse/v2 v2.7.14 h1:100KJ+QAO3PpMb3uUjzEU/NpmCdbBYz6KPmCIAfWpR8=
github.com/tdewolff/parse/v2 v2.7.14/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,8 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/storage"
@ -36,14 +36,14 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
return
}
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
proxyOption := config.Opts.MediaProxyMode()
for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
@ -164,7 +164,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
}
for i := range entries {
entries[i].Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entries[i].Content)
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entries[i].Content)
}
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
@ -275,7 +275,9 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
}
entryUpdateRequest.Patch(entry)
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
json.ServerError(w, r, err)

View File

@ -115,7 +115,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
return
}
if validationErr := validator.ValidateFeedModification(h.store, userID, &feedModificationRequest); validationErr != nil {
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}

View File

@ -42,6 +42,7 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
requestBuilder.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,

View File

@ -77,7 +77,7 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
}
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
json.BadRequest(w, r, errors.New("Only administrators can change permissions of standard users"))
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
return
}
}
@ -141,7 +141,7 @@ func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
@ -163,7 +163,7 @@ func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
return
}
@ -194,7 +194,7 @@ func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
}
if user.ID == request.UserID(r) {
json.BadRequest(w, r, errors.New("You cannot remove yourself"))
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
return
}

View File

@ -16,7 +16,7 @@ func askCredentials() (string, string) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
printErrorAndExit(fmt.Errorf("this is not a terminal, exiting"))
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
}
fmt.Print("Enter Username: ")

View File

@ -23,7 +23,7 @@ const (
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create admin user"
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
@ -191,7 +191,7 @@ func Parse() {
}
if flagCreateAdmin {
createAdmin(store)
createAdminUserFromInteractiveTerminal(store)
return
}
@ -211,9 +211,8 @@ func Parse() {
printErrorAndExit(err)
}
// Create admin user and start the daemon.
if config.Opts.CreateAdmin() {
createAdmin(store)
createAdminUserFromEnvironmentVariables(store)
}
if flagRefreshFeeds {

View File

@ -12,15 +12,20 @@ import (
"miniflux.app/v2/internal/validator"
)
func createAdmin(store *storage.Storage) {
userCreationRequest := &model.UserCreationRequest{
Username: config.Opts.AdminUsername(),
Password: config.Opts.AdminPassword(),
IsAdmin: true,
}
func createAdminUserFromEnvironmentVariables(store *storage.Storage) {
createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())
}
if userCreationRequest.Username == "" || userCreationRequest.Password == "" {
userCreationRequest.Username, userCreationRequest.Password = askCredentials()
func createAdminUserFromInteractiveTerminal(store *storage.Storage) {
username, password := askCredentials()
createAdminUser(store, username, password)
}
func createAdminUser(store *storage.Storage, username, password string) {
userCreationRequest := &model.UserCreationRequest{
Username: username,
Password: password,
IsAdmin: true,
}
if store.UserExists(userCreationRequest.Username) {
@ -34,7 +39,12 @@ func createAdmin(store *storage.Storage) {
printErrorAndExit(validationErr.Error())
}
if _, err := store.CreateUser(userCreationRequest); err != nil {
if user, err := store.CreateUser(userCreationRequest); err != nil {
printErrorAndExit(err)
} else {
slog.Info("Created new admin user",
slog.String("username", user.Username),
slog.Int64("user_id", user.ID),
)
}
}

View File

@ -45,7 +45,7 @@ func refreshFeeds(store *storage.Storage) {
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
)
for i := 0; i < config.Opts.WorkerPoolSize(); i++ {
for i := range config.Opts.WorkerPoolSize() {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
@ -56,7 +56,7 @@ func refreshFeeds(store *storage.Storage) {
slog.Int("worker_id", workerID),
)
if localizedError := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); err != nil {
if localizedError := feedHandler.RefreshFeed(store, job.UserID, job.FeedID, false); localizedError != nil {
slog.Warn("Unable to refresh feed",
slog.Int64("feed_id", job.FeedID),
slog.Int64("user_id", job.UserID),

View File

@ -4,6 +4,7 @@
package config // import "miniflux.app/v2/internal/config"
import (
"bytes"
"os"
"testing"
)
@ -759,6 +760,41 @@ func TestPollingFrequency(t *testing.T) {
}
}
func TestDefautForceRefreshInterval(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultForceRefreshInterval
result := opts.ForceRefreshInterval()
if result != expected {
t.Fatalf(`Unexpected FORCE_REFRESH_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestForceRefreshInterval(t *testing.T) {
os.Clearenv()
os.Setenv("FORCE_REFRESH_INTERVAL", "42")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 42
result := opts.ForceRefreshInterval()
if result != expected {
t.Fatalf(`Unexpected FORCE_REFRESH_INTERVAL value, got %v instead of %v`, result, expected)
}
}
func TestDefaultBatchSizeValue(t *testing.T) {
os.Clearenv()
@ -1407,9 +1443,9 @@ func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
}
}
func TestProxyOption(t *testing.T) {
func TestMediaProxyMode(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("MEDIA_PROXY_MODE", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1418,14 +1454,14 @@ func TestProxyOption(t *testing.T) {
}
expected := "all"
result := opts.ProxyOption()
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
}
}
func TestDefaultProxyOptionValue(t *testing.T) {
func TestDefaultMediaProxyModeValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@ -1434,17 +1470,17 @@ func TestDefaultProxyOptionValue(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultProxyOption
result := opts.ProxyOption()
expected := defaultMediaProxyMode
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypes(t *testing.T) {
func TestMediaProxyResourceTypes(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1454,25 +1490,25 @@ func TestProxyMediaTypes(t *testing.T) {
expected := []string{"audio", "image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
func TestMediaProxyResourceTypesWithDuplicatedValues(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio, image")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio, image")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1481,23 +1517,119 @@ func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
func TestDefaultMediaProxyResourceTypes(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestMediaProxyHTTPClientTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_HTTP_CLIENT_TIMEOUT", "24")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultMediaProxyHTTPClientTimeoutValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultMediaProxyHTTPClientTimeout
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestMediaProxyCustomURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
}
}
func TestMediaProxyPrivateKey(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected MEDIA_PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
@ -1508,30 +1640,31 @@ func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
}
expected := []string{"image"}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
expectedProxyOption := "all"
result := opts.ProxyOption()
result := opts.MediaProxyMode()
if result != expectedProxyOption {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
}
}
func TestDefaultProxyMediaTypes(t *testing.T) {
func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGE_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@ -1539,25 +1672,73 @@ func TestDefaultProxyMediaTypes(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_IMAGE_URL value, got %q instead of %q`, result, expected)
}
}
if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
func TestProxyURLOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_URL value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.ProxyMediaTypes() {
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestProxyHTTPClientTimeout(t *testing.T) {
func TestProxyOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "all"
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
}
}
func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
@ -1566,29 +1747,26 @@ func TestProxyHTTPClientTimeout(t *testing.T) {
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.ProxyHTTPClientTimeout()
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultProxyHTTPClientTimeoutValue(t *testing.T) {
func TestProxyPrivateKeyOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_PRIVATE_KEY", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultProxyHTTPClientTimeout
result := opts.ProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
@ -1843,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
}
}
func TestFetchNebulaWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchNebulaWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
func TestFetchOdyseeWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
@ -1909,7 +2105,7 @@ func TestParseConfigDumpOutput(t *testing.T) {
t.Fatal(err)
}
if _, err := tmpfile.Write([]byte(serialized)); err != nil {
if _, err := tmpfile.WriteString(serialized); err != nil {
t.Fatal(err)
}

View File

@ -4,12 +4,12 @@
package config // import "miniflux.app/v2/internal/config"
import (
"crypto/rand"
"fmt"
"sort"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
@ -27,8 +27,9 @@ const (
defaultBaseURL = "http://localhost"
defaultRootURL = "http://localhost"
defaultBasePath = ""
defaultWorkerPoolSize = 5
defaultWorkerPoolSize = 16
defaultPollingFrequency = 60
defaultForceRefreshInterval = 30
defaultBatchSize = 100
defaultPollingScheduler = "round_robin"
defaultSchedulerEntryFrequencyMinInterval = 5
@ -50,10 +51,12 @@ const (
defaultCleanupArchiveUnreadDays = 180
defaultCleanupArchiveBatchSize = 10000
defaultCleanupRemoveSessionsDays = 30
defaultProxyHTTPClientTimeout = 120
defaultProxyOption = "http-only"
defaultProxyMediaTypes = "image"
defaultProxyUrl = ""
defaultMediaProxyHTTPClientTimeout = 120
defaultMediaProxyMode = "http-only"
defaultMediaResourceTypes = "image"
defaultMediaProxyURL = ""
defaultFilterEntryMaxAgeDays = 0
defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
@ -122,6 +125,7 @@ type Options struct {
cleanupArchiveBatchSize int
cleanupRemoveSessionsDays int
pollingFrequency int
forceRefreshInterval int
batchSize int
pollingScheduler string
schedulerEntryFrequencyMinInterval int
@ -133,12 +137,14 @@ type Options struct {
createAdmin bool
adminUsername string
adminPassword string
proxyHTTPClientTimeout int
proxyOption string
proxyMediaTypes []string
proxyUrl string
mediaProxyHTTPClientTimeout int
mediaProxyMode string
mediaProxyResourceTypes []string
mediaProxyCustomURL string
fetchNebulaWatchTime bool
fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int
youTubeEmbedUrlOverride string
oauth2UserCreationAllowed bool
oauth2ClientID string
@ -163,15 +169,12 @@ type Options struct {
metricsPassword string
watchdog bool
invidiousInstance string
proxyPrivateKey []byte
mediaProxyPrivateKey []byte
webAuthn bool
}
// NewOptions returns Options with default values.
func NewOptions() *Options {
randomKey := make([]byte, 16)
rand.Read(randomKey)
return &Options{
HTTPS: defaultHTTPS,
logFile: defaultLogFile,
@ -200,6 +203,7 @@ func NewOptions() *Options {
cleanupArchiveBatchSize: defaultCleanupArchiveBatchSize,
cleanupRemoveSessionsDays: defaultCleanupRemoveSessionsDays,
pollingFrequency: defaultPollingFrequency,
forceRefreshInterval: defaultForceRefreshInterval,
batchSize: defaultBatchSize,
pollingScheduler: defaultPollingScheduler,
schedulerEntryFrequencyMinInterval: defaultSchedulerEntryFrequencyMinInterval,
@ -209,10 +213,12 @@ func NewOptions() *Options {
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin,
proxyHTTPClientTimeout: defaultProxyHTTPClientTimeout,
proxyOption: defaultProxyOption,
proxyMediaTypes: []string{defaultProxyMediaTypes},
proxyUrl: defaultProxyUrl,
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout,
mediaProxyMode: defaultMediaProxyMode,
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
mediaProxyCustomURL: defaultMediaProxyURL,
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
@ -239,7 +245,7 @@ func NewOptions() *Options {
metricsPassword: defaultMetricsPassword,
watchdog: defaultWatchdog,
invidiousInstance: defaultInvidiousInstance,
proxyPrivateKey: randomKey,
mediaProxyPrivateKey: crypto.GenerateRandomBytes(16),
webAuthn: defaultWebAuthn,
}
}
@ -378,6 +384,11 @@ func (o *Options) PollingFrequency() int {
return o.pollingFrequency
}
// ForceRefreshInterval returns the force refresh interval
func (o *Options) ForceRefreshInterval() int {
return o.forceRefreshInterval
}
// BatchSize returns the number of feeds to send for background processing.
func (o *Options) BatchSize() int {
return o.batchSize
@ -478,30 +489,41 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// FetchNebulaWatchTime returns true if the Nebula video duration
// should be fetched and used as a reading time.
func (o *Options) FetchNebulaWatchTime() bool {
return o.fetchNebulaWatchTime
}
// FetchOdyseeWatchTime returns true if the Odysee video duration
// should be fetched and used as a reading time.
func (o *Options) FetchOdyseeWatchTime() bool {
return o.fetchOdyseeWatchTime
}
// ProxyOption returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) ProxyOption() string {
return o.proxyOption
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) MediaProxyMode() string {
return o.mediaProxyMode
}
// ProxyMediaTypes returns a slice of media types to proxy.
func (o *Options) ProxyMediaTypes() []string {
return o.proxyMediaTypes
// MediaProxyResourceTypes returns a slice of resource types to proxy.
func (o *Options) MediaProxyResourceTypes() []string {
return o.mediaProxyResourceTypes
}
// ProxyUrl returns a string of a URL to use to proxy image requests
func (o *Options) ProxyUrl() string {
return o.proxyUrl
// MediaCustomProxyURL returns the custom proxy URL for medias.
func (o *Options) MediaCustomProxyURL() string {
return o.mediaProxyCustomURL
}
// ProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) ProxyHTTPClientTimeout() int {
return o.proxyHTTPClientTimeout
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) MediaProxyHTTPClientTimeout() int {
return o.mediaProxyHTTPClientTimeout
}
// MediaProxyPrivateKey returns the private key used by the media proxy.
func (o *Options) MediaProxyPrivateKey() []byte {
return o.mediaProxyPrivateKey
}
// HasHTTPService returns true if the HTTP service is enabled.
@ -597,16 +619,16 @@ func (o *Options) InvidiousInstance() string {
return o.invidiousInstance
}
// ProxyPrivateKey returns the private key used by the media proxy
func (o *Options) ProxyPrivateKey() []byte {
return o.proxyPrivateKey
}
// WebAuthn returns true if WebAuthn logins are supported
func (o *Options) WebAuthn() bool {
return o.webAuthn
}
// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
func (o *Options) FilterEntryMaxAgeDays() int {
return o.filterEntryMaxAgeDays
}
// SortedOptions returns options as a list of key value pairs, sorted by keys.
func (o *Options) SortedOptions(redactSecret bool) []*Option {
var keyValues = map[string]interface{}{
@ -632,7 +654,9 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"DISABLE_HSTS": !o.hsts,
"DISABLE_HTTP_SERVICE": !o.httpService,
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
"HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
@ -663,13 +687,14 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
"POLLING_FREQUENCY": o.pollingFrequency,
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
"POLLING_SCHEDULER": o.pollingScheduler,
"PROXY_HTTP_CLIENT_TIMEOUT": o.proxyHTTPClientTimeout,
"PROXY_MEDIA_TYPES": o.proxyMediaTypes,
"PROXY_OPTION": o.proxyOption,
"PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
"PROXY_URL": o.proxyUrl,
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
"MEDIA_PROXY_RESOURCE_TYPES": o.mediaProxyResourceTypes,
"MEDIA_PROXY_MODE": o.mediaProxyMode,
"MEDIA_PROXY_PRIVATE_KEY": redactSecretValue(string(o.mediaProxyPrivateKey), redactSecret),
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL,
"ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations,
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,

View File

@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
@ -56,7 +57,7 @@ func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
@ -87,6 +88,7 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.logFormat = parsedValue
}
case "DEBUG":
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
parsedValue := parseBool(value, defaultDebug)
if parsedValue {
p.opts.logLevel = "debug"
@ -112,6 +114,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
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":
@ -142,6 +146,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "FORCE_REFRESH_INTERVAL":
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
case "BATCH_SIZE":
p.opts.batchSize = parseInt(value, defaultBatchSize)
case "POLLING_SCHEDULER":
@ -156,20 +162,41 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
// kept for compatibility purpose
case "PROXY_IMAGES":
p.opts.proxyOption = parseString(value, defaultProxyOption)
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.proxyHTTPClientTimeout = parseInt(value, defaultProxyHTTPClientTimeout)
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "PROXY_OPTION":
p.opts.proxyOption = parseString(value, defaultProxyOption)
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "MEDIA_PROXY_MODE":
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_MEDIA_TYPES":
p.opts.proxyMediaTypes = parseStringList(value, []string{defaultProxyMediaTypes})
// kept for compatibility purpose
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "MEDIA_PROXY_RESOURCE_TYPES":
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "PROXY_IMAGE_URL":
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_URL":
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_PRIVATE_KEY":
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
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 = parseString(value, defaultMediaProxyURL)
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME":
@ -232,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
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":
@ -242,10 +271,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
case "WEBAUTHN":
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
}

View File

@ -7,6 +7,7 @@ import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
@ -16,8 +17,7 @@ import (
// HashFromBytes returns a SHA-256 checksum of the input.
func HashFromBytes(value []byte) string {
sum := sha256.Sum256(value)
return fmt.Sprintf("%x", sum)
return fmt.Sprintf("%x", sha256.Sum256(value))
}
// Hash returns a SHA-256 checksum of a string.
@ -55,3 +55,12 @@ func GenerateSHA256Hmac(secret string, data []byte) string {
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
func GenerateUUID() string {
b := GenerateRandomBytes(16)
return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func ConstantTimeCmp(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

View File

@ -834,4 +834,73 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkace_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkace_url text default '';
ALTER TABLE integrations ADD COLUMN linkace_api_key text default '';
ALTER TABLE integrations ADD COLUMN linkace_tags text default '';
ALTER TABLE integrations ADD COLUMN linkace_is_private bool default 't';
ALTER TABLE integrations ADD COLUMN linkace_check_disabled bool default 't';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkwarden_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN linkwarden_url text default '';
ALTER TABLE integrations ADD COLUMN linkwarden_api_key text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN readeck_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN readeck_only_url bool default 'f';
ALTER TABLE integrations ADD COLUMN readeck_url text default '';
ALTER TABLE integrations ADD COLUMN readeck_api_key text default '';
ALTER TABLE integrations ADD COLUMN readeck_labels text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// the WHERE part speed-up the request a lot
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
}

View File

@ -13,8 +13,8 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/storage"
"github.com/gorilla/mux"
@ -324,7 +324,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
FeedID: entry.FeedID,
Title: entry.Title,
Author: entry.Author,
HTML: proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content),
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content),
URL: entry.URL,
IsSaved: isSaved,
IsRead: isRead,

View File

@ -18,8 +18,8 @@ import (
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/fetcher"
mff "miniflux.app/v2/internal/reader/handler"
mfs "miniflux.app/v2/internal/reader/subscription"
@ -265,9 +265,10 @@ func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
}
func getStream(streamID string, userID int64) (Stream, error) {
if strings.HasPrefix(streamID, FeedPrefix) {
switch {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix) {
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
id = strings.TrimPrefix(id, StreamPrefix)
switch id {
@ -288,15 +289,15 @@ func getStream(streamID string, userID int64) (Stream, error) {
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil
} else if streamID == "" {
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
@ -382,7 +383,7 @@ func getItemIDs(r *http.Request) ([]int64, error) {
return itemIDs, nil
}
func checkOutputFormat(w http.ResponseWriter, r *http.Request) error {
func checkOutputFormat(r *http.Request) error {
var output string
if r.Method == http.MethodPost {
err := r.ParseForm()
@ -736,11 +737,12 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
}
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
if category.ID == "" {
switch {
case category.ID == "":
return store.FirstCategory(userID)
} else if store.CategoryTitleExists(userID, category.ID) {
case store.CategoryTitleExists(userID, category.ID):
return store.CategoryByTitle(userID, category.ID)
} else {
default:
catRequest := model.CategoryRequest{
Title: category.ID,
}
@ -764,7 +766,7 @@ func subscribe(newFeed Stream, category Stream, title string, store *storage.Sto
}
created, localizedError := mff.CreateFeed(store, userID, &feedRequest)
if err != nil {
if localizedError != nil {
return nil, localizedError.Error()
}
@ -908,8 +910,8 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -960,7 +962,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
if len(entries) == 0 {
json.ServerError(w, r, fmt.Errorf("googlereader: no items returned from the database"))
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
return
}
@ -984,7 +986,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, len(entry.Enclosures))
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
enclosures = append(enclosures, contentItemEnclosure{URL: enclosure.URL, Type: enclosure.MimeType})
}
@ -1001,14 +1003,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories = append(categories, userStarred)
}
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
proxyOption := config.Opts.MediaProxyMode()
for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
@ -1019,10 +1021,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
ID: fmt.Sprintf(EntryIDLong, entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
Published: entry.Date.Unix(),
Updated: entry.Date.Unix(),
Updated: entry.ChangedAt.Unix(),
Categories: categories,
Canonical: []contentHREF{
{
@ -1170,7 +1172,7 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil {
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1205,8 +1207,8 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1224,7 +1226,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: "", //TODO Icons are only base64 encode in DB yet
IconURL: "", // TODO: Icons are base64 encoded in the DB.
})
}
json.OK(w, r, result)
@ -1251,8 +1253,8 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()),
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, err)
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1276,8 +1278,8 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID),
)
if err := checkOutputFormat(w, r); err != nil {
json.ServerError(w, r, fmt.Errorf("googlereader: output only as json supported"))
if err := checkOutputFormat(r); err != nil {
json.BadRequest(w, r, err)
return
}
@ -1477,8 +1479,7 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets {
switch s.Type {
case ReadStream:
if s.Type == ReadStream {
builder.WithoutStatus(model.EntryStatusRead)
}
}

View File

@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
// Cookie duration in days.
cookieDuration = 30
)
// New creates a new cookie.
@ -25,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(cookieDuration * 24 * time.Hour),
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
SameSite: http.SameSiteLaxMode,
}
}

View File

@ -37,14 +37,10 @@ const (
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
value, valid := v.(model.WebAuthnSession)
if !valid {
return nil
if value, valid := v.(model.WebAuthnSession); valid {
return &value
}
return &value
}
return nil
}
@ -151,39 +147,27 @@ func ClientIP(r *http.Request) string {
func getContextStringValue(r *http.Request, key ContextKey) string {
if v := r.Context().Value(key); v != nil {
value, valid := v.(string)
if !valid {
return ""
if value, valid := v.(string); valid {
return value
}
return value
}
return ""
}
func getContextBoolValue(r *http.Request, key ContextKey) bool {
if v := r.Context().Value(key); v != nil {
value, valid := v.(bool)
if !valid {
return false
if value, valid := v.(bool); valid {
return value
}
return value
}
return false
}
func getContextInt64Value(r *http.Request, key ContextKey) int64 {
if v := r.Context().Value(key); v != nil {
value, valid := v.(int64)
if !valid {
return 0
if value, valid := v.(int64); valid {
return value
}
return value
}
return 0
}

View File

@ -12,6 +12,8 @@ import (
"net/http"
"strings"
"time"
"github.com/andybalholm/brotli"
)
const compressionThreshold = 1024
@ -96,7 +98,6 @@ func (b *Builder) Write() {
}
func (b *Builder) writeHeaders() {
b.headers["X-XSS-Protection"] = "1; mode=block"
b.headers["X-Content-Type-Options"] = "nosniff"
b.headers["X-Frame-Options"] = "DENY"
b.headers["Referrer-Policy"] = "no-referrer"
@ -111,8 +112,15 @@ func (b *Builder) writeHeaders() {
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
defer brotliWriter.Close()
brotliWriter.Write(data)
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()

View File

@ -28,7 +28,6 @@ func TestResponseHasCommonHeaders(t *testing.T) {
resp := w.Result()
headers := map[string]string{
"X-XSS-Protection": "1; mode=block",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
@ -229,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
func TestBuildResponseWithBrotliCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
@ -246,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "br"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "gzip"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {

View File

@ -10,13 +10,17 @@ import (
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkace"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/linkwarden"
"miniflux.app/v2/internal/integration/matrixbot"
"miniflux.app/v2/internal/integration/notion"
"miniflux.app/v2/internal/integration/nunuxkeeper"
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
"miniflux.app/v2/internal/integration/shiori"
@ -180,6 +184,30 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.LinkAceEnabled {
slog.Debug("Sending entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linkace.NewClient(
userIntegrations.LinkAceURL,
userIntegrations.LinkAceAPIKey,
userIntegrations.LinkAceTags,
userIntegrations.LinkAcePrivate,
userIntegrations.LinkAceCheckDisabled,
)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkdingEnabled {
slog.Debug("Sending entry to Linkding",
slog.Int64("user_id", userIntegrations.UserID),
@ -203,6 +231,50 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.LinkwardenEnabled {
slog.Debug("Sending entry to linkwarden",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := linkwarden.NewClient(
userIntegrations.LinkwardenURL,
userIntegrations.LinkwardenAPIKey,
)
if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Linkwarden",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ReadeckEnabled {
slog.Debug("Sending entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := readeck.NewClient(
userIntegrations.ReadeckURL,
userIntegrations.ReadeckAPIKey,
userIntegrations.ReadeckLabels,
userIntegrations.ReadeckOnlyURL,
)
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
slog.Error("Unable to send entry to Readeck",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ReadwiseEnabled {
slog.Debug("Sending entry to Readwise",
slog.Int64("user_id", userIntegrations.UserID),
@ -288,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
}
}
if userIntegrations.OmnivoreEnabled {
slog.Debug("Sending entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
@ -305,6 +378,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
}
}
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.

View File

@ -0,0 +1,83 @@
package linkace
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
tags string
private bool
checkDisabled bool
}
func NewClient(baseURL, apiKey, tags string, private bool, checkDisabled bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, private: private, checkDisabled: checkDisabled}
}
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("linkace: missing base URL or API key")
}
tagsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
if err != nil {
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&createItemRequest{
Url: entryURL,
Title: entryTitle,
Tags: strings.FieldsFunc(c.tags, tagsSplitFn),
Private: c.private,
CheckDisabled: c.checkDisabled,
})
if err != nil {
return fmt.Errorf("linkace: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkace: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkace: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linkace: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type createItemRequest struct {
Title string `json:"title,omitempty"`
Url string `json:"url"`
Tags []string `json:"tags,omitempty"`
Private bool `json:"is_private,omitempty"`
CheckDisabled bool `json:"check_disabled,omitempty"`
}

View File

@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package linkwarden // import "miniflux.app/v2/internal/integration/linkwarden"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("linkwarden: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
if err != nil {
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&linkwardenBookmark{
Url: entryURL,
Name: "",
Description: "",
Tags: []string{},
Collection: map[string]interface{}{},
})
if err != nil {
return fmt.Errorf("linkwarden: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("linkwarden: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "__Secure-next-auth.session-token", Value: c.apiKey})
request.AddCookie(&http.Cookie{Name: "next-auth.session-token", Value: c.apiKey})
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("linkwarden: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("linkwarden: unable to create link: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type linkwardenBookmark struct {
Url string `json:"url"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
Collection map[string]interface{} `json:"collection"`
}

View File

@ -10,7 +10,7 @@ import (
"miniflux.app/v2/internal/model"
)
// PushEntry pushes entries to matrix chat using integration settings provided
// PushEntries pushes entries to matrix chat using integration settings provided
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
client := NewClient(matrixBaseURL)
discovery, err := client.DiscoverEndpoints()
@ -28,7 +28,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixU
for _, entry := range entries {
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href="%s">%s</a></li>`, feed.Title, entry.URL, entry.Title))
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href=%q>%s</a></li>`, feed.Title, entry.URL, entry.Title))
}
_, err = client.SendFormattedTextMessage(

View File

@ -11,8 +11,7 @@ import (
"net/http"
"time"
"github.com/google/uuid"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version"
)
@ -79,7 +78,7 @@ func (c *client) SaveUrl(url string) error {
"query": mutation,
"variables": map[string]interface{}{
"input": map[string]interface{}{
"clientRequestId": uuid.New().String(),
"clientRequestId": crypto.GenerateUUID(),
"source": "api",
"url": url,
},

View File

@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
token string
collectionID string
tags []string
}
func NewClient(token, collectionID, tags string) *Client {
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
}
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
if c.token == "" {
return fmt.Errorf("raindrop: missing token")
}
var request *http.Request
requestBodyJson, err := json.Marshal(&raindrop{
Link: entryURL,
Title: entryTitle,
Collection: collection{Id: c.collectionID},
Tags: c.tags,
})
if err != nil {
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("raindrop: 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.token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("raindrop: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
}
return nil
}
type raindrop struct {
Link string `json:"link"`
Title string `json:"title"`
Collection collection `json:"collection,omitempty"`
Tags []string `json:"tags"`
}
type collection struct {
Id string `json:"$id"`
}

View File

@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package readeck // import "miniflux.app/v2/internal/integration/readeck"
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
baseURL string
apiKey string
labels string
onlyURL bool
}
func NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, labels: labels, onlyURL: onlyURL}
}
func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("readeck: missing base URL or API key")
}
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
if err != nil {
return fmt.Errorf(`readeck: invalid API endpoint: %v`, err)
}
labelsSplitFn := func(c rune) bool {
return c == ',' || c == ' '
}
labelsSplit := strings.FieldsFunc(c.labels, labelsSplitFn)
var request *http.Request
if c.onlyURL {
requestBodyJson, err := json.Marshal(&readeckBookmark{
Url: entryURL,
Title: entryTitle,
Labels: labelsSplit,
})
if err != nil {
return fmt.Errorf("readeck: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBodyJson))
if err != nil {
return fmt.Errorf("readeck: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
} else {
requestBody := new(bytes.Buffer)
multipartWriter := multipart.NewWriter(requestBody)
urlPart, err := multipartWriter.CreateFormField("url")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry url): %v", err)
}
urlPart.Write([]byte(entryURL))
titlePart, err := multipartWriter.CreateFormField("title")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry title): %v", err)
}
titlePart.Write([]byte(entryTitle))
featurePart, err := multipartWriter.CreateFormField("feature_find_main")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (feature_find_main flag): %v", err)
}
featurePart.Write([]byte("false")) // false to disable readability
for _, label := range labelsSplit {
labelPart, err := multipartWriter.CreateFormField("labels")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry labels): %v", err)
}
labelPart.Write([]byte(label))
}
contentBodyHeader, err := json.Marshal(&partContentHeader{
Url: entryURL,
ContentHeader: contentHeader{ContentType: "text/html"},
})
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)
}
contentPart, err := multipartWriter.CreateFormFile("resource", "blob")
if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content): %v", err)
}
contentPart.Write(contentBodyHeader)
contentPart.Write([]byte("\n"))
contentPart.Write([]byte(entryContent))
err = multipartWriter.Close()
if err != nil {
return fmt.Errorf("readeck: unable to encode request body: %v", err)
}
request, err = http.NewRequest(http.MethodPost, apiEndpoint, requestBody)
if err != nil {
return fmt.Errorf("readeck: unable to create request: %v", err)
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
}
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("readeck: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("readeck: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type readeckBookmark struct {
Url string `json:"url"`
Title string `json:"title"`
Labels []string `json:"labels,omitempty"`
}
type contentHeader struct {
ContentType string `json:"content-type"`
}
type partContentHeader struct {
Url string `json:"url"`
ContentHeader contentHeader `json:"headers"`
}

View File

@ -1,15 +1,20 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package rssbridge // import "miniflux.app/integration/rssbridge"
package rssbridge // import "miniflux.app/v2/internal/integration/rssbridge"
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const defaultClientTimeout = 30 * time.Second
type Bridge struct {
URL string `json:"url"`
BridgeMeta BridgeMeta `json:"bridgeMeta"`
@ -19,30 +24,61 @@ type BridgeMeta struct {
Name string `json:"name"`
}
func DetectBridges(rssbridgeURL, websiteURL string) (bridgeResponse []Bridge, err error) {
u, err := url.Parse(rssbridgeURL)
func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
endpointURL, err := url.Parse(rssBridgeURL)
if err != nil {
return nil, err
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
}
values := u.Query()
values := endpointURL.Query()
values.Add("action", "findfeed")
values.Add("format", "atom")
values.Add("url", websiteURL)
u.RawQuery = values.Encode()
endpointURL.RawQuery = values.Encode()
response, err := http.Get(u.String())
slog.Debug("Detecting RSS bridges", slog.String("url", endpointURL.String()))
request, err := http.NewRequest(http.MethodGet, endpointURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to excute request: %w", err)
return nil, fmt.Errorf("RSS-Bridge: unable to create request: %w", err)
}
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to execute request: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return
return nil, nil
}
if response.StatusCode > 400 {
return nil, fmt.Errorf("RSS-Bridge: unexpected status code %d", response.StatusCode)
}
var bridgeResponse []*Bridge
if err := json.NewDecoder(response.Body).Decode(&bridgeResponse); err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to decode bridge response: %w", err)
}
return
for _, bridge := range bridgeResponse {
slog.Debug("Found RSS bridge",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
if strings.HasPrefix(bridge.URL, "./") {
bridge.URL = rssBridgeURL + bridge.URL[2:]
slog.Debug("Rewrited relative RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
}
}
return bridgeResponse, nil
}

View File

@ -11,7 +11,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"miniflux.app/v2/internal/urllib"
@ -74,14 +73,15 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
}
func (c *Client) generateBearerToken() string {
header := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(`{"typ":"JWT", "alg":"HS256"}`)), "=")
payload := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat": %d}`, time.Now().Unix()))), "=")
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
data := header + "." + payload
mac := hmac.New(sha512.New, []byte(c.apiSecret))
mac.Write([]byte(header + "." + payload))
signature := strings.TrimRight(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=")
mac.Write([]byte(data))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return header + "." + payload + "." + signature
return data + "." + signature
}
type addLinkRequest struct {

View File

@ -57,6 +57,7 @@ func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error {
ID: entry.Feed.ID,
UserID: entry.Feed.UserID,
CategoryID: entry.Feed.Category.ID,
Category: &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title},
FeedURL: entry.Feed.FeedURL,
SiteURL: entry.Feed.SiteURL,
Title: entry.Feed.Title,
@ -94,13 +95,13 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
Tags: entry.Tags,
})
}
return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{
EventType: NewEntriesEventType,
Feed: &WebhookFeed{
ID: feed.ID,
UserID: feed.UserID,
CategoryID: feed.Category.ID,
Category: &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title},
FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL,
Title: feed.Title,
@ -145,13 +146,19 @@ func (c *Client) makeRequest(eventType string, payload any) error {
}
type WebhookFeed struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CategoryID int64 `json:"category_id"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CategoryID int64 `json:"category_id"`
Category *WebhookCategory `json:"category,omitempty"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
CheckedAt time.Time `json:"checked_at"`
}
type WebhookCategory struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type WebhookEntry struct {

View File

@ -20,7 +20,7 @@ var translationFiles embed.FS
// LoadCatalogMessages loads and parses all translations encoded in JSON.
func LoadCatalogMessages() error {
var err error
defaultCatalog = make(catalog)
defaultCatalog = make(catalog, len(AvailableLanguages()))
for language := range AvailableLanguages() {
defaultCatalog[language], err = loadTranslationFile(language)

View File

@ -53,11 +53,11 @@ func TestAllKeysHaveValue(t *testing.T) {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q have an empty string as value`, k, language)
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
}
case []string:
case []any:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q have an empty list as value`, k, language)
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
}
}
}
@ -88,3 +88,20 @@ func TestMissingTranslations(t *testing.T) {
}
}
}
func TestTranslationFilePluralForms(t *testing.T) {
for language := range AvailableLanguages() {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
for k, v := range messages {
if value, ok := v.([]any); ok {
if len(value) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
}
}
}
}
}

View File

@ -3,6 +3,27 @@
package locale // import "miniflux.app/v2/internal/locale"
var numberOfPluralFormsPerLanguage = map[string]int{
"en_US": 2,
"es_ES": 2,
"fr_FR": 2,
"de_DE": 2,
"pl_PL": 3,
"pt_BR": 2,
"zh_CN": 1,
"zh_TW": 1,
"nl_NL": 2,
"ru_RU": 3,
"it_IT": 2,
"ja_JP": 1,
"tr_TR": 2,
"el_EL": 2,
"fi_FI": 2,
"hi_IN": 2,
"uk_UA": 3,
"id_ID": 1,
}
// AvailableLanguages returns the list of available languages.
func AvailableLanguages() map[string]string {
return map[string]string{

View File

@ -3,69 +3,65 @@
package locale // import "miniflux.app/v2/internal/locale"
type pluralFormFunc func(n int) int
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
var pluralForms = map[string]pluralFormFunc{
var pluralForms = map[string](func(n int) int){
// nplurals=2; plural=(n != 1);
"default": func(n int) int {
if n != 1 {
return 1
}
return 0
},
// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
"ar_AR": func(n int) int {
if n == 0 {
switch {
case n == 0:
return 0
}
if n == 1 {
case n == 1:
return 1
}
if n == 2 {
case n == 2:
return 2
}
if n%100 >= 3 && n%100 <= 10 {
case n%100 >= 3 && n%100 <= 10:
return 3
}
if n%100 >= 11 {
case n%100 >= 11:
return 4
}
return 5
},
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
"cs_CZ": func(n int) int {
if n == 1 {
switch {
case n == 1:
return 0
}
if n >= 2 && n <= 4 {
case n >= 2 && n <= 4:
return 1
}
return 2
},
// nplurals=2; plural=(n > 1);
"fr_FR": func(n int) int {
if n > 1 {
return 1
}
return 0
},
// nplurals=1; plural=0;
"id_ID": func(n int) int {
return 0
},
// nplurals=1; plural=0;
"ja_JP": func(n int) int {
return 0
},
// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
"pl_PL": func(n int) int {
if n == 1 {
switch {
case n == 1:
return 0
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
}
return 2
},
// nplurals=2; plural=(n > 1);
@ -76,23 +72,31 @@ var pluralForms = map[string]pluralFormFunc{
return 0
},
"ru_RU": pluralFormRuSrUa,
// nplurals=2; plural=(n > 1);
"tr_TR": func(n int) int {
if n > 1 {
return 1
}
return 0
},
"uk_UA": pluralFormRuSrUa,
"sr_RS": pluralFormRuSrUa,
// nplurals=1; plural=0;
"zh_CN": func(n int) int {
return 0
},
"zh_TW": func(n int) int {
return 0
},
}
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
func pluralFormRuSrUa(n int) int {
if n%10 == 1 && n%100 != 11 {
switch {
case n%10 == 1 && n%100 != 11:
return 0
}
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
}
return 2
}

View File

@ -25,6 +25,20 @@ func TestPluralRules(t *testing.T) {
2: 1,
5: 2,
},
"fr_FR": {
1: 0,
2: 1,
5: 1,
},
"id_ID": {
1: 0,
5: 0,
},
"ja_JP": {
1: 0,
2: 0,
5: 0,
},
"pl_PL": {
1: 0,
2: 1,
@ -45,10 +59,24 @@ func TestPluralRules(t *testing.T) {
2: 1,
5: 2,
},
"tr_TR": {
1: 0,
2: 1,
5: 1,
},
"uk_UA": {
1: 0,
2: 1,
5: 2,
},
"zh_CN": {
1: 0,
5: 0,
},
"zh_TW": {
1: 0,
5: 0,
},
}
for rule, values := range scenarios {

View File

@ -10,6 +10,15 @@ type Printer struct {
language string
}
func (p *Printer) Print(key string) string {
if str, ok := defaultCatalog[p.language][key]; ok {
if translation, ok := str.(string); ok {
return translation
}
}
return key
}
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...interface{}) string {
var translation string

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Sind Sie sicher?",
"confirm.question.refresh": "Möchten Sie eine erzwungene Aktualisierung durchführen?",
"confirm.yes": "ja",
@ -18,6 +19,8 @@
"action.home_screen": "Zum Startbildschirm hinzufügen",
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
"tooltip.logged_user": "Angemeldet als %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Ungelesen",
"menu.starred": "Lesezeichen",
"menu.history": "Verlauf",
@ -32,6 +35,7 @@
"menu.about": "Über",
"menu.export": "Exportieren",
"menu.import": "Importieren",
"menu.search": "Suche",
"menu.create_category": "Kategorie anlegen",
"menu.mark_page_as_read": "Diese Seite als gelesen markieren",
"menu.mark_all_as_read": "Alle als gelesen markieren",
@ -50,6 +54,7 @@
"menu.shared_entries": "Geteilte Artikel",
"search.label": "Suche",
"search.placeholder": "Suche...",
"search.submit": "Search",
"pagination.next": "Nächste",
"pagination.previous": "Vorherige",
"entry.status.unread": "Ungelesen",
@ -81,11 +86,27 @@
"entry.estimated_reading_time": [
"%d Minute zu lesen",
"%d Minuten zu lesen"
],
],
"entry.tags.label": "Stichworte:",
"page.shared_entries.title": "Geteilte Artikel",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Ungelesen",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Lesezeichen",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Kategorien",
"page.categories.no_feed": "Kein Abonnement.",
"page.categories.entries": "Artikel",
@ -94,21 +115,28 @@
"Es gibt %d Abonnement.",
"Es gibt %d Abonnements."
],
"page.categories.unread_counter": "Anzahl der ungelesenen Artikel",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "Neue Kategorie",
"page.new_user.title": "Neuer Benutzer",
"page.edit_category.title": "Kategorie bearbeiten: %s",
"page.edit_user.title": "Benutzer bearbeiten: %s",
"page.feeds.title": "Abonnements",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Letzte Aktualisierung:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Anzahl der ungelesenen Artikel",
"page.feeds.next_check": "Nächste Aktualisierung:",
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
"page.feeds.error_count": [
"%d Fehler",
"%d Fehler"
],
"page.history.title": "Verlauf",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Importieren",
"page.search.title": "Suchergebnisse",
"page.about.title": "Über",
@ -118,12 +146,12 @@
"page.about.author": "Autor:",
"page.about.license": "Lizenz:",
"page.about.global_config_options": "Globale Konfigurationsoptionen",
"page.about.postgres_version": "Postgres Version:",
"page.about.go_version": "Go Version:",
"page.about.postgres_version": "Postgres-Version:",
"page.about.go_version": "Go-Version:",
"page.add_feed.title": "Neues Abonnement",
"page.add_feed.no_category": "Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Abonnement suchen",
"page.add_feed.submit": "Abonnement finden",
"page.add_feed.legend.advanced_options": "Erweiterte Optionen",
"page.add_feed.choose_feed": "Abonnement auswählen",
"page.edit_feed.title": "Abonnement bearbeiten: %s",
@ -132,7 +160,7 @@
"page.edit_feed.etag_header": "ETag-Kopfzeile:",
"page.edit_feed.no_header": "Nicht verfügbar",
"page.edit_feed.last_parsing_error": "Letzter Analysefehler",
"page.entry.attachments": "Anlagen",
"page.entry.attachments": "Anhänge",
"page.keyboard_shortcuts.title": "Tastenkürzel",
"page.keyboard_shortcuts.subtitle.sections": "Navigation zwischen den Menüpunkten",
"page.keyboard_shortcuts.subtitle.items": "Navigation zwischen den Artikeln",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
"page.keyboard_shortcuts.go_to_bottom_item": "Gehen Sie zum untersten Element",
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
"page.keyboard_shortcuts.open_item": "Gewählten Artikel öffnen",
"page.keyboard_shortcuts.open_original": "Original-Artikel öffnen",
"page.keyboard_shortcuts.open_original_same_window": "Öffne den Original-Link in der aktuellen Registerkarte",
@ -162,10 +192,10 @@
"page.keyboard_shortcuts.download_content": "Vollständigen Inhalt herunterladen",
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen hinzufügen/entfernen",
"page.keyboard_shortcuts.save_article": "Artikel speichern",
"page.keyboard_shortcuts.scroll_item_to_top": "Artikel nach oben blättern",
"page.keyboard_shortcuts.scroll_item_to_top": "Artikel an den Anfang blättern",
"page.keyboard_shortcuts.remove_feed": "Dieses Abonnement entfernen",
"page.keyboard_shortcuts.go_to_search": "Fokus auf das Suchformular setzen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikel Anhänge öffnen/schließen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikelanhänge öffnen/schließen",
"page.keyboard_shortcuts.close_modal": "Liste der Tastenkürzel schließen",
"page.users.title": "Benutzer",
"page.users.username": "Benutzername",
@ -176,15 +206,15 @@
"page.users.last_login": "Letzte Anmeldung",
"page.users.is_admin": "Administrator",
"page.settings.title": "Einstellungen",
"page.settings.link_google_account": "Google Konto verknüpfen",
"page.settings.unlink_google_account": "Google Konto Verknüpfung entfernen",
"page.settings.link_oidc_account": "OpenID Connect Konto verknüpfen",
"page.settings.unlink_oidc_account": "OpenID Connect Konto Verknüpfung entfernen",
"page.settings.link_google_account": "Google-Konto verknüpfen",
"page.settings.unlink_google_account": "Verknüpfung mit Google-Konto entfernen",
"page.settings.link_oidc_account": "OpenID-Connect-Konto verknüpfen",
"page.settings.unlink_oidc_account": "Verknüpfung mit OpenID-Connect-Konto entfernen",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.actions": "Aktionen",
"page.settings.webauthn.passkey_name": "Name des Passkeys",
"page.settings.webauthn.added_on": "Hinzugefügt am",
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.settings.webauthn.delete": [
@ -194,21 +224,21 @@
"page.login.title": "Anmeldung",
"page.login.google_signin": "Anmeldung mit Google",
"page.login.oidc_signin": "Anmeldung mit OpenID Connect",
"page.login.webauthn_login": "Melden Sie sich mit dem Hauptschlüssel an",
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
"page.integrations.title": "Dienste",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpunkt",
"page.integration.miniflux_api": "Miniflux-API",
"page.integration.miniflux_api_endpoint": "API-Endpunkt",
"page.integration.miniflux_api_username": "Benutzername",
"page.integration.miniflux_api_password": "Passwort",
"page.integration.miniflux_api_password_value": "Ihr Konto Passwort",
"page.integration.miniflux_api_password_value": "Ihr Konto-Passwort",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Mit Miniflux abonnieren",
"page.integration.bookmarklet.instructions": "Ziehen Sie diesen Link in Ihre Lesezeichen.",
"page.integration.bookmarklet.help": "Dieser spezielle Link ermöglicht es, eine Webseite direkt über ein Lesezeichen im Browser zu abonnieren.",
"page.sessions.title": "Sitzungen",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP Addresse",
"page.sessions.table.ip": "IP-Addresse",
"page.sessions.table.user_agent": "Benutzeragent",
"page.sessions.table.actions": "Aktionen",
"page.sessions.table.current_session": "Aktuelle Sitzung",
@ -223,11 +253,12 @@
"page.offline.title": "Offline-Modus",
"page.offline.message": "Du bist offline",
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
"page.webauthn_rename.title": "Rename Passkey",
"page.webauthn_rename.title": "Passkey umbenennen",
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
@ -254,6 +285,13 @@
"error.unable_to_update_user": "Dieser Benutzer konnte nicht aktualisiert werden.",
"error.unable_to_update_feed": "Dieses Abonnement konnte nicht aktualisiert werden.",
"error.subscription_not_found": "Es wurden keine Abonnements gefunden.",
"error.invalid_theme": "Ungültiges Thema.",
"error.invalid_language": "Ungültige Sprache.",
"error.invalid_timezone": "Ungültige Zeitzone.",
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
"error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
"error.empty_file": "Diese Datei ist leer.",
"error.bad_credentials": "Benutzername oder Passwort ungültig.",
"error.fields_mandatory": "Alle Felder sind obligatorisch.",
@ -276,77 +314,72 @@
"error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
"error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
"error.invalid_theme": "Ungültiges Thema.",
"error.invalid_language": "Ungültige Sprache.",
"error.invalid_timezone": "Ungültige Zeitzone.",
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
"error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "Webseite-URL",
"form.feed.label.feed_url": "Abonnement-URL",
"form.feed.label.site_url": "URL der Webseite",
"form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.description": "Beschreibung",
"form.feed.label.category": "Kategorie",
"form.feed.label.crawler": "Inhalt herunterladen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.feed_username": "Benutzername des Abonnements",
"form.feed.label.feed_password": "Passwort des Abonnements",
"form.feed.label.user_agent": "Standardbenutzeragenten überschreiben",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.scraper_rules": "Extraktionsregeln",
"form.feed.label.rewrite_rules": "Umschreiberegeln",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise Service-URLs",
"form.feed.label.blocklist_rules": "Blockierregeln",
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-cache",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.general": "Allgemein",
"form.feed.fieldset.rules": "Regeln",
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
"form.feed.fieldset.integration": "Drittanbieter-Dienste",
"form.category.label.title": "Titel",
"form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.user.label.username": "Benutzername",
"form.user.label.password": "Passwort",
"form.user.label.confirmation": "Passwort Bestätigung",
"form.user.label.confirmation": "Passwortbestätigung",
"form.user.label.admin": "Administrator",
"form.prefs.label.language": "Sprache",
"form.prefs.label.timezone": "Zeitzone",
"form.prefs.label.theme": "Thema",
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entry_sorting": "Sortierung der Einträge",
"form.prefs.label.entries_per_page": "Einträge pro Seite",
"form.prefs.label.default_reading_speed": "Lesegeschwindigkeit für andere Sprachen (Wörter pro Minute)",
"form.prefs.label.cjk_reading_speed": "Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)",
"form.prefs.label.display_mode": "Anzeigemodus der Web-App (muss neu installiert werden)",
"form.prefs.select.older_first": "Älteste Artikel zuerst",
"form.prefs.select.recent_first": "Neueste Artikel zuerst",
"form.prefs.label.display_mode": "Anzeigemodus der progressiven Web-Anwendung (PWA)",
"form.prefs.select.older_first": "Ältere Einträge zuerst",
"form.prefs.select.recent_first": "Neue Einträge zuerst",
"form.prefs.select.fullscreen": "Vollbildschirm",
"form.prefs.select.standalone": "Eigenständige",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Eintrag veröffentlichte Zeit",
"form.prefs.select.created_time": "Eintrag erstellt Zeit",
"form.prefs.select.publish_time": "Artikel veröffentlichte am",
"form.prefs.select.created_time": "Artikel erstellt am",
"form.prefs.select.alphabetical": "Alphabetisch",
"form.prefs.select.unread_count": "Ungelesen zählen",
"form.prefs.select.none": "Keiner",
"form.prefs.select.unread_count": "Ungelesen",
"form.prefs.select.none": "Keine",
"form.prefs.select.tap": "Doppeltippen",
"form.prefs.select.swipe": "Wischen",
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
"form.prefs.label.entry_swipe": "Aktivieren Sie das Streichen von Einträgen auf Touchscreens",
"form.prefs.label.entry_swipe": "Aktivieren Sie das Wischen von Einträgen auf Touchscreens",
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
"form.prefs.label.entry_order": "Eintrag Sortierspalte",
"form.prefs.label.default_home_page": "Standard Startseite",
"form.prefs.label.categories_sorting_order": "Kategorien sortieren",
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
"form.prefs.label.default_home_page": "Standard-Startseite",
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
"form.import.label.file": "OPML Datei",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Fever API aktivieren",
@ -356,72 +389,90 @@
"form.integration.googlereader_activate": "Google Reader API aktivieren",
"form.integration.googlereader_username": "Google Reader Benutzername",
"form.integration.googlereader_password": "Google Reader Passwort",
"form.integration.googlereader_endpoint": "Google Reader API Endpunkt:",
"form.integration.pinboard_activate": "Artikel in Pinboard speichern",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.googlereader_endpoint": "Google Reader API-Endpunkt:",
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
"form.integration.pinboard_token": "Pinboard API-Token",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.instapaper_activate": "Artikel in Instapaper speichern",
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
"form.integration.instapaper_username": "Instapaper Benutzername",
"form.integration.instapaper_password": "Instapaper Passwort",
"form.integration.pocket_activate": "Artikel in Pocket speichern",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_activate": "Einträge in Pocket speichern",
"form.integration.pocket_consumer_key": "Pocket Verbraucher-Schlüssel",
"form.integration.pocket_access_token": "Pocket Zugangs-Token",
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket Konto",
"form.integration.wallabag_activate": "Artikel in Wallabag speichern",
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_client_secret": "Wallabag Client-Geheimnis",
"form.integration.wallabag_username": "Wallabag Benutzername",
"form.integration.wallabag_password": "Wallabag Passwort",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_activate": "Einträge in Notion speichern",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.notion_token": "Notion Geheimnis-Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Kommaseparierte Liste der Apprise service URLs",
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise service URLs",
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
"form.integration.omnivore_activate": "Artikel in Omnivore speichern",
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
"form.integration.omnivore_url": "Omnivore API-Endpunkt",
"form.integration.omnivore_api_key": "Omnivore API-Schlüssel",
"form.integration.espial_activate": "Artikel in Espial speichern",
"form.integration.espial_activate": "Einträge in Espial",
"form.integration.espial_endpoint": "Espial API-Endpunkt",
"form.integration.espial_api_key": "Espial API-Schlüssel",
"form.integration.espial_tags": "Espial tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Pushen Sie neue Artikel in den Telegram-Chat",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.espial_tags": "Espial Tags",
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
"form.integration.readwise_api_key": "Readwise Reader Zugangs-Token",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise Zugangs-Token",
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
"form.integration.telegram_bot_token": "Bot-Token",
"form.integration.telegram_chat_id": "Chat-ID",
"form.integration.telegram_topic_id": "Thema-ID",
"form.integration.telegram_bot_disable_web_page_preview": "Webseiten-Vorschau deaktivieren",
"form.integration.telegram_bot_disable_notification": "Benachrichtigungen deaktivieren",
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Artikel in Linkding speichern",
"form.integration.linkding_endpoint": "Linkding API-Endpunkt",
"form.integration.linkding_api_key": "Linkding API-Schlüssel",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.matrix_bot_activate": "Neue Artikel in die Matrix übertragen",
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
"form.integration.linkwarden_endpoint": "Linkwarden API-Endpunkt",
"form.integration.linkwarden_api_key": "Linkwarden API-Schlüssel",
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
"form.integration.matrix_bot_user": "Benutzername für Matrix",
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.shiori_activate": "Artikel in Shiori",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Artikel in Readeck speichern",
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
"form.integration.shiori_activate": "Artikel in Shiori speichern",
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
"form.integration.shiori_username": "Shiori Benutzername",
"form.integration.shiori_password": "Shiori Passwort",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.shaarli_api_secret": "Shaarli API Geheimnis",
"form.integration.webhook_activate": "Webhook aktivieren",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.webhook_secret": "Webhook Geheimnis",
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API-Schlüsselbezeichnung",
"form.submit.loading": "Lade...",
@ -453,30 +504,44 @@
"vor %d Jahr",
"vor %d Jahren"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"alert.too_many_feeds_refresh": [
"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minute, bevor Sie es erneut versuchen.",
"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minuten, bevor Sie es erneut versuchen."
],
"alert.background_feed_refresh": "Alle Abonnements werden derzeit im Hintergrund aktualisiert. Sie können Miniflux weiterhin benutzen, während dieser Prozess ausgeführt wird.",
"error.http_response_too_large": "Die HTTP-Antwort ist zu groß. Sie könnten die Grenze für die Größe der HTTP-Antwort in den globalen Einstellungen erhöhen (benötigt einen Neustart des Servers)",
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.tls_error": "TLS-Fehler: %q. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.",
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
"error.http_client_error": "HTTP-Client-Fehler: %v.",
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise sind der Benutzername oder das Passwort falsch.",
"error.http_too_many_requests": "Miniflux hat zu viele Anfragen an diese Webseite gestellt. Bitte versuchen Sie es später erneut oder ändern Sie die Konfiguration der Anwendung.",
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.http_resource_not_found": "Die gewünschte Quelle wurde nicht gefunden. Bitte stellen Sie sicher, dass die URL korrekt ist.",
"error.http_internal_server_error": "Die Webseite steht durch einen Server-Fehler derzeit nicht zur Verfügung. Versuchen Sie es bitte später erneut.",
"error.http_bad_gateway": "Die Webseite ist aufgrund eines Bad-Gateway-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_service_unavailable": "Die Webseite ist aufgrund eines Internal-Server-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_gateway_timeout": "Die Webseite ist aufgrund eines Gateway-Timeout-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.http_unexpected_status_code": "Die Webseite ist aufgrund eines eines unerwarteten HTTP-Fehlers derzeit nicht verfügbar: %d. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
"error.database_error": "Datenbank-Fehler: %v.",
"error.category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.duplicated_feed": "Dieses Abonnement existiert bereits.",
"error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
"error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Είστε σίγουροι;",
"confirm.question.refresh": "Θέλετε να επιτελέσετε μια υποχρεωτική ανανέωση;",
"confirm.yes": "ναι",
@ -18,6 +19,8 @@
"action.home_screen": "Προσθήκη στην αρχική οθόνη",
"tooltip.keyboard_shortcuts": "Συντόμευση πληκτρολογίου: % s",
"tooltip.logged_user": "Συνδεδεμένος/η ως %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Μη αναγνωσμένα",
"menu.starred": "Αγαπημένα",
"menu.history": "Ιστορικό",
@ -32,6 +35,7 @@
"menu.about": "Περί",
"menu.export": "Εξαγωγή",
"menu.import": "Εισαγωγή",
"menu.search": "Αναζήτηση",
"menu.create_category": "Δημιουργήστε μια κατηγορία",
"menu.mark_page_as_read": "Σημείωση αυτής της σελίδας ως αναγνωσμένη",
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
@ -50,6 +54,7 @@
"menu.shared_entries": "Κοινόχρηστες καταχωρήσεις",
"search.label": "Αναζήτηση",
"search.placeholder": "Αναζήτηση...",
"search.submit": "Search",
"pagination.next": "Επόμενη",
"pagination.previous": "Προηγούμενη",
"entry.status.unread": "Μη αναγνωσμένο",
@ -84,8 +89,24 @@
],
"entry.tags.label": "Ετικέτες:",
"page.shared_entries.title": "Κοινόχρηστες Καταχωρήσεις",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Μη αναγνωσμένα",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Αγαπημένo",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Κατηγορίες",
"page.categories.no_feed": "Καμία ροή.",
"page.categories.entries": "Άρθρα",
@ -94,21 +115,28 @@
"Υπάρχει μία %d ροή.",
"Υπάρχουν %d ροές."
],
"page.categories.unread_counter": "Αριθμός μη αναγνωσμένων καταχωρήσεων",
"page.new_category.title": "Νέα Κατηγορία",
"page.new_user.title": "Νέος Χρήστης",
"page.edit_category.title": "Επεξεργασία κατηγορίας: % s",
"page.edit_user.title": "Επεξεργασία χρήστη: % s",
"page.feeds.title": "Ροές",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Τελευταίος έλεγχος:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Αριθμός μη αναγνωσμένων καταχωρήσεων",
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
"page.feeds.error_count": [
"%d σφάλμα",
"%d σφάλματα"
],
"page.history.title": "Ιστορικό",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.categories_count": [
"%d category",
"%d categories"
],
"page.import.title": "Εισαγωγή",
"page.search.title": "Αποτελέσματα Αναζήτησης",
"page.about.title": "Περί",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
"page.keyboard_shortcuts.go_to_bottom_item": "Μετάβαση στο κάτω στοιχείο",
"page.keyboard_shortcuts.go_to_top_item": "Μετάβαση στο επάνω στοιχείο",
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
@ -228,6 +258,7 @@
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
@ -288,6 +319,7 @@
"form.feed.label.title": "Τίτλος",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.category": "Κατηγορία",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
@ -300,6 +332,7 @@
"form.feed.label.keeplist_rules": "Κρατήστε Κανόνες",
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
"form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Λήψη μέσω διακομιστή μεσολάβησης",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Αποθήκευση άρθρων στο Linkding",
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
"form.integration.linkding_api_key": "Κλειδί API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
"form.integration.linkwarden_endpoint": "Τελικό σημείο Linkwarden API",
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
"form.integration.matrix_bot_user": "Όνομα χρήστη για το Matrix",
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_api_key": "Κλειδί API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",
@ -453,13 +504,16 @@
"πριν %d έτος",
"πριν %d έτη"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Are you sure?",
"confirm.question.refresh": "Are you sure you want to force refresh?",
"confirm.yes": "yes",
@ -18,6 +19,8 @@
"action.home_screen": "Add to home screen",
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
"tooltip.logged_user": "Logged in as %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Unread",
"menu.starred": "Starred",
"menu.history": "History",
@ -32,6 +35,7 @@
"menu.about": "About",
"menu.export": "Export",
"menu.import": "Import",
"menu.search": "Search",
"menu.create_category": "Create a category",
"menu.mark_page_as_read": "Mark this page as read",
"menu.mark_all_as_read": "Mark all as read",
@ -50,6 +54,7 @@
"menu.shared_entries": "Shared entries",
"search.label": "Search",
"search.placeholder": "Search…",
"search.submit": "Search",
"pagination.next": "Next",
"pagination.previous": "Previous",
"entry.status.unread": "Unread",
@ -84,8 +89,24 @@
],
"entry.tags.label": "Tags:",
"page.shared_entries.title": "Shared entries",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Unread",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Starred",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Categories",
"page.categories.no_feed": "No feed.",
"page.categories.entries": "Entries",
@ -94,21 +115,28 @@
"There is %d feed.",
"There are %d feeds."
],
"page.categories.unread_counter": "Number of unread entries",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "New Category",
"page.new_user.title": "New User",
"page.edit_category.title": "Edit Category: %s",
"page.edit_user.title": "Edit User: %s",
"page.feeds.title": "Feeds",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Last check:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Number of unread entries",
"page.feeds.read_counter": "Number of read entries",
"page.feeds.error_count": [
"%d error",
"%d errors"
],
"page.history.title": "History",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Import",
"page.search.title": "Search Results",
"page.about.title": "About",
@ -148,6 +176,8 @@
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
"page.keyboard_shortcuts.go_to_next_item": "Go to next item",
"page.keyboard_shortcuts.go_to_feed": "Go to feed",
"page.keyboard_shortcuts.go_to_top_item": "Go to top item",
"page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item",
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
"page.keyboard_shortcuts.go_to_next_page": "Go to next page",
"page.keyboard_shortcuts.open_item": "Open selected item",
@ -187,7 +217,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.settings.webauthn.delete" : [
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
],
@ -228,6 +258,7 @@
"alert.no_bookmark": "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_tag_entry": "There are no entries matching this tag.",
"alert.no_feed_entry": "There are no entries for this feed.",
"alert.no_feed": "You dont have any feeds.",
"alert.no_feed_in_category": "There is no feed for this category.",
@ -286,6 +317,7 @@
"form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Description",
"form.feed.label.category": "Category",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.feed_username": "Feed Username",
@ -300,6 +332,7 @@
"form.feed.label.urlrewrite_rules": "URL Rewrite Rules",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Fetch via proxy",
"form.feed.label.disabled": "Do not refresh this feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Save entries to Linkding",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Mark bookmark as unread",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
"form.integration.matrix_bot_user": "Username for Matrix",
"form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Save entries to readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -453,13 +504,16 @@
"%d year ago",
"%d years ago"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
"error.settings_media_playback_rate_range": "Playback speed is out of range",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "¿Estás seguro?",
"confirm.question.refresh": "¿Quieres forzar la actualización?",
"confirm.yes": "sí",
@ -18,6 +19,8 @@
"action.home_screen": "Añadir a la pantalla principal",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "No leídos",
"menu.starred": "Marcadores",
"menu.history": "Historial",
@ -32,6 +35,7 @@
"menu.about": "Acerca de",
"menu.export": "Exportar",
"menu.import": "Importar",
"menu.search": "Buscar",
"menu.create_category": "Crear una categoría",
"menu.mark_page_as_read": "Marcar esta página como leída",
"menu.mark_all_as_read": "Marcar todos como leídos",
@ -50,6 +54,7 @@
"menu.shared_entries": "Artículos compartidos",
"search.label": "Buscar",
"search.placeholder": "Búsqueda...",
"search.submit": "Search",
"pagination.next": "Siguiente",
"pagination.previous": "Anterior",
"entry.status.unread": "No leído",
@ -81,11 +86,27 @@
"entry.estimated_reading_time": [
"%d minuto de lectura",
"%d minutos de lectura"
],
],
"entry.tags.label": "Etiquetas:",
"page.shared_entries.title": "Artículos compartidos",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "No leídos",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Marcadores",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Categorías",
"page.categories.no_feed": "Sin fuente.",
"page.categories.entries": "Artículos",
@ -94,21 +115,28 @@
"Hay %d fuente.",
"Hay %d fuentes."
],
"page.categories.unread_counter": "Número de artículos no leídos",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_user.title": "Editar usuario: %s",
"page.feeds.title": "Fuentes",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Última verificación:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Número de artículos no leídos",
"page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.error_count": [
"%d error",
"%d errores"
],
"page.history.title": "Historial",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Importar",
"page.search.title": "Resultados de la búsqueda",
"page.about.title": "Acerca de",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
"page.keyboard_shortcuts.go_to_top_item": "Ir al elemento superior",
"page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado",
"page.keyboard_shortcuts.open_original": "Abrir el enlace original",
"page.keyboard_shortcuts.open_original_same_window": "Abrir enlace original en la pestaña actual",
@ -228,6 +258,7 @@
"alert.no_bookmark": "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_tag_entry": "No hay artículos con esta etiqueta.",
"alert.no_feed_entry": "No hay artículos para esta fuente.",
"alert.no_feed": "No tienes fuentes.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
@ -286,6 +317,7 @@
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.description": "Descripción",
"form.feed.label.category": "Categoría",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
@ -300,6 +332,7 @@
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
"form.feed.label.disabled": "No actualice este feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Enviar artículos a Linkding",
"form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_api_key": "Clave de API de Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
"form.integration.matrix_bot_user": "Nombre de usuario para Matrix",
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
"form.integration.shiori_activate": "Guardar artículos a Shiori",
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
"form.integration.shiori_username": "Nombre de usuario de Shiori",
@ -453,13 +504,16 @@
"hace %d año",
"hace %d años"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Oletko varma?",
"confirm.question.refresh": "Haluatko pakottaa päivityksen?",
"confirm.yes": "kyllä",
@ -18,6 +19,8 @@
"action.home_screen": "Lisää aloitusnäytölle",
"tooltip.keyboard_shortcuts": "Pikanäppäin: %s",
"tooltip.logged_user": "Kirjautunut %s-käyttäjänä",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Lukemattomat",
"menu.starred": "Suosikit",
"menu.history": "Historia",
@ -32,6 +35,7 @@
"menu.about": "Tietoja",
"menu.export": "Vie",
"menu.import": "Tuo",
"menu.search": "Haku",
"menu.create_category": "Luo kategoria",
"menu.mark_page_as_read": "Merkitse tämä sivu luetuksi",
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
@ -50,6 +54,7 @@
"menu.shared_entries": "Jaetut artikkelit",
"search.label": "Haku",
"search.placeholder": "Hae...",
"search.submit": "Search",
"pagination.next": "Seuraava",
"pagination.previous": "Edellinen",
"entry.status.unread": "Lukematon",
@ -84,8 +89,24 @@
],
"entry.tags.label": "Tags:",
"page.shared_entries.title": "Jaetut artikkelit",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Lukemattomat",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Suosikit",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Kategoriat",
"page.categories.no_feed": "Ei syötettä.",
"page.categories.entries": "Artikkelit",
@ -94,21 +115,28 @@
"On %d syöte.",
"On %d syötettä."
],
"page.categories.unread_counter": "Lukemattomien artikkeleiden määrä",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "Uusi kategoria",
"page.new_user.title": "Uusi käyttäjä",
"page.edit_category.title": "Muokkaa kategoria: %s",
"page.edit_user.title": "Muokkaa käyttäjä: %s",
"page.feeds.title": "Syötteet",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Viimeisin tarkistus:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Lukemattomien artikkeleiden määrä",
"page.feeds.read_counter": "Luettujen artikkeleiden määrä",
"page.feeds.error_count": [
"%d virhe",
"%d virhettä"
],
"page.history.title": "Historia",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Tuo",
"page.search.title": "Hakutulokset",
"page.about.title": "Tietoja",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
"page.keyboard_shortcuts.go_to_bottom_item": "Siirry alimpaan kohtaan",
"page.keyboard_shortcuts.go_to_top_item": "Siirry alkuun",
"page.keyboard_shortcuts.open_item": "Avaa valittu kohde",
"page.keyboard_shortcuts.open_original": "Avaa alkuperäinen linkki",
"page.keyboard_shortcuts.open_original_same_window": "Avaa alkuperäinen linkki nykyisessä välilehdessä",
@ -228,6 +258,7 @@
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
@ -288,6 +319,7 @@
"form.feed.label.title": "Otsikko",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.description": "Kuvaus",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
@ -300,6 +332,7 @@
"form.feed.label.keeplist_rules": "Keep-säännöt",
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Nouda välityspalvelimen kautta",
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkding_endpoint": "Linkding API-päätepiste",
"form.integration.linkding_api_key": "Linkding API-avain",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkwarden_endpoint": "Linkwarden API-päätepiste",
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
"form.integration.matrix_bot_user": "Matrixin käyttäjätunnus",
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_api_key": "Readeck API-avain",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -453,13 +504,16 @@
"%d vuosi sitten",
"%d vuotta sitten"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Aller au contenu",
"confirm.question": "Êtes-vous sûr ?",
"confirm.question.refresh": "Voulez-vous forcer le rafraîchissement ?",
"confirm.yes": "oui",
@ -18,6 +19,8 @@
"action.home_screen": "Ajouter à l'écran d'accueil",
"tooltip.keyboard_shortcuts": "Raccourci clavier : %s",
"tooltip.logged_user": "Connecté en tant que %s",
"menu.title": "Menu",
"menu.home_page": "Page d'accueil",
"menu.unread": "Non lus",
"menu.starred": "Favoris",
"menu.history": "Historique",
@ -32,6 +35,7 @@
"menu.about": "À propos",
"menu.export": "Export",
"menu.import": "Import",
"menu.search": "Recherche",
"menu.create_category": "Créer une catégorie",
"menu.mark_page_as_read": "Marquer cette page comme lu",
"menu.mark_all_as_read": "Tout marquer comme lu",
@ -50,6 +54,7 @@
"menu.shared_entries": "Articles partagés",
"search.label": "Recherche",
"search.placeholder": "Recherche...",
"search.submit": "Rechercher",
"pagination.next": "Suivant",
"pagination.previous": "Précédent",
"entry.status.unread": "Non lu",
@ -81,11 +86,27 @@
"entry.estimated_reading_time": [
"%d minute de lecture",
"%d minutes de lecture"
],
],
"entry.tags.label": "Libellés :",
"page.shared_entries.title": "Articles partagés",
"page.shared_entries_count": [
"%d article partagé",
"%d articles partagés"
],
"page.unread.title": "Non lus",
"page.unread_entry_count": [
"%d article non lu",
"%d articles non lus"
],
"page.total_entry_count": [
"%d article au total",
"%d articles au total"
],
"page.starred.title": "Favoris",
"page.starred_entry_count": [
"%d favori",
"%d favoris"
],
"page.categories.title": "Catégories",
"page.categories.no_feed": "Aucun abonnement.",
"page.categories.entries": "Articles",
@ -94,21 +115,28 @@
"Il y a %d abonnement.",
"Il y a %d abonnements."
],
"page.categories.unread_counter": "Nombre d'entrées non lues",
"page.categories_count": [
"%d catégorie",
"%d catégories"
],
"page.new_category.title": "Nouvelle catégorie",
"page.new_user.title": "Nouvel Utilisateur",
"page.edit_category.title": "Modification de la catégorie : %s",
"page.edit_user.title": "Modification de l'utilisateur : %s",
"page.feeds.title": "Abonnements",
"page.category_label": "Catégorie : %s",
"page.feeds.last_check": "Dernière vérification :",
"page.feeds.next_check": "Prochaine vérification :",
"page.feeds.unread_counter": "Nombre d'entrées non lues",
"page.feeds.read_counter": "Nombre d'entrées lues",
"page.feeds.error_count": [
"%d erreur",
"%d erreurs"
],
"page.history.title": "Historique",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Importation",
"page.search.title": "Résultats de la recherche",
"page.about.title": "À propos",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
"page.keyboard_shortcuts.go_to_next_page": "Page suivante",
"page.keyboard_shortcuts.go_to_bottom_item": "Aller à l'élément du bas",
"page.keyboard_shortcuts.go_to_top_item": "Aller à l'élément supérieur",
"page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné",
"page.keyboard_shortcuts.open_original": "Ouvrir le lien original",
"page.keyboard_shortcuts.open_original_same_window": "Ouvrir le lien original dans l'onglet en cours",
@ -187,7 +217,7 @@
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.register": "Enregister une nouvelle clé daccès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès",
"page.settings.webauthn.delete" : [
"page.settings.webauthn.delete": [
"Supprimer %d clé daccès",
"Supprimer %d clés daccès"
],
@ -228,6 +258,7 @@
"alert.no_bookmark": "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_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed": "Vous n'avez aucun abonnement.",
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
@ -286,6 +317,7 @@
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
"form.feed.label.description": "Description",
"form.feed.label.category": "Catégorie",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
@ -300,6 +332,7 @@
"form.feed.label.urlrewrite_rules": "Règles de réécriture d'URL",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.disable_http2": "Désactiver HTTP/2",
"form.feed.label.fetch_via_proxy": "Récupérer via proxy",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Désactiver l'aperçu de la page Web",
"form.integration.telegram_bot_disable_notification": "Désactiver les notifications",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Sauvegarder les articles vers Linkding",
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
"form.integration.linkding_api_key": "Clé d'API de Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
"form.integration.linkwarden_endpoint": "URL de l'API de Linkwarden",
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
"form.integration.matrix_bot_user": "Nom de l'utilisateur Matrix",
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
"form.integration.matrix_bot_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.shiori_activate": "Sauvegarder les articles vers Shiori",
"form.integration.shiori_endpoint": "URL de l'API de Shiori",
"form.integration.shiori_username": "Nom d'utilisateur de Shiori",
@ -453,13 +504,16 @@
"il y a %d an",
"il y a %d ans"
],
"alert.too_many_feeds_refresh": "Vous avez déclenché trop d'actualisations de flux. Veuillez attendre 30 minutes avant de réessayer.",
"alert.too_many_feeds_refresh": [
"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minute avant de réessayer.",
"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minutes avant de réessayer."
],
"alert.background_feed_refresh": "Les abonnements sont en cours d'actualisation en arrière-plan. Vous pouvez continuer à naviguer dans l'application.",
"error.http_response_too_large": "La réponse HTTP est trop volumineuse. Vous pouvez augmenter la limite de taille de réponse HTTP dans les paramètres de l'application (redémarrage de l'application nécessaire).",
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
"error.tls_error": "Erreur TLS : %v. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.tls_error": "Erreur TLS : %q. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.",
"error.network_timeout": "Ce site web est trop lent à répondre : %v.",
"error.http_client_error": "Erreur du client HTTP : %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
"error.feed_not_found": "Impossible de trouver ce flux.",
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v."
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
"enclosure_media_controls.seek" : "Avancer/Reculer :",
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
"enclosure_media_controls.speed" : "Vitesse :",
"enclosure_media_controls.speed.faster" : "Accélérer",
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
"enclosure_media_controls.speed.slower" : "Ralentir",
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
"enclosure_media_controls.speed.reset" : "Réinitialiser",
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "मंजूर है?",
"confirm.question.refresh": "क्या आप बल द्वारा ताज़ा करना चाहते हैं?",
"confirm.yes": "हाँ",
@ -18,6 +19,8 @@
"action.home_screen": "होम स्क्रीन में शामिल करें",
"tooltip.keyboard_shortcuts": "कुंजीपटल संक्षिप्त रीति: %s",
"tooltip.logged_user": "%s के रूप में लॉग इन किया",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "अपठित",
"menu.starred": "तारांकित",
"menu.history": "इतिहास",
@ -32,6 +35,7 @@
"menu.about": "के बारे में",
"menu.export": "निर्यात करे",
"menu.import": "आयात करे",
"menu.search": "खोज",
"menu.create_category": "श्रेणी बनाए",
"menu.mark_page_as_read": "इस पृष्ठ को पढ़ा हुआ चिह्नित करें",
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
@ -50,6 +54,7 @@
"menu.shared_entries": "साझा प्रविष्टियां",
"search.label": "खोजे",
"search.placeholder": "खोजे...",
"search.submit": "Search",
"pagination.next": "अगला",
"pagination.previous": "पिछला",
"entry.status.unread": "अपठित",
@ -81,11 +86,27 @@
"entry.estimated_reading_time": [
"पढ़ने मे %d मिनट मागेगा",
"पढ़ने मे %d मिनट मागेगा"
],
],
"entry.tags.label": "टैग:",
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "अपठित",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "तारांकित",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "श्रेणियाँ",
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
"page.categories.entries": "विषयवस्तुया",
@ -94,21 +115,28 @@
"%d फ़ीड बाकी है।",
"%d फ़ीड बाकी है।"
],
"page.categories.unread_counter": "अपठित प्रविष्टिया",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "नया श्रेणी",
"page.new_user.title": "नया उपभोक्ता",
"page.edit_category.title": "%s श्रेणी संपाद करे",
"page.edit_user.title": "%s उपभोक्ता संपाद करे",
"page.feeds.title": "फ़ीड",
"page.category_label": "Category: %s",
"page.feeds.last_check": "आखरी जाँच",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "अपठित विषयवस्तुया",
"page.feeds.read_counter": "पड़े हुए विषयवस्तुया",
"page.feeds.error_count": [
"%d समस्या",
"%d समस्याए"
],
"page.history.title": "इतिहास",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "आयात",
"page.search.title": "खोज का परिणाम",
"page.about.title": "पृष्ठ के बारे में",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
"page.keyboard_shortcuts.go_to_bottom_item": "निचले आइटम पर जाएँ",
"page.keyboard_shortcuts.go_to_top_item": "शीर्ष आइटम पर जाएँ",
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
@ -228,6 +258,7 @@
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
@ -286,6 +317,7 @@
"form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.description": "विवरण",
"form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
@ -300,6 +332,7 @@
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें",
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -363,7 +396,7 @@
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.pocket_activate": "विषय-कविता को पॉकेट में सहेजें",
"form.integration.pocket_consumer_key": "पॉकेट उपभोक्ता कुंजी",
"form.integration.pocket_access_token": "पॉकेट एक्सेस टोकन",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "लिंक्डिन में विषयवस्तु सहेजें",
"form.integration.linkding_endpoint": "लिंकिंग एपीआई समापन बिंदु",
"form.integration.linkding_api_key": "लिंकिंग एपीआई कुंजी",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
"form.integration.matrix_bot_user": "मैट्रिक्स के लिए उपयोगकर्ता नाम",
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -453,13 +504,16 @@
"%d साल पहले",
"%d वर्षों पहले"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Apakah Anda yakin?",
"confirm.question.refresh": "Apakah Anda ingin memaksa penyegaran?",
"confirm.yes": "ya",
@ -18,6 +19,8 @@
"action.home_screen": "Tambahkan ke beranda",
"tooltip.keyboard_shortcuts": "Pintasan Papan Tik: %s",
"tooltip.logged_user": "Masuk sebagai %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Belum Dibaca",
"menu.starred": "Markah",
"menu.history": "Riwayat",
@ -32,6 +35,7 @@
"menu.about": "Tentang",
"menu.export": "Ekspor",
"menu.import": "Impor",
"menu.search": "Cari",
"menu.create_category": "Buat kategori",
"menu.mark_page_as_read": "Tandai halaman ini sebagai telah dibaca",
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
@ -50,6 +54,7 @@
"menu.shared_entries": "Entri yang Dibagikan",
"search.label": "Cari",
"search.placeholder": "Cari...",
"search.submit": "Search",
"pagination.next": "Berikutnya",
"pagination.previous": "Sebelumnya",
"entry.status.unread": "Belum dibaca",
@ -79,33 +84,50 @@
"entry.shared_entry.title": "Buka tautan publik",
"entry.shared_entry.label": "Bagikan",
"entry.estimated_reading_time": [
"%d menit untuk dibaca"
],
"%d menit untuk dibaca"
],
"entry.tags.label": "Tanda:",
"page.shared_entries.title": "Entri yang Dibagikan",
"page.shared_entries_count": [
"%d shared entry"
],
"page.unread.title": "Belum Dibaca",
"page.unread_entry_count": [
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total"
],
"page.starred.title": "Markah",
"page.starred_entry_count": [
"%d starred entry"
],
"page.categories.title": "Kategori",
"page.categories.no_feed": "Tidak ada umpan.",
"page.categories.entries": "Artikel",
"page.categories.feeds": "Langganan",
"page.categories.feed_count": [
"Ada %d umpan."
"Ada %d umpan."
],
"page.categories_count": [
"%d category"
],
"page.categories.unread_counter": "Jumlah entri yang belum dibaca",
"page.new_category.title": "Kategori Baru",
"page.new_user.title": "Pengguna Baru",
"page.edit_category.title": "Sunting Kategori: %s",
"page.edit_user.title": "Sunting Pengguna: %s",
"page.feeds.title": "Umpan",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Terakhir diperiksa:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Jumlah entri yang belum dibaca",
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
"page.feeds.error_count": [
"%d galat"
"%d galat"
],
"page.history.title": "Riwayat",
"page.read_entry_count": [
"%d read entry"
],
"page.import.title": "Impor",
"page.search.title": "Hasil Pencarian",
"page.about.title": "Tentang",
@ -147,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ke umpan",
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
"page.keyboard_shortcuts.go_to_bottom_item": "Pergi ke item paling bawah",
"page.keyboard_shortcuts.go_to_top_item": "Pergi ke item teratas",
"page.keyboard_shortcuts.open_item": "Buka entri yang dipilih",
"page.keyboard_shortcuts.open_original": "Buka tautan asli",
"page.keyboard_shortcuts.open_original_same_window": "Buka tautan asli di bilah saat ini",
@ -185,8 +209,7 @@
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.settings.webauthn.delete": [
"Remove %d passkey",
"Remove %d passkeys"
"Remove %d passkey"
],
"page.login.title": "Masuk",
"page.login.google_signin": "Masuk dengan Google",
@ -225,6 +248,7 @@
"alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
@ -283,6 +307,7 @@
"form.feed.label.title": "Judul",
"form.feed.label.site_url": "URL Situs",
"form.feed.label.feed_url": "URL Umpan",
"form.feed.label.description": "Deskripsi",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.feed_username": "Nama Pengguna Umpan",
@ -297,6 +322,7 @@
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Ambil via Proksi",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -398,16 +424,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Simpan artikel ke Linkding",
"form.integration.linkding_endpoint": "Titik URL API Linkding",
"form.integration.linkding_api_key": "Kunci API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
"form.integration.linkwarden_endpoint": "Titik URL API Linkwarden",
"form.integration.linkwarden_api_key": "Kunci API Linkwarden",
"form.integration.matrix_bot_activate": "Kirim entri baru ke Matrix",
"form.integration.matrix_bot_user": "Nama Pengguna Matrix",
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -427,30 +471,32 @@
"time_elapsed.yesterday": "kemarin",
"time_elapsed.now": "baru saja",
"time_elapsed.minutes": [
"%d menit yang lalu"
"%d menit yang lalu"
],
"time_elapsed.hours": [
"%d jam yang lalu"
"%d jam yang lalu"
],
"time_elapsed.days": [
"%d hari yang lalu"
"%d hari yang lalu"
],
"time_elapsed.weeks": [
"%d pekan yang lalu"
"%d pekan yang lalu"
],
"time_elapsed.months": [
"%d bulan yang lalu"
"%d bulan yang lalu"
],
"time_elapsed.years": [
"%d tahun yang lalu"
"%d tahun yang lalu"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -469,5 +515,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Sei sicuro?",
"confirm.question.refresh": "Vuoi forzare l'aggiornamento?",
"confirm.yes": "sì",
@ -18,6 +19,8 @@
"action.home_screen": "Aggiungere alla schermata Home",
"tooltip.keyboard_shortcuts": "Scorciatoia da tastiera: %s",
"tooltip.logged_user": "Autenticato come %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Da leggere",
"menu.starred": "Preferiti",
"menu.history": "Cronologia",
@ -32,6 +35,7 @@
"menu.about": "Informazioni",
"menu.export": "Esporta",
"menu.import": "Importa",
"menu.search": "Cerca",
"menu.create_category": "Aggiungi una categoria",
"menu.mark_page_as_read": "Segna questa pagina come letta",
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
@ -50,6 +54,7 @@
"menu.shared_entries": "Voci condivise",
"search.label": "Cerca",
"search.placeholder": "Cerca...",
"search.submit": "Search",
"pagination.next": "Successivo",
"pagination.previous": "Precedente",
"entry.status.unread": "Da leggere",
@ -81,11 +86,27 @@
"entry.estimated_reading_time": [
"%d minuto di lettura",
"%d minuti di lettura"
],
],
"entry.tags.label": "Tag:",
"page.shared_entries.title": "Voci condivise",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Da leggere",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Preferiti",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Categorie",
"page.categories.no_feed": "Nessun feed.",
"page.categories.entries": "Articoli",
@ -94,21 +115,28 @@
"C'è %d feed.",
"Ci sono %d feed."
],
"page.categories.unread_counter": "Numero di voci non lette",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "Nuova categoria",
"page.new_user.title": "Nuovo utente",
"page.edit_category.title": "Modifica categoria: %s",
"page.edit_user.title": "Modifica utente: %s",
"page.feeds.title": "Feed",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Ultimo controllo:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Numero di voci non lette",
"page.feeds.read_counter": "Numero di voci lette",
"page.feeds.error_count": [
"%d errore",
"%d errori"
],
"page.history.title": "Cronologia",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Importa",
"page.search.title": "Risultati della ricerca",
"page.about.title": "Informazioni",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
"page.keyboard_shortcuts.go_to_bottom_item": "Vai all'elemento in fondo",
"page.keyboard_shortcuts.go_to_top_item": "Vai all'elemento principale",
"page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato",
"page.keyboard_shortcuts.open_original": "Apri la pagina web originale",
"page.keyboard_shortcuts.open_original_same_window": "Apri il link originale nella scheda corrente",
@ -228,6 +258,7 @@
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
@ -286,6 +317,7 @@
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
"form.feed.label.description": "Descrizione",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.feed_username": "Nome utente del feed",
@ -300,6 +332,7 @@
"form.feed.label.urlrewrite_rules": "Regole di riscrittura URL",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
"form.feed.label.disabled": "Non aggiornare questo feed",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Salva gli articoli su LinkAce",
"form.integration.linkace_endpoint": "Endpoint dell'API di LinkAce",
"form.integration.linkace_api_key": "API key dell'account LinkAce",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Rendi i link privati",
"form.integration.linkace_check_disabled": "Disabilita i controlli",
"form.integration.linkding_activate": "Salva gli articoli su Linkding",
"form.integration.linkding_endpoint": "Endpoint dell'API di Linkding",
"form.integration.linkding_api_key": "API key dell'account Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Segna i preferiti come non letti",
"form.integration.linkwarden_activate": "Salva gli articoli su Linkwarden",
"form.integration.linkwarden_endpoint": "Endpoint dell'API di Linkwarden",
"form.integration.linkwarden_api_key": "API key dell'account Linkwarden",
"form.integration.matrix_bot_activate": "Trasferimento di nuovi articoli a Matrix",
"form.integration.matrix_bot_user": "Nome utente per Matrix",
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Invia solo URL (invece del contenuto completo)",
"form.integration.shiori_activate": "Salva gli articoli su Shiori",
"form.integration.shiori_endpoint": "Endpoint dell'API di Shiori",
"form.integration.shiori_username": "Nome utente dell'account Shiori",
@ -453,13 +504,16 @@
"%d anno fa",
"%d anni fa"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "よろしいですか?",
"confirm.question.refresh": "強制的に更新しますか?",
"confirm.yes": "はい",
@ -18,6 +19,8 @@
"action.home_screen": "ホームスクリーンに追加",
"tooltip.keyboard_shortcuts": "キーボードショートカット: %s",
"tooltip.logged_user": "%s としてログイン中",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "未読",
"menu.starred": "星付き",
"menu.history": "履歴",
@ -32,6 +35,7 @@
"menu.about": "ソフトウェア情報",
"menu.export": "エクスポート",
"menu.import": "インポート",
"menu.search": "検索",
"menu.create_category": "カテゴリを作成",
"menu.mark_page_as_read": "このページを既読にする",
"menu.mark_all_as_read": "すべて既読にする",
@ -50,6 +54,7 @@
"menu.shared_entries": "共有エントリ",
"search.label": "検索",
"search.placeholder": "…を検索",
"search.submit": "Search",
"pagination.next": "次",
"pagination.previous": "前",
"entry.status.unread": "未読にする",
@ -79,36 +84,50 @@
"entry.shared_entry.title": "公開リンクを開く",
"entry.shared_entry.label": "共有する",
"entry.estimated_reading_time": [
"%d 分で読めます",
"%d 分で読めます"
],
"entry.tags.label": "タグ:",
"page.shared_entries.title": "共有エントリ",
"page.shared_entries_count": [
"%d shared entry"
],
"page.unread.title": "未読",
"page.unread_entry_count": [
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total"
],
"page.starred.title": "星付き",
"page.starred_entry_count": [
"%d starred entry"
],
"page.categories.title": "カテゴリ",
"page.categories.no_feed": "フィードはありません。",
"page.categories.entries": "記事一覧",
"page.categories.feeds": "フィード一覧",
"page.categories.feed_count": [
"%d 件のフィードがあります。",
"%d 件のフィードがあります。"
],
"page.categories.unread_counter": "未読記事の数",
"page.categories_count": [
"%d category"
],
"page.new_category.title": "新規カテゴリ",
"page.new_user.title": "新規ユーザー",
"page.edit_category.title": "カテゴリを編集: %s",
"page.edit_user.title": "ユーザーを編集: %s",
"page.feeds.title": "フィード一覧",
"page.category_label": "Category: %s",
"page.feeds.last_check": "最終チェック:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "未読記事の数",
"page.feeds.read_counter": "既読記事の数",
"page.feeds.error_count": [
"%d 個のエラー",
"%d 個のエラー"
],
"page.history.title": "履歴",
"page.read_entry_count": [
"%d read entry"
],
"page.import.title": "インポート",
"page.search.title": "検索結果",
"page.about.title": "ソフトウェア情報",
@ -150,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "フィード",
"page.keyboard_shortcuts.go_to_previous_page": "前のページ",
"page.keyboard_shortcuts.go_to_next_page": "次のページ",
"page.keyboard_shortcuts.go_to_bottom_item": "一番下の項目に移動",
"page.keyboard_shortcuts.go_to_top_item": "先頭の項目に移動",
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
@ -188,7 +209,6 @@
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.settings.webauthn.delete": [
"%d 個のパスキーを削除",
"%d 個のパスキーを削除"
],
"page.login.title": "ログイン",
@ -228,6 +248,7 @@
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed": "何も購読していません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
@ -286,6 +307,7 @@
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
"form.feed.label.description": "説明",
"form.feed.label.category": "カテゴリ",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.feed_username": "フィードのユーザー名",
@ -300,6 +322,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "プロキシ経由で取得",
"form.feed.label.disabled": "このフィードを更新しない",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +424,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Linkding に記事を保存する",
"form.integration.linkding_endpoint": "Linkding の API Endpoint",
"form.integration.linkding_api_key": "Linkding の API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "ブックマークを未読にする",
"form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
"form.integration.linkwarden_endpoint": "Linkwarden の API Endpoint",
"form.integration.linkwarden_api_key": "Linkwarden の API key",
"form.integration.matrix_bot_activate": "新しい記事をMatrixに転送する",
"form.integration.matrix_bot_user": "Matrixのユーザー名",
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Readeck に記事を保存する",
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_api_key": "Readeck の API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "URL のみを送信 (完全なコンテンツではなく)",
"form.integration.shiori_activate": "Shiori に記事を保存する",
"form.integration.shiori_endpoint": "Shiori の API Endpoint",
"form.integration.shiori_username": "Shiori の ユーザー名",
@ -430,36 +471,32 @@
"time_elapsed.yesterday": "昨日",
"time_elapsed.now": "今",
"time_elapsed.minutes": [
"%d 分前",
"%d 分前"
],
"time_elapsed.hours": [
"%d 時間前",
"%d 時間前"
],
"time_elapsed.days": [
"%d 日前",
"%d 日前"
],
"time_elapsed.weeks": [
"%d 週間前",
"%d 週間前"
],
"time_elapsed.months": [
"%d か月前",
"%d か月前"
],
"time_elapsed.years": [
"%d 年前",
"%d 年前"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +515,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"error.settings_media_playback_rate_range": "再生速度が範囲外",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Weet je het zeker?",
"confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
"confirm.yes": "ja",
@ -18,6 +19,8 @@
"action.home_screen": "Toevoegen aan startscherm",
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
"tooltip.logged_user": "Ingelogd als %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Ongelezen",
"menu.starred": "Favorieten",
"menu.history": "Geschiedenis",
@ -32,6 +35,7 @@
"menu.about": "Over",
"menu.export": "Exporteren",
"menu.import": "Importeren",
"menu.search": "Zoeken",
"menu.create_category": "Categorie toevoegen",
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
"menu.mark_all_as_read": "Markeer alle items als gelezen",
@ -50,6 +54,7 @@
"menu.shared_entries": "Gedeelde vermeldingen",
"search.label": "Zoeken",
"search.placeholder": "Zoeken...",
"search.submit": "Search",
"pagination.next": "Volgende",
"pagination.previous": "Vorige",
"entry.status.unread": "Ongelezen",
@ -84,8 +89,24 @@
],
"entry.tags.label": "Labels:",
"page.shared_entries.title": "Gedeelde vermeldingen",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Ongelezen",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Favorieten",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Categorieën",
"page.categories.no_feed": "Geen feeds.",
"page.categories.entries": "Lidwoord",
@ -94,21 +115,28 @@
"Er is %d feed.",
"Er zijn %d feeds."
],
"page.categories.unread_counter": "Aantal ongelezen vermeldingen",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
"page.edit_category.title": "Bewerken van categorie: %s",
"page.edit_user.title": "Bewerk gebruiker: %s",
"page.feeds.title": "Feeds",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Laatste update:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Aantal ongelezen vermeldingen",
"page.feeds.read_counter": "Aantal gelezen vermeldingen",
"page.feeds.error_count": [
"%d error",
"%d errors"
],
"page.history.title": "Geschiedenis",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Importeren",
"page.login.title": "Inloggen",
"page.search.title": "Zoekresultaten",
@ -151,6 +179,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
"page.keyboard_shortcuts.open_original": "Open originele link",
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
@ -228,6 +258,7 @@
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
@ -286,6 +317,7 @@
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Beschrijving",
"form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed-gebruikersnaam",
@ -300,6 +332,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
"form.feed.label.disabled": "Vernieuw deze feed niet",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Opslaan naar Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API-sleutel",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
"form.integration.linkwarden_activate": "Opslaan naar Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
"form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
"form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Opslaan naar Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.shiori_activate": "Opslaan naar Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Shiori gebruikersnaam",
@ -453,13 +504,16 @@
"%d jaar geleden",
"%d jaar geleden"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Czy jesteś pewny?",
"confirm.question.refresh": "Czy chcesz wymusić odświeżenie?",
"confirm.yes": "tak",
@ -18,6 +19,8 @@
"action.home_screen": "Dodaj do ekranu głównego",
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
"tooltip.logged_user": "Zalogowany jako %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Nieprzeczytane",
"menu.starred": "Ulubione",
"menu.history": "Historia",
@ -32,6 +35,7 @@
"menu.about": "O stronie",
"menu.export": "Eksportuj",
"menu.import": "Importuj",
"menu.search": "Szukaj",
"menu.create_category": "Utwórz kategorię",
"menu.mark_page_as_read": "Oznacz jako przeczytane",
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
@ -50,6 +54,7 @@
"menu.shared_entries": "Udostępnione wpisy",
"search.label": "Szukaj",
"search.placeholder": "Szukaj...",
"search.submit": "Search",
"pagination.next": "Następny",
"pagination.previous": "Poprzedni",
"entry.status.unread": "Nieprzeczytane",
@ -80,12 +85,33 @@
"entry.shared_entry.label": "Udostępnianie",
"entry.estimated_reading_time": [
"%d minuta czytania",
"%d minuty czytania",
"%d minut czytania"
],
"entry.tags.label": "Tagi:",
"page.shared_entries.title": "Udostępnione wpisy",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Nieprzeczytane",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Oznaczone gwiazdką",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Kategorie",
"page.categories.no_feed": "Brak kanałów.",
"page.categories.entries": "Artykuły",
@ -95,15 +121,19 @@
"Są %d kanały.",
"Jest %d kanałów."
],
"page.categories.unread_counter": "Liczba nieprzeczytanych wpisów",
"page.categories_count": [
"%d category",
"%d category",
"%d categories"
],
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
"page.edit_category.title": "Edycja Kategorii: %s",
"page.edit_user.title": "Edytuj użytkownika: %s",
"page.feeds.title": "Kanały",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Ostatnia aktualizacja:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Liczba nieprzeczytanych wpisów",
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
"page.feeds.error_count": [
"%d błąd",
@ -111,6 +141,11 @@
"%d błędów"
],
"page.history.title": "Historia",
"page.read_entry_count": [
"%d read entry",
"%d read entry",
"%d read entries"
],
"page.import.title": "Importuj",
"page.search.title": "Wyniki wyszukiwania",
"page.about.title": "O",
@ -152,6 +187,8 @@
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do najwyższego elementu",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
@ -231,6 +268,7 @@
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
@ -289,6 +327,7 @@
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
"form.feed.label.description": "Opis",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
@ -303,6 +342,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -404,16 +444,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Zapisz artykuły do Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.linkwarden_activate": "Zapisz artykuły do Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.matrix_bot_activate": "Przenieś nowe artykuły do Matrix",
"form.integration.matrix_bot_user": "Nazwa użytkownika dla Matrix",
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Login do Shiori",
@ -462,13 +520,17 @@
"%d lat temu",
"%d lat temu"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -487,5 +549,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Tem certeza?",
"confirm.question.refresh": "Você deseja forçar a atualização?",
"confirm.yes": "Sim",
@ -18,6 +19,8 @@
"action.home_screen": "Voltar para a tela inicial",
"tooltip.keyboard_shortcuts": "Atalho do teclado: %s",
"tooltip.logged_user": "Autenticado como %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Não lido",
"menu.starred": "Favoritos",
"menu.history": "Histórico",
@ -32,6 +35,7 @@
"menu.about": "Sobre",
"menu.export": "Exportar",
"menu.import": "Importar",
"menu.search": "Buscar",
"menu.create_category": "Criar uma categoria",
"menu.mark_page_as_read": "Marcar essa página como lida",
"menu.mark_all_as_read": "Marcar todos como lido",
@ -50,6 +54,7 @@
"menu.shared_entries": "Itens compartilhados",
"search.label": "Buscar",
"search.placeholder": "Buscar por...",
"search.submit": "Search",
"pagination.next": "Próximo",
"pagination.previous": "Anterior",
"entry.status.unread": "Não lido",
@ -84,8 +89,24 @@
],
"entry.tags.label": "Etiquetas:",
"page.shared_entries.title": "Itens compartilhados",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.unread.title": "Não lidos",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.starred.title": "Favoritos",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.categories.title": "Categorias",
"page.categories.no_feed": "Sem fonte.",
"page.categories.entries": "Itens",
@ -94,21 +115,28 @@
"Existe %d fonte.",
"Existem %d fontes."
],
"page.categories.unread_counter": "Numero de itens não lidos",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.new_category.title": "Nova categoria",
"page.new_user.title": "Novo usuário",
"page.edit_category.title": "Editar categoria: %s",
"page.edit_user.title": "Editar usuário: %s",
"page.feeds.title": "Fontes",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Última verificação:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Numero de itens não lidos",
"page.feeds.read_counter": "Número de itens lidos",
"page.feeds.error_count": [
"%d erro",
"%d erros"
],
"page.history.title": "Histórico",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
],
"page.import.title": "Importar",
"page.search.title": "Resultados da busca",
"page.about.title": "Sobre",
@ -150,6 +178,8 @@
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir para o item inferior",
"page.keyboard_shortcuts.go_to_top_item": "Ir para o item superior",
"page.keyboard_shortcuts.open_item": "Abrir o item selecionado",
"page.keyboard_shortcuts.open_original": "Abrir o conteúdo original",
"page.keyboard_shortcuts.open_original_same_window": "Abrir o conteúdo original na janela atual",
@ -228,6 +258,7 @@
"alert.no_bookmark": "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_tag_entry": "Não há itens que correspondam a esta etiqueta.",
"alert.no_feed_entry": "Não há itens nessa fonte.",
"alert.no_feed": "Não há inscrições.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
@ -286,6 +317,7 @@
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte",
"form.feed.label.description": "Descrição",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.feed_username": "Nome de usuário da fonte",
@ -300,6 +332,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disabled": "Não atualizar esta fonte",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
@ -401,16 +434,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Salvar itens no Linkding",
"form.integration.linkding_endpoint": "Endpoint de API do Linkding",
"form.integration.linkding_api_key": "Chave de API do Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Salvar marcador como não lido",
"form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
"form.integration.linkwarden_endpoint": "Endpoint de API do Linkwarden",
"form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
"form.integration.matrix_bot_activate": "Transferir novos artigos para o Matrix",
"form.integration.matrix_bot_user": "Nome de utilizador para Matrix",
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Salvar itens no Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
"form.integration.shiori_activate": "Salvar itens no Shiori",
"form.integration.shiori_endpoint": "Endpoint da API do Shiori",
"form.integration.shiori_username": "Nome de usuário do Shiori",
@ -453,13 +504,16 @@
"há %d ano",
"há %d anos"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +532,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Вы уверены?",
"confirm.question.refresh": "Вы хотите выполнить принудительное обновление?",
"confirm.yes": "да",
@ -18,6 +19,8 @@
"action.home_screen": "Добавить на домашний экран",
"tooltip.keyboard_shortcuts": "Сочетания клавиш: %s",
"tooltip.logged_user": "Авторизован как %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Непрочитанное",
"menu.starred": "Избранное",
"menu.history": "История",
@ -32,6 +35,7 @@
"menu.about": "О приложении",
"menu.export": "Экспорт",
"menu.import": "Импорт",
"menu.search": "Поиск",
"menu.create_category": "Создать категорию",
"menu.mark_page_as_read": "Отметить эту страницу прочитанной",
"menu.mark_all_as_read": "Отметить всё как прочитанное",
@ -50,6 +54,7 @@
"menu.shared_entries": "Общие записи",
"search.label": "Поиск",
"search.placeholder": "Поиск…",
"search.submit": "Search",
"pagination.next": "Следующая",
"pagination.previous": "Предыдущая",
"entry.status.unread": "Не прочитано",
@ -80,12 +85,33 @@
"entry.shared_entry.label": "Поделиться",
"entry.estimated_reading_time": [
"%d минута чтения",
"%d минуты чтения",
"%d минут чтения"
],
"entry.tags.label": "Теги:",
"page.shared_entries.title": "Общедоступные статьи",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
"%d shared entries"
],
"page.unread.title": "Непрочитанное",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total",
"%d entries in total"
],
"page.starred.title": "Избранное",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries",
"%d starred entries"
],
"page.categories.title": "Категории",
"page.categories.no_feed": "Нет подписок.",
"page.categories.entries": "Cтатьи",
@ -95,15 +121,19 @@
"Есть %d подписки.",
"Есть %d подписок."
],
"page.categories.unread_counter": "Количество непрочитанных статей",
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.new_category.title": "Новая категория",
"page.new_user.title": "Новый пользователь",
"page.edit_category.title": "Изменить категорию: %s",
"page.edit_user.title": "Изменить пользователя: %s",
"page.feeds.title": "Подписки",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Последняя проверка:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Количество непрочитанных статей",
"page.feeds.read_counter": "Количество прочитанных статей",
"page.feeds.error_count": [
"%d ошибка",
@ -111,6 +141,11 @@
"%d ошибок"
],
"page.history.title": "История",
"page.read_entry_count": [
"%d read entry",
"%d read entries",
"%d read entries"
],
"page.import.title": "Импорт",
"page.search.title": "Результаты поиска",
"page.about.title": "О приложении",
@ -152,6 +187,8 @@
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти к нижнему элементу",
"page.keyboard_shortcuts.go_to_top_item": "Перейти к верхнему элементу",
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
@ -231,6 +268,7 @@
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_in_category": "Для этой категории нет подписки.",
@ -289,6 +327,7 @@
"form.feed.label.title": "Название",
"form.feed.label.site_url": "Адрес сайта",
"form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.description": "Описание",
"form.feed.label.category": "Категория",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.feed_username": "Имя пользователя подписки",
@ -303,6 +342,7 @@
"form.feed.label.apprise_service_urls": "Список ссылок сервисов Apprise, разделенный запятой",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP кеш",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Использовать прокси",
"form.feed.label.disabled": "Не обновлять эту подписку",
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
@ -404,16 +444,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Сохранять статьи в Linkding",
"form.integration.linkding_endpoint": "Конечная точка Linkding API",
"form.integration.linkding_api_key": "API-ключ Linkding",
"form.integration.linkding_tags": "Теги Linkding",
"form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
"form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
"form.integration.linkwarden_endpoint": "Конечная точка Linkwarden API",
"form.integration.linkwarden_api_key": "API-ключ Linkwarden",
"form.integration.matrix_bot_activate": "Репостить новые статьи в Matrix",
"form.integration.matrix_bot_user": "Имя пользователя Matrix",
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_api_key": "API-ключ Readeck",
"form.integration.readeck_labels": "Теги Readeck",
"form.integration.readeck_only_url": "Отправлять только ссылку (без содержимого)",
"form.integration.shiori_activate": "Сохранять статьи в Shiori",
"form.integration.shiori_endpoint": "Конечная точка Shiori API",
"form.integration.shiori_username": "Имя пользователя Shiori",
@ -462,13 +520,17 @@
"%d года назад",
"%d лет назад"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -487,5 +549,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,482 +1,514 @@
{
"confirm.question": "Emin misiniz?",
"confirm.question.refresh": "Zorla yenilemek istiyor musunuz?",
"confirm.yes": "evet",
"confirm.no": "hayır",
"confirm.loading": "Devam ediyor...",
"action.subscribe": "Abone Ol",
"action.save": "Kaydet",
"action.or": "veya",
"action.cancel": "iptal",
"action.remove": "Kaldır",
"action.remove_feed": "Bu beslemeyi kaldır",
"action.update": "Güncelle",
"action.edit": "Düzenle",
"action.download": "İndir",
"action.import": "İçeri Aktar",
"action.login": "Giriş",
"action.home_screen": "Ana ekrana ekle",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı",
"menu.unread": "Okunmadı",
"menu.starred": "Yıldız",
"menu.history": "Geçmiş",
"menu.feeds": "Beslemeler",
"menu.categories": "Kategoriler",
"menu.settings": "Ayarlar",
"menu.logout": ıkış",
"menu.preferences": "Tercihler",
"menu.integrations": "Bütünleşmeler",
"menu.sessions": "Oturumlar",
"menu.users": "Kullanıcılar",
"menu.about": "Hakkında",
"menu.export": "Dışarı Aktar",
"menu.import": "İçeri Aktar",
"menu.create_category": "Kategori oluştur",
"menu.mark_page_as_read": "Bu sayfayı okundu olarak işaretle",
"menu.mark_all_as_read": "Tümünü okundu olarak işaretle",
"menu.show_all_entries": "Tüm iletileri göster",
"menu.show_only_unread_entries": "Sadece okunmamış iletileri göster",
"menu.refresh_feed": "Yenile",
"menu.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"menu.edit_feed": "Düzenle",
"menu.edit_category": "Düzenle",
"menu.add_feed": "Abonelik ekle",
"menu.add_user": "Kullanıcı ekle",
"menu.flush_history": "Geçmişi temizle",
"menu.feed_entries": "İletiler",
"menu.api_keys": "API Anahtarları",
"menu.create_api_key": "Yeni bir API anahtarı oluştur",
"menu.shared_entries": "Paylaşılan iletiler",
"search.label": "Ara",
"search.placeholder": "Ara...",
"pagination.next": "Sonraki",
"pagination.previous": "Önceki",
"entry.status.unread": "Okunmadı",
"entry.status.read": "Okundu",
"entry.status.toast.unread": "Okunmadı olarak işaretle",
"entry.status.toast.read": "Okundu olarak işaretle",
"entry.status.title": "İleti durumunu değiştir",
"entry.bookmark.toggle.on": "Yıldız ekle",
"entry.bookmark.toggle.off": "Yıldızı kaldır",
"entry.bookmark.toast.on": "Yıldızlı",
"entry.bookmark.toast.off": "Yıldızsız",
"entry.state.saving": "Kaydediliyor...",
"entry.state.loading": "Yükleniyor...",
"entry.save.label": "Kaydet",
"entry.save.title": "Bu makaleyi kaydet",
"entry.save.completed": "Bitti!",
"entry.save.toast.completed": "Makale kaydedildi",
"entry.scraper.label": "İndir",
"entry.scraper.title": "Orijinal içeriği çek",
"entry.scraper.completed": "Bitti!",
"entry.external_link.label": "Dış bağlantı",
"entry.comments.label": "Yorumlar",
"entry.comments.title": "Yorumları Göster",
"entry.share.label": "Paylaş",
"entry.share.title": "Bu makaleyi paylaş",
"entry.unshare.label": "Paylaşma",
"entry.shared_entry.title": "Herkese açık bağlantıyı aç",
"entry.shared_entry.label": "Paylaş",
"entry.estimated_reading_time": [
"%d dakikalık okuma",
"%d dakikalık okuma"
],
"entry.tags.label": "Etiketleri:",
"page.shared_entries.title": "Paylaşılan iletiler",
"page.unread.title": "Okunmadı",
"page.starred.title": "Yıldızlı",
"page.categories.title": "Kategoriler",
"page.categories.no_feed": "Besleme yok.",
"page.categories.entries": "Makaleler",
"page.categories.feeds": "Abonelikler",
"page.categories.feed_count": [
"%d besleme var.",
"%d besleme var."
],
"page.categories.unread_counter": "Okunmamış iletilerin sayısı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
"page.edit_category.title": "Kategoriyi Düzenle: %s",
"page.edit_user.title": "Kullanıcıyı Düzenle: %s",
"page.feeds.title": "Beslemeler",
"page.feeds.last_check": "Son kontrol:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Okunmamış iletilerin sayısı",
"page.feeds.read_counter": "Okunmuş iletilerin sayısı",
"page.feeds.error_count": [
"%d hata",
"%d hata"
],
"page.history.title": "Geçmiş",
"page.import.title": "İçeri Aktar",
"page.search.title": "Arama Sonuçları",
"page.about.title": "Hakkında",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.version": "Sürüm:",
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.author": "Yazar:",
"page.about.license": "Lisans:",
"page.about.global_config_options": "Global yapılandırma seçenekleri",
"page.about.postgres_version": "Postgres sürümü:",
"page.about.go_version": "Go sürümü:",
"page.add_feed.title": "Yeni Abonelik",
"page.add_feed.no_category": "Kategori yok. En az bir kategoriye sahip olmalısınız.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Bir abonelik bul",
"page.add_feed.legend.advanced_options": "Gelişmiş Seçenekler",
"page.add_feed.choose_feed": "Bir Abonelik Seçin",
"page.edit_feed.title": "Beslemeyi düzenle: %s",
"page.edit_feed.last_check": "Son kontrol:",
"page.edit_feed.last_modified_header": "LastModified başlığı:",
"page.edit_feed.etag_header": "ETag başlığı:",
"page.edit_feed.no_header": "Hiçbiri",
"page.edit_feed.last_parsing_error": "Son Ayrıştırma Hatası",
"page.entry.attachments": "Ekler",
"page.keyboard_shortcuts.title": "Klavye Kısayolları",
"page.keyboard_shortcuts.subtitle.sections": "Bölüm Gezinmesi",
"page.keyboard_shortcuts.subtitle.items": "Öğe Gezinmesi",
"page.keyboard_shortcuts.subtitle.pages": "Sayfa Gezinmesi",
"page.keyboard_shortcuts.subtitle.actions": "Hareketler",
"page.keyboard_shortcuts.go_to_unread": "Okunmamışa git",
"page.keyboard_shortcuts.go_to_starred": "Yer imlerine git",
"page.keyboard_shortcuts.go_to_history": "Geçmişe git",
"page.keyboard_shortcuts.go_to_feeds": "Beslemelere git",
"page.keyboard_shortcuts.go_to_categories": "Kategorilere git",
"page.keyboard_shortcuts.go_to_settings": "Ayarlara git",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Klavye kısayollarını göster",
"page.keyboard_shortcuts.go_to_previous_item": "Önceki öğeye git",
"page.keyboard_shortcuts.go_to_next_item": "Sonraki öğeye git",
"page.keyboard_shortcuts.go_to_feed": "Beslemeye git",
"page.keyboard_shortcuts.go_to_previous_page": "Önceki sayfaya git",
"page.keyboard_shortcuts.go_to_next_page": "Sonraki sayfaya git",
"page.keyboard_shortcuts.open_item": "Seçili öğeyi aç",
"page.keyboard_shortcuts.open_original": "Orijinal bağlantıyı aç",
"page.keyboard_shortcuts.open_original_same_window": "Orijinal bağlantıyı mevcut sekmede aç",
"page.keyboard_shortcuts.open_comments": "Yorumlar bağlantısını aç",
"page.keyboard_shortcuts.open_comments_same_window": "Yorumlar bağlantısını mevcut sekmede aç",
"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",
"page.keyboard_shortcuts.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"page.keyboard_shortcuts.mark_page_as_read": "Mevcut sayfayı okundu olarak işaretle",
"page.keyboard_shortcuts.download_content": "Orijinal içeriği indir",
"page.keyboard_shortcuts.toggle_bookmark_status": "Yer işaretini değiştir",
"page.keyboard_shortcuts.save_article": "Makaleyi kaydet",
"page.keyboard_shortcuts.scroll_item_to_top": "Öğeyi en üste kaydır",
"page.keyboard_shortcuts.remove_feed": "Bu beslemeyi kaldır",
"page.keyboard_shortcuts.go_to_search": "Arama formuna odakla",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "İletişim kutusunu kapat",
"page.users.title": "Kullanıcılar",
"page.users.username": "Kullanıcı adı",
"page.users.never_logged": "Asla",
"page.users.admin.yes": "Evet",
"page.users.admin.no": "Hayır",
"page.users.actions": "Hareketler",
"page.users.last_login": "Son Giriş",
"page.users.is_admin": "Yönetici",
"page.settings.title": "Ayarlar",
"page.settings.link_google_account": "Google hesabımı bağla",
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
"page.settings.link_oidc_account": "OpenID Connect hesabımı bağla",
"page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "şifreyi kaydet",
"page.settings.webauthn.register.error": "Geçiş anahtarı kaydedilemiyor",
"page.settings.webauthn.delete": [
"%d geçiş anahtarını kaldır",
"%d geçiş anahtarını kaldır"
],
"page.login.title": "Oturum aç",
"page.login.google_signin": "Google ile oturum aç",
"page.login.oidc_signin": "OpenID Connect ile oturum aç",
"page.login.webauthn_login": "şifre ile giriş yap",
"page.login.webauthn_login.error": "şifre ile giriş yapılamıyor",
"page.integrations.title": "Bütünleşmeler",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Uç Noktası",
"page.integration.miniflux_api_username": "Kullanıcı adı",
"page.integration.miniflux_api_password": "Parola",
"page.integration.miniflux_api_password_value": "Hesap parolan",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Miniflux'a Ekle",
"page.integration.bookmarklet.instructions": "Bu bağlantıyı yer imlerinize sürükleyip bırakın",
"page.integration.bookmarklet.help": "Bu özel bağlantı, web tarayıcınızdaki yer imini kullanarak bir web sitesine doğrudan abone olmanızı sağlar.",
"page.sessions.title": "Oturumlar",
"page.sessions.table.date": "Tarih",
"page.sessions.table.ip": "IP Adresi",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Hareketler",
"page.sessions.table.current_session": "Mevcut Oturum",
"page.api_keys.title": "API Anahtarları",
"page.api_keys.table.description": "Açıklama",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Son Kullanılma",
"page.api_keys.table.created_at": "Oluşturulma Tarihi",
"page.api_keys.table.actions": "Hareketler",
"page.api_keys.never_used": "Hiç Kullanılmadı",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.offline.title": "Çevrimdışı Modu",
"page.offline.message": "Çevrimdışısınız",
"page.offline.refresh_page": "Sayfayı yenilemeyi dene",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Paylaşılan ileti yok.",
"alert.no_bookmark": "Şu anda hiç yer imi yok.",
"alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makale yok.",
"alert.no_feed_entry": "Bu besleme için makale yok.",
"alert.no_feed": "Hiç aboneliğiniz yok.",
"alert.no_feed_in_category": "Bu kategori için aboneliğiniz yok.",
"alert.no_history": "Şu anda hiç geçmiş yok.",
"alert.feed_error": "Bu beslemeyle ilgili bir problem var",
"alert.no_search_result": "Bu arama için sonuç yok",
"alert.no_unread_entry": "Okunmamış makale yok",
"alert.no_user": "Tek kullanıcı sizsiniz",
"alert.account_unlinked": "Harici hesabınızın bağlantısı kaldırıldı!",
"alert.account_linked": "Harici hesabınız bağlandı.",
"alert.pocket_linked": "Pocket hesabınız bağlandı.",
"alert.prefs_saved": "Tercihler kaydedildi!",
"error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
"error.duplicate_linked_account": "Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!",
"error.duplicate_fever_username": "Aynı Fever kullanıcı adına sahip başka biri zaten var!",
"error.duplicate_googlereader_username": "Aynı Google Reader kullanıcı adına sahip başka biri zaten var!",
"error.pocket_request_token": "Pocket'tan istek tokeni alınamıyor!",
"error.pocket_access_token": "Pocket'tan erişim tokeni alınamıyor!",
"error.category_already_exists": "Bu kategori zaten mevcut.",
"error.unable_to_create_category": "Bu kategori oluşturulamıyor.",
"error.unable_to_update_category": "Bu kategori güncellenemiyor.",
"error.user_already_exists": "Bu kullanıcı zaten mevcut.",
"error.unable_to_create_user": "Bu kullanıcı oluşturulamıyor.",
"error.unable_to_update_user": "Bu kullanıcı güncellenemiyor.",
"error.unable_to_update_feed": "Bu besleme güncellenemiyor.",
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
"error.invalid_theme": "Geçersiz tema.",
"error.invalid_language": "Geçersiz dil.",
"error.invalid_timezone": "Geçersiz saat dilimi",
"error.invalid_entry_direction": "Geçersiz giriş yönü.",
"error.invalid_display_mode": "Geçersiz web uygulaması görüntüleme modu.",
"error.invalid_gesture_nav": "Hareketle gezinme geçersiz.",
"error.invalid_default_home_page": "Geçersiz varsayılan ana sayfa!",
"error.empty_file": "Bu dosya boş.",
"error.bad_credentials": "Geçersiz kullanıcı veya parola.",
"error.fields_mandatory": "Tüm alanlar zorunlu.",
"error.title_required": "Başlık zorunlu.",
"error.different_passwords": "Parolalar eşleşmiyor.",
"error.password_min_length": "Parola en az 6 karakter içermeli.",
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
"error.entries_per_page_invalid": "Sayfa başına ileti sayısı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_already_exists": "Bu besleme zaten mevcut.",
"error.invalid_feed_url": "Geçersiz besleme URL'si.",
"error.invalid_site_url": "Geçersiz site URL'si.",
"error.feed_url_not_empty": "Besleme URL'si boş olamaz.",
"error.site_url_not_empty": "Site URL'si boş olamaz.",
"error.feed_title_not_empty": "Besleme başlığı boş olamaz.",
"error.feed_category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_invalid_blocklist_rule": "Engelleme listesi kuralı geçersiz.",
"error.feed_invalid_keeplist_rule": "Saklama listesi kuralı geçersiz.",
"error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
"error.api_key_already_exists": "Bu API anahtarı zaten mevcut.",
"error.unable_to_create_api_key": "Bu API anahtarı oluşturulamıyor.",
"form.feed.label.title": "Başlık",
"form.feed.label.site_url": "Site URL'si",
"form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.scraper_rules": "Scrapper Kuralları",
"form.feed.label.rewrite_rules": "Yeniden Yazma Kuralları",
"form.feed.label.blocklist_rules": "Engelleme Kuralları",
"form.feed.label.keeplist_rules": "Saklama Kuralları",
"form.feed.label.urlrewrite_rules": "URL Yeniden Yazma Kuralları",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Başlık",
"form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.user.label.username": "Kullanıcı Adı",
"form.user.label.password": "Parola",
"form.user.label.confirmation": "Parola Doğrulama",
"form.user.label.admin": "Yönetici",
"form.prefs.label.language": "Dil",
"form.prefs.label.timezone": "Saat Dilimi",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "İleti Sıralaması",
"form.prefs.label.entries_per_page": "Sayfa başına ileti",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.display_mode": "Aşamalı Web Uygulaması (PWA) görüntüleme modu",
"form.prefs.select.older_first": "Önce eski iletiler",
"form.prefs.select.recent_first": "Önce yeni iletiler",
"form.prefs.select.fullscreen": "Tam Ekran",
"form.prefs.select.standalone": "Bağımsız",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Tarayıcı",
"form.prefs.select.publish_time": "Giriş yayınlanma zamanı",
"form.prefs.select.created_time": "Girişin oluşturulma zamanı",
"form.prefs.select.alphabetical": "Alfabetik",
"form.prefs.select.unread_count": "Okunmamış sayısı",
"form.prefs.select.none": "Hiçbiri",
"form.prefs.select.tap": "çift dokunma",
"form.prefs.select.swipe": "Tokatlamak",
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
"form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
"form.prefs.label.gesture_nav": "Girişler arasında gezinmek için hareket",
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
"form.prefs.label.custom_css": "Özel CSS",
"form.prefs.label.entry_order": "Giriş Sıralama Sütunu",
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
"form.prefs.label.categories_sorting_order": "Kategoriler sıralama",
"form.prefs.label.mark_read_on_view": "Girişleri görüntülendiğinde otomatik olarak okundu olarak işaretle",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Fever API'yi Etkinleştir",
"form.integration.fever_username": "Fever Kullanıcı Adı",
"form.integration.fever_password": "Fever Parolası",
"form.integration.fever_endpoint": "Fever API uç noktası:",
"form.integration.googlereader_activate": "Google Reader API'yi Etkinleştir",
"form.integration.googlereader_username": "Google Reader Kullanıcı Adı",
"form.integration.googlereader_password": "Google Reader Parolası",
"form.integration.googlereader_endpoint": "Google Reader API uç noktası:",
"form.integration.pinboard_activate": "Makaleleri Pinboard'a kaydet",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard Etiketleri",
"form.integration.pinboard_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
"form.integration.instapaper_username": "Instapaper Kullanıcı Adı",
"form.integration.instapaper_password": "Instapaper Parolası",
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam içerik yerine)",
"form.integration.wallabag_endpoint": "Wallabag API Uç Noktası",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
"form.integration.wallabag_password": "Wallabag Parolası",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",
"form.integration.omnivore_activate": "Makaleleri Omnivore'a kaydet",
"form.integration.omnivore_url": "Omnivore API Uç Noktası",
"form.integration.omnivore_api_key": "Omnivore API anahtarı",
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
"form.integration.espial_endpoint": "Espial API Uç Noktası",
"form.integration.espial_api_key": "Espial API Anahtarı",
"form.integration.espial_tags": "Espial Etiketleri",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Yeni makaleleri Telegram sohbetine gönderin",
"form.integration.telegram_bot_token": "Bot jetonu",
"form.integration.telegram_chat_id": "Sohbet kimliği",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkding_activate": "Makaleleri Linkding'e kaydet",
"form.integration.linkding_endpoint": "Linkding API Uç Noktası",
"form.integration.linkding_api_key": "Linkding API Anahtarı",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.matrix_bot_activate": "Yeni makaleleri Matrix'e aktarın",
"form.integration.matrix_bot_user": "Matrix için Kullanıcı Adı",
"form.integration.matrix_bot_password": "Matrix kullanıcısı için şifre",
"form.integration.matrix_bot_url": "Matris sunucusu URL'si",
"form.integration.matrix_bot_chat_id": "Matris odasının kimliği",
"form.integration.shiori_activate": "Makaleleri Shiori'e kaydet",
"form.integration.shiori_endpoint": "Shiori API Uç Noktası",
"form.integration.shiori_username": "Shiori Kullanıcı Adı",
"form.integration.shiori_password": "Shiori Parolası",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "API Anahtar Etiketi",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"time_elapsed.not_yet": "henüz değil",
"time_elapsed.yesterday": "dün",
"time_elapsed.now": "şimdi",
"time_elapsed.minutes": [
"%d dakika önce",
"%d dakika önce"
],
"time_elapsed.hours": [
"%d saat önce",
"%d saat önce"
],
"time_elapsed.days": [
"%d gün önce",
"%d gün önce"
],
"time_elapsed.weeks": [
"%d hafta önce",
"%d hafta önce"
],
"time_elapsed.months": [
"%d ay önce",
"%d ay önce"
],
"time_elapsed.years": [
"%d yıl önce",
"%d yıl önce"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"action.cancel": "iptal",
"action.download": "İndir",
"action.edit": "Düzenle",
"action.home_screen": "Ana ekrana ekle",
"action.import": "İçeri Aktar",
"action.login": "Giriş",
"action.or": "veya",
"action.remove": "Kaldır",
"action.remove_feed": "Bu beslemeyi kaldır",
"action.save": "Kaydet",
"action.subscribe": "Abone Ol",
"action.update": "Güncelle",
"alert.account_linked": "Harici hesabınız bağlandı!",
"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_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
"alert.no_feed": "Hiç beslemeniz yok.",
"alert.no_feed_entry": "Bu besleme için makele yok.",
"alert.no_feed_in_category": "Bu kategori için besleme yok.",
"alert.no_history": "Şu anda hiç geçmiş yok.",
"alert.no_search_result": "Bu arama için sonuç yok",
"alert.no_shared_entry": "Paylaşılan bir makele yok.",
"alert.no_unread_entry": "Okunmamış makele yok",
"alert.no_user": "Tek kullanıcı sizsiniz",
"alert.pocket_linked": "Pocket hesabınız artık bağlandı.",
"alert.prefs_saved": "Tercihler kaydedildi!",
"alert.too_many_feeds_refresh": [
"Çok fazla besleme yenilemesi başlattınız. Tekrar denemeden önce lütfen %d dakika bekleyin.",
"Çok fazla besleme yenilemesi başlattınız. Tekrar denemeden önce lütfen %d dakika bekleyin."
],
"confirm.loading": "Devam ediyor...",
"confirm.no": "hayır",
"confirm.question": "Emin misiniz?",
"confirm.question.refresh": "Zorla yenilemek istiyor musunuz?",
"confirm.yes": "evet",
"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.comments.label": "Yorumlar",
"entry.comments.title": "Yorumları Göster",
"entry.estimated_reading_time": [
"%d dakika okuma süresi",
"%d dakika okuma süresi"
],
"entry.external_link.label": "Dış bağlantı",
"entry.save.completed": "Tamamlandı!",
"entry.save.label": "Kaydet",
"entry.save.title": "Bu makeleyi kaydet",
"entry.save.toast.completed": "Makele kaydedildi",
"entry.scraper.completed": "Tamamlandı!",
"entry.scraper.label": "İndir",
"entry.scraper.title": "Orijinal içeriği çek",
"entry.share.label": "Paylaş",
"entry.share.title": "Bu makeleyi paylaş",
"entry.shared_entry.label": "Paylaş",
"entry.shared_entry.title": "Herkese açık bağlantıyı aç",
"entry.state.loading": "Yükleniyor...",
"entry.state.saving": "Kaydediliyor...",
"entry.status.read": "Okundu",
"entry.status.title": "Makele okundu durumunu değiştir",
"entry.status.toast.read": "Okundu olarak işaretle",
"entry.status.toast.unread": "Okunmadı olarak işaretle",
"entry.status.unread": "Okunmadı",
"entry.tags.label": "Etiketler:",
"entry.unshare.label": "Paylaşma",
"error.api_key_already_exists": "Bu API anahtarı zaten mevcut.",
"error.bad_credentials": "Geçersiz kullanıcı veya parola.",
"error.category_already_exists": "Bu kategori zaten mevcut.",
"error.category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.database_error": "Veritabanı hatası: %v.",
"error.different_passwords": "Parolalar eşleşmiyor.",
"error.duplicate_fever_username": "Aynı Fever kullanıcı adına sahip başka biri zaten var!",
"error.duplicate_googlereader_username": "Aynı Google Reader kullanıcı adına sahip başka biri zaten var!",
"error.duplicate_linked_account": "Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!",
"error.duplicated_feed": "Bu makele zaten var.",
"error.empty_file": "Bu dosya boş.",
"error.entries_per_page_invalid": "Sayfa başına makele sayısı geçersiz.",
"error.feed_already_exists": "Bu besleme zaten mevcut.",
"error.feed_category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_format_not_detected": "Besleme formatı algılanamadı: %v.",
"error.feed_invalid_blocklist_rule": "Engelleme listesi kuralı geçersiz.",
"error.feed_invalid_keeplist_rule": "Saklama listesi kuralı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_not_found": "Bu makele mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_title_not_empty": "Besleme başlığı boş olamaz.",
"error.feed_url_not_empty": "Besleme URL'si boş olamaz.",
"error.fields_mandatory": "Tüm alanlar zorunlu.",
"error.http_bad_gateway": "Kötü ağ geçidi hatası nedeniyle bu website şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_body_read": "HTTP gövdesi okunamıyor: %v.",
"error.http_client_error": "HTTP istemci hatası: %v.",
"error.http_empty_response": "HTTP yanıtı boş. Belki bu web sitesi bir bot koruma mekanizması kullanıyordur?",
"error.http_empty_response_body": "HTTP yanıt gövdesi boş.",
"error.http_forbidden": "Bu siteye erişim yasak. Belki bu web sitesinin bir bot koruma mekanizması vardır?",
"error.http_gateway_timeout": "Ağ geçidi zaman aşımı hatası nedeniyle bu websitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_internal_server_error": "Sunucu hatası nedeniyle bu websitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_not_authorized": "Bu web sitesine erişim izni verilmemektedir. Kötü bir kullanıcı adı veya şifreden kaynaklanıyor olabilir.",
"error.http_resource_not_found": "İstenilen kaynak bulunamadı. Lütfen URL'yi doğrulayın.",
"error.http_response_too_large": "HTTP yanıtı çok büyük. Genel ayarlardan HTTP yanıt boyutu sınırını artırabilirsiniz (sunucunun yeniden başlatılmasını gerektirir).",
"error.http_service_unavailable": "Dahili sunucu hatası nedeniyle web sitesi şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.http_too_many_requests": "Miniflux bu web sitesine çok fazla istek oluşturdu. Lütfen daha sonra tekrar deneyin veya uygulama yapılandırmasını değiştirin.",
"error.http_unexpected_status_code": "Beklenmeyen bir HTTP durum kodu nedeniyle bu websitesi şu anda kullanılamıyor: %d. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
"error.invalid_default_home_page": "Geçersiz varsayılan ana sayfa!",
"error.invalid_display_mode": "Geçersiz web uygulaması görüntüleme modu.",
"error.invalid_entry_direction": "Geçersiz makele sıralaması.",
"error.invalid_feed_url": "Geçersiz besleme URL'si.",
"error.invalid_gesture_nav": "Hareketle gezinme geçersiz.",
"error.invalid_language": "Geçersiz dil.",
"error.invalid_site_url": "Geçersiz site URL'si.",
"error.invalid_theme": "Geçersiz tema.",
"error.invalid_timezone": "Geçersiz saat dilimi.",
"error.network_operation": "Miniflux bir ağ hatası nedeniyle bu websitesine erişemiyor: %v.",
"error.network_timeout": "Bu websitesi çok yavaş ve istek zaman aşımına uğradı: %v",
"error.password_min_length": "Parola en az 6 karakter içermeli.",
"error.pocket_access_token": "Pocket'tan access tokeni alınamıyor!",
"error.pocket_request_token": "Pocket'tan request tokeni alınamıyor!",
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
"error.settings_media_playback_rate_range": "Oynatma hızı aralık dışında",
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
"error.site_url_not_empty": "Site URL'si boş olamaz.",
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
"error.title_required": "Başlık zorunlu.",
"error.tls_error": "TLS hatası: %q. İsterseniz feed ayarlarından TLS doğrulamasını devre dışı bırakabilirsiniz.",
"error.unable_to_create_api_key": "Bu API anahtarı oluşturulamıyor.",
"error.unable_to_create_category": "Bu kategori oluşturulamıyor.",
"error.unable_to_create_user": "Bu kullanıcı oluşturulamıyor.",
"error.unable_to_detect_rssbridge": "RSS-Bridge kullanılarak besleme algılanamıyor: %v.",
"error.unable_to_parse_feed": "Bu besleme ayrıştırılamıyor: %v.",
"error.unable_to_update_category": "Bu kategori güncellenemiyor.",
"error.unable_to_update_feed": "Bu besleme güncellenemiyor.",
"error.unable_to_update_user": "Bu kullanıcı güncellenemiyor.",
"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.",
"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",
"form.feed.fieldset.general": "Genel",
"form.feed.fieldset.integration": "Üçüncü Taraf Hizmetleri",
"form.feed.fieldset.network_settings": "Ağ Ayarları",
"form.feed.fieldset.rules": "Kurallar",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.apprise_service_urls": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
"form.feed.label.blocklist_rules": "Engelleme Kuralları",
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.disable_http2": "Parmak izini önlemek için HTTP/2'yi devre dışı bırakın",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.description": "Açıklama",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.keeplist_rules": "Saklama Kuralları",
"form.feed.label.no_media_player": "Medya oynatıcı yok (ses/video)",
"form.feed.label.rewrite_rules": "Yeniden Yazma Kuralları",
"form.feed.label.scraper_rules": "Scrapper Kuralları",
"form.feed.label.site_url": "Site URL'si",
"form.feed.label.title": "Başlık",
"form.feed.label.urlrewrite_rules": "URL Yeniden Yazma Kuralları",
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
"form.integration.espial_api_key": "Espial API Anahtarı",
"form.integration.espial_endpoint": "Espial API Uç Noktası",
"form.integration.espial_tags": "Espial Etiketleri",
"form.integration.fever_activate": "Fever API'yi Etkinleştir",
"form.integration.fever_endpoint": "Fever API uç noktası:",
"form.integration.fever_password": "Fever Parolası",
"form.integration.fever_username": "Fever Kullanıcı Adı",
"form.integration.googlereader_activate": "Google Reader API'yi Etkinleştir",
"form.integration.googlereader_endpoint": "Google Reader API uç noktası:",
"form.integration.googlereader_password": "Google Reader Parolası",
"form.integration.googlereader_username": "Google Reader Kullanıcı Adı",
"form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
"form.integration.instapaper_password": "Instapaper Parolası",
"form.integration.instapaper_username": "Instapaper Kullanıcı Adı",
"form.integration.linkace_activate": "Makaleleri LinkAce'e kaydet",
"form.integration.linkace_api_key": "LinkAce API anahtarı",
"form.integration.linkace_check_disabled": "Link kontrolünü devre dışı bırak",
"form.integration.linkace_endpoint": "LinkAce API Uç Noktası",
"form.integration.linkace_is_private": "Linki özel olarak işaretle",
"form.integration.linkace_tags": "LinkAce Etiketleri",
"form.integration.linkding_activate": "Makaleleri Linkding'e kaydet",
"form.integration.linkding_api_key": "Linkding API Anahtarı",
"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.linkwarden_activate": "Makaleleri Linkwarden'e kaydet",
"form.integration.linkwarden_api_key": "Linkwarden API Anahtarı",
"form.integration.linkwarden_endpoint": "Linkwarden API Uç Noktası",
"form.integration.matrix_bot_activate": "Yeni makaleleri Matrix'e aktarın",
"form.integration.matrix_bot_chat_id": "Matrix odasının kimliği",
"form.integration.matrix_bot_password": "Matrix kullanıcısı için parola",
"form.integration.matrix_bot_url": "Matrix sunucu URL'si",
"form.integration.matrix_bot_user": "Matrix için Kullanıcı Adı",
"form.integration.notion_activate": "Makaleleri Notion'a kaydet",
"form.integration.notion_page_id": "Notion Sayfa ID'si",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
"form.integration.omnivore_activate": "Makaleleri Omnivore'a kaydet",
"form.integration.omnivore_api_key": "Omnivore API anahtarı",
"form.integration.omnivore_url": "Omnivore API Uç Noktası",
"form.integration.pinboard_activate": "Makaleleri Pinboard'a kaydet",
"form.integration.pinboard_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.pinboard_tags": "Pinboard Etiketleri",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
"form.integration.readeck_api_key": "Readeck API Anahtarı",
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
"form.integration.readeck_labels": "Readeck Etiketleri",
"form.integration.readeck_only_url": "Yalnızca URL gönder (tam makale yerine)",
"form.integration.readwise_activate": "Makaleleri Readwise Reader'a kaydet",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Readwise Access Token'ınızı alın",
"form.integration.rssbridge_activate": "Abonelik eklerken RSS-Bridge'i kontrol edin",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Makaleleri Shaarli'ye kaydet",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Makaleleri Shiori'ye kaydet",
"form.integration.shiori_endpoint": "Shiori API Uç Noktası",
"form.integration.shiori_password": "Shiori Parolası",
"form.integration.shiori_username": "Shiori Kullanıcı Adı",
"form.integration.telegram_bot_activate": "Yeni makaleleri Telegram sohbetine gönderin",
"form.integration.telegram_bot_disable_buttons": "Butonları devre dışı bırak",
"form.integration.telegram_bot_disable_notification": "Bildirimleri devre dışı bırak",
"form.integration.telegram_bot_disable_web_page_preview": "Web sayfası önizlemesini devre dışı bırak",
"form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Sohbet ID",
"form.integration.telegram_topic_id": "Konu ID",
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_endpoint": "Wallabag API Uç Noktası",
"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.webhook_activate": "Webhook'u etkinleştir",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.webhook_url": "Webhook URL",
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.custom_css": "Özel CSS",
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
"form.prefs.label.entries_per_page": "Sayfa başına makale",
"form.prefs.label.entry_order": "Makale Sıralama Sütunu",
"form.prefs.label.entry_sorting": "Makale Sıralaması",
"form.prefs.label.entry_swipe": "Dokunmatik ekranlarda makale kaydırmayı etkinleştir",
"form.prefs.label.gesture_nav": "Makaleler arasında gezinmek için dokunma hareketi",
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
"form.prefs.label.language": "Dil",
"form.prefs.label.mark_read_on_view": "Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle",
"form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Saat Dilimi",
"form.prefs.select.alphabetical": "Alfabetik",
"form.prefs.select.browser": "Tarayıcı",
"form.prefs.select.created_time": "İçeriğin oluşturulma zamanı",
"form.prefs.select.fullscreen": "Tam Ekran",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.none": "Hiçbiri",
"form.prefs.select.older_first": "Önce eski makaleler",
"form.prefs.select.publish_time": "Makale yayınlanma zamanı",
"form.prefs.select.recent_first": "Önce yeni makaleler",
"form.prefs.select.standalone": "Bağımsız",
"form.prefs.select.swipe": "Kaydırma",
"form.prefs.select.tap": "Çift dokunma",
"form.prefs.select.unread_count": "Okunmamış sayısı",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"form.user.label.admin": "Yönetici",
"form.user.label.confirmation": "Parola Doğrulama",
"form.user.label.password": "Parola",
"form.user.label.username": "Kullanıcı Adı",
"menu.about": "Hakkında",
"menu.add_feed": "Besleme ekle",
"menu.add_user": "Kullanıcı ekle",
"menu.api_keys": "API Anahtarları",
"menu.categories": "Kategoriler",
"menu.create_api_key": "Yeni bir API anahtarı oluştur",
"menu.create_category": "Kategori oluştur",
"menu.edit_category": "Düzenle",
"menu.edit_feed": "Düzenle",
"menu.export": "Dışarı Aktar",
"menu.feed_entries": "Makaleler",
"menu.feeds": "Beslemeler",
"menu.flush_history": "Geçmişi temizle",
"menu.history": "Geçmiş",
"menu.home_page": "Anasayfa",
"menu.import": "İçeri Aktar",
"menu.integrations": "Entegrasyonlar",
"menu.logout": ıkış",
"menu.mark_all_as_read": "Tümünü okundu olarak işaretle",
"menu.mark_page_as_read": "Bu sayfayı okundu olarak işaretle",
"menu.preferences": "Tercihler",
"menu.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"menu.refresh_feed": "Yenile",
"menu.search": "Ara",
"menu.sessions": "Oturumlar",
"menu.settings": "Ayarlar",
"menu.shared_entries": "Paylaşılan makaleler",
"menu.show_all_entries": "Tüm makaleleri göster",
"menu.show_only_unread_entries": "Sadece okunmamış makaleleri göster",
"menu.starred": "Yıldız",
"menu.title": "Menü",
"menu.unread": "Okunmadı",
"menu.users": "Kullanıcılar",
"page.about.author": "Yazar:",
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.global_config_options": "Global yapılandırma seçenekleri",
"page.about.go_version": "Go sürümü:",
"page.about.license": "Lisans:",
"page.about.postgres_version": "Postgres sürümü:",
"page.about.title": "Hakkında",
"page.about.version": "Sürüm:",
"page.add_feed.choose_feed": "Bir Besleme Seçin",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "Gelişmiş Seçenekler",
"page.add_feed.no_category": "Kategori yok. En az bir kategoriye sahip olmalısınız.",
"page.add_feed.submit": "Besleme bul",
"page.add_feed.title": "Yeni Besleme",
"page.api_keys.never_used": "Hiç Kullanılmadı",
"page.api_keys.table.actions": "Hareketler",
"page.api_keys.table.created_at": "Oluşturulma Tarihi",
"page.api_keys.table.description": "Açıklama",
"page.api_keys.table.last_used_at": "Son Kullanılma",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API Anahtarları",
"page.categories.entries": "Makaleler",
"page.categories.feed_count": ["%d besleme var.", "%d besleme var."],
"page.categories.feeds": "Beslemeler",
"page.categories.no_feed": "Besleme yok.",
"page.categories.title": "Kategoriler",
"page.categories_count": ["%d kategori", "%d kategori"],
"page.category_label": "Kategori: %s",
"page.edit_category.title": "Kategoriyi Düzenle: %s",
"page.edit_feed.etag_header": "ETag başlığı:",
"page.edit_feed.last_check": "Son kontrol:",
"page.edit_feed.last_modified_header": "LastModified başlığı:",
"page.edit_feed.last_parsing_error": "Son Ayrıştırma Hatası",
"page.edit_feed.no_header": "Hiçbiri",
"page.edit_feed.title": "Beslemeyi düzenle: %s",
"page.edit_user.title": "Kullanıcıyı Düzenle: %s",
"page.entry.attachments": "Ekler",
"page.feeds.error_count": ["%d hatası", "%d hatası"],
"page.feeds.last_check": "Son kontrol:",
"page.feeds.next_check": "Sonraki kontrol:",
"page.feeds.read_counter": "Okunmuş makalelerin sayısı",
"page.feeds.title": "Beslemeler",
"page.history.title": "Geçmiş",
"page.import.title": "İçeri Aktar",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.help": "Bu özel bağlantı, web tarayıcınızdaki yer imini kullanarak bir websitesine doğrudan abone olmanızı sağlar.",
"page.integration.bookmarklet.instructions": "Bu bağlantıyı yer imlerinize sürükleyip bırakın",
"page.integration.bookmarklet.name": "Miniflux'a Ekle",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Uç Noktası",
"page.integration.miniflux_api_password": "Parola",
"page.integration.miniflux_api_password_value": "Hesap parolan",
"page.integration.miniflux_api_username": "Kullanıcı adı",
"page.integrations.title": "Entegrasyonlar",
"page.keyboard_shortcuts.close_modal": "İletişim kutusunu kapat",
"page.keyboard_shortcuts.download_content": "Orijinal içeriği indir",
"page.keyboard_shortcuts.go_to_bottom_item": "Alt makeleye git",
"page.keyboard_shortcuts.go_to_categories": "Kategorilere git",
"page.keyboard_shortcuts.go_to_feed": "Beslemeye git",
"page.keyboard_shortcuts.go_to_feeds": "Beslemelere git",
"page.keyboard_shortcuts.go_to_history": "Geçmişe git",
"page.keyboard_shortcuts.go_to_next_item": "Sonraki makeleye git",
"page.keyboard_shortcuts.go_to_next_page": "Sonraki sayfaya git",
"page.keyboard_shortcuts.go_to_previous_item": "Önceki makeleye git",
"page.keyboard_shortcuts.go_to_previous_page": "Önceki sayfaya git",
"page.keyboard_shortcuts.go_to_search": "Arama formuna odakla",
"page.keyboard_shortcuts.go_to_settings": "Ayarlara git",
"page.keyboard_shortcuts.go_to_starred": "Yer imlerine git",
"page.keyboard_shortcuts.go_to_top_item": "En üstteki makeleye git",
"page.keyboard_shortcuts.go_to_unread": "Okunmamışa git",
"page.keyboard_shortcuts.mark_page_as_read": "Mevcut sayfayı okundu olarak işaretle",
"page.keyboard_shortcuts.open_comments": "Yorumlar bağlantısını aç",
"page.keyboard_shortcuts.open_comments_same_window": "Yorumlar bağlantısını mevcut sekmede aç",
"page.keyboard_shortcuts.open_item": "Seçili makeleyi aç",
"page.keyboard_shortcuts.open_original": "Orijinal bağlantıyı aç",
"page.keyboard_shortcuts.open_original_same_window": "Orijinal bağlantıyı mevcut sekmede aç",
"page.keyboard_shortcuts.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"page.keyboard_shortcuts.remove_feed": "Bu beslemeyi kaldır",
"page.keyboard_shortcuts.save_article": "İçeriği kaydet",
"page.keyboard_shortcuts.scroll_item_to_top": "Makaleyi en üste kaydır",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Klavye kısayollarını göster",
"page.keyboard_shortcuts.subtitle.actions": "Eylemler",
"page.keyboard_shortcuts.subtitle.items": "Makalelerde Gezinme",
"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_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",
"page.login.google_signin": "Google ile oturum aç",
"page.login.oidc_signin": "OpenID Connect ile oturum aç",
"page.login.title": "Oturum aç",
"page.login.webauthn_login": "Passkey ile giriş yap",
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
"page.offline.message": "Çevrimdışısınız",
"page.offline.refresh_page": "Sayfayı yenilemeyi dene",
"page.offline.title": "Çevrimdışı Modu",
"page.read_entry_count": ["%d okunmuş makale", "%d okunmuş makale"],
"page.search.title": "Arama Sonuçları",
"page.sessions.table.actions": "Eylemler",
"page.sessions.table.current_session": "Mevcut Oturum",
"page.sessions.table.date": "Tarih",
"page.sessions.table.ip": "IP Adresi",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.title": "Oturumlar",
"page.settings.link_google_account": "Google hesabımı bağla",
"page.settings.link_oidc_account": "OpenID Connect hesabımı bağla",
"page.settings.title": "Ayarlar",
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
"page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır",
"page.settings.webauthn.actions": "Eylemler",
"page.settings.webauthn.added_on": "Eklendi",
"page.settings.webauthn.delete": [
"%d passkey'i kaldır",
"%d passkey'i kaldır"
],
"page.settings.webauthn.last_seen_on": "Son Kullanım",
"page.settings.webauthn.passkey_name": "Passkey Adı",
"page.settings.webauthn.passkeys": "Passkeyler",
"page.settings.webauthn.register": "Passkey'i kaydet",
"page.settings.webauthn.register.error": "Passkey kaydedilemiyor",
"page.shared_entries.title": "Paylaşılan makaleler",
"page.shared_entries_count": [
"%d paylaşılan makaleler",
"%d paylaşılan makaleler"
],
"page.starred.title": "Yıldızlı",
"page.starred_entry_count": [
"%d yıldızlanmış makale",
"%d yıldızlanmış makale"
],
"page.total_entry_count": ["Toplamda %d makale", "Toplamda %d makale"],
"page.unread.title": "Okunmadı",
"page.unread_entry_count": [
"Toplamda %d okunmamış makale",
"Toplamda %d okunmamış makale"
],
"page.users.actions": "Eylemler",
"page.users.admin.no": "Hayır",
"page.users.admin.yes": "Evet",
"page.users.is_admin": "Yönetici",
"page.users.last_login": "Son Giriş",
"page.users.never_logged": "Asla",
"page.users.title": "Kullanıcılar",
"page.users.username": "Kullanıcı adı",
"page.webauthn_rename.title": "Passkey'i Yeniden Adlandır",
"pagination.next": "Sonraki",
"pagination.previous": "Önceki",
"search.label": "Ara",
"search.placeholder": "Ara...",
"search.submit": "Ara",
"skip_to_content": "İçeriğe atla",
"time_elapsed.days": ["%d gün önce", "%d gün önce"],
"time_elapsed.hours": ["%d saat önce", "%d saat önce"],
"time_elapsed.minutes": ["%d dakika önce", "%d dakika önce"],
"time_elapsed.months": ["%d ay önce", "%d ay önce"],
"time_elapsed.not_yet": "henüz değil",
"time_elapsed.now": "şimdi",
"time_elapsed.weeks": ["%d hafta önce", "%d hafta önce"],
"time_elapsed.years": ["%d yıl önce", "%d yıl önce"],
"time_elapsed.yesterday": "dün",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Ви впевнені?",
"confirm.question.refresh": "Ви хочете змусити оновити?",
"confirm.yes": "так",
@ -18,6 +19,8 @@
"action.home_screen": "Додати до головного екрану",
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
"tooltip.logged_user": "Здійснено вхід як %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "Непрочитане",
"menu.starred": "З зірочкою",
"menu.history": "Історія",
@ -32,6 +35,7 @@
"menu.about": "Про додаток",
"menu.export": "Експорт",
"menu.import": "Імпорт",
"menu.search": "Пошук",
"menu.create_category": "Створити категорію",
"menu.mark_page_as_read": "Відмітити цю сторінку як прочитане",
"menu.mark_all_as_read": "Відмітити все як прочитане",
@ -50,6 +54,7 @@
"menu.shared_entries": "Спільні записи",
"search.label": "Пошук",
"search.placeholder": "Шукати...",
"search.submit": "Search",
"pagination.next": "Вперед",
"pagination.previous": "Назад",
"entry.status.unread": "Непрочитане",
@ -85,8 +90,28 @@
],
"entry.tags.label": "Теги:",
"page.shared_entries.title": "Спильні записи",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
"%d shared entries"
],
"page.unread.title": "Непрочитане",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries",
"%d unread entries"
],
"page.total_entry_count": [
"%d entry in total",
"%d entries in total",
"%d entries in total"
],
"page.starred.title": "З зірочкою",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries",
"%d starred entries"
],
"page.categories.title": "Категорії",
"page.categories.no_feed": "Немає стрічки.",
"page.categories.entries": "Статті",
@ -96,15 +121,19 @@
"Містить %d стрічки.",
"Містить %d стрічок."
],
"page.categories.unread_counter": "Кількість непрочитаних записів",
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.new_category.title": "Нова категорія",
"page.new_user.title": "Новий користувач",
"page.edit_category.title": "Редагування категорії: %s",
"page.edit_user.title": "Редагування користувача: %s",
"page.feeds.title": "Стрічки",
"page.category_label": "Category: %s",
"page.feeds.last_check": "Остання перевірка:",
"page.feeds.next_check": "Next check:",
"page.feeds.unread_counter": "Кількість непрочитаних записів",
"page.feeds.read_counter": "Кількість прочитаних записів",
"page.feeds.error_count": [
"%d помилка",
@ -112,6 +141,11 @@
"%d помилок"
],
"page.history.title": "Історія",
"page.read_entry_count": [
"%d read entry",
"%d read entries",
"%d read entries"
],
"page.import.title": "Імпорт",
"page.search.title": "Результати пошуку",
"page.about.title": "Про додадок",
@ -153,6 +187,8 @@
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти до нижнього пункту",
"page.keyboard_shortcuts.go_to_top_item": "Перейти до верхнього пункту",
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
@ -232,6 +268,7 @@
"alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed": "У вас немає підписок.",
"alert.no_feed_in_category": "У цій категорії немає підписок.",
@ -290,6 +327,7 @@
"form.feed.label.title": "Назва",
"form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.description": "Опис",
"form.feed.label.category": "Категорія",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
@ -304,6 +342,7 @@
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Ігнорувати кеш HTTP",
"form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Використати проксі-сервер",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
"form.feed.label.no_media_player": "No media player (audio/video)",
@ -405,16 +444,34 @@
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_chat_id": "ID чату",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Зберігати статті до Linkding",
"form.integration.linkding_endpoint": "Linkding API Endpoint",
"form.integration.linkding_api_key": "Ключ API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Відмічати закладку як непрочитану",
"form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
"form.integration.linkwarden_api_key": "Ключ API Linkwarden",
"form.integration.matrix_bot_activate": "Перенесення нових статей в Матрицю",
"form.integration.matrix_bot_user": "Ім'я користувача для Matrix",
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Зберігати статті до Readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Ключ API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_username": "Shiori Username",
@ -463,13 +520,17 @@
"%d роки тому",
"%d років тому"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -488,5 +549,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "您确认吗?",
"confirm.question.refresh": "您是否要强制刷新?",
"confirm.yes": "是",
@ -18,6 +19,8 @@
"action.home_screen": "添加到主屏幕",
"tooltip.keyboard_shortcuts": "快捷键: %s",
"tooltip.logged_user": "当前登录 %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.unread": "未读",
"menu.starred": "收藏",
"menu.history": "历史",
@ -32,6 +35,7 @@
"menu.about": "关于",
"menu.export": "导出",
"menu.import": "导入",
"menu.search": "搜索",
"menu.create_category": "新建分类",
"menu.mark_page_as_read": "标记为已读",
"menu.mark_all_as_read": "全部标为已读",
@ -50,6 +54,7 @@
"menu.shared_entries": "已分享的文章",
"search.label": "搜索",
"search.placeholder": "搜索…",
"search.submit": "Search",
"pagination.next": "下一页",
"pagination.previous": "上一页",
"entry.status.unread": "标为未读",
@ -79,13 +84,24 @@
"entry.shared_entry.title": "打开公共链接",
"entry.shared_entry.label": "分享",
"entry.estimated_reading_time": [
"需要 %d 分钟阅读",
"需要 %d 分钟阅读"
],
"entry.tags.label": "标签:",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"%d shared entry"
],
"page.unread.title": "未读",
"page.unread_entry_count": [
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d starred entry"
],
"page.categories.title": "分类",
"page.categories.no_feed": "没有源",
"page.categories.entries": "查看内容",
@ -93,20 +109,25 @@
"page.categories.feed_count": [
"有 %d 个源"
],
"page.categories.unread_counter": "未读文章数",
"page.categories_count": [
"%d category"
],
"page.new_category.title": "新分类",
"page.new_user.title": "新用户",
"page.edit_category.title": "编辑分类 : %s",
"page.edit_user.title": "编辑用户 : %s",
"page.feeds.title": "源",
"page.category_label": "Category: %s",
"page.feeds.last_check": "最后检查时间:",
"page.feeds.next_check": "下次检查时间:",
"page.feeds.unread_counter": "未读文章数",
"page.feeds.read_counter": "已读文章数",
"page.feeds.error_count": [
"%d 错误"
],
"page.history.title": "历史",
"page.read_entry_count": [
"%d read entry"
],
"page.import.title": "导入",
"page.search.title": "搜索结果",
"page.about.title": "关于",
@ -148,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
"page.keyboard_shortcuts.go_to_next_page": "下一页",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.open_item": "打开选定的文章",
"page.keyboard_shortcuts.open_original": "打开原始链接",
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
@ -186,7 +209,6 @@
"page.settings.webauthn.register": "注册 Passkey",
"page.settings.webauthn.register.error": "无法注册 Passkey",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey",
"删除 %d 个 Passkey"
],
"page.login.title": "登录",
@ -226,6 +248,7 @@
"alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章",
"alert.no_tag_entry": "没有与此标签匹配的条目。",
"alert.no_feed_entry": "该源中没有文章",
"alert.no_feed": "目前没有源",
"alert.no_history": "目前没有历史",
@ -284,6 +307,7 @@
"form.feed.label.title": "标题",
"form.feed.label.site_url": "源网站 URL",
"form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "类别",
"form.feed.label.crawler": "抓取全文内容",
"form.feed.label.feed_username": "源用户名",
@ -298,6 +322,7 @@
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "通过代理获取",
"form.feed.label.disabled": "请勿刷新此源",
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
@ -385,9 +410,9 @@
"form.integration.omnivore_activate": "保存文章到 Omnivore",
"form.integration.omnivore_url": "Omnivore API 端点",
"form.integration.omnivore_api_key": "Omnivore API 密钥",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_tags": "Espial 标签",
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
@ -399,16 +424,34 @@
"form.integration.telegram_bot_disable_notification": "禁用通知",
"form.integration.telegram_bot_disable_buttons": "不展示按钮",
"form.integration.telegram_chat_id": "聊天ID",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "保存文章到 Linkding",
"form.integration.linkding_endpoint": "Linkding API 端点",
"form.integration.linkding_api_key": "Linkding API 密钥",
"form.integration.linkding_tags": "Linkding 默认标签",
"form.integration.linkding_bookmark": "标记为未读",
"form.integration.linkwarden_activate": "保存文章到 Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden API 端点",
"form.integration.linkwarden_api_key": "Linkwarden API 密钥",
"form.integration.matrix_bot_activate": "将新文章推送到 Matrix",
"form.integration.matrix_bot_user": "Matrix Bot 用户名",
"form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "保存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_api_key": "Readeck API 密钥",
"form.integration.readeck_labels": "Readeck 默认标签",
"form.integration.readeck_only_url": "仅发送 URL而不是完整内容",
"form.integration.shiori_activate": "保存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端点",
"form.integration.shiori_username": "Shiori 用户名",
@ -445,13 +488,15 @@
"time_elapsed.years": [
"%d 年前"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -470,5 +515,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出范围",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,4 +1,5 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "您確認嗎?",
"confirm.question.refresh": "您想要強制刷新嗎?",
"confirm.yes": "是",
@ -18,6 +19,8 @@
"action.home_screen": "新增到主螢幕",
"tooltip.keyboard_shortcuts": "快捷鍵: %s",
"tooltip.logged_user": "當前登入 %s",
"menu.title": "導覽",
"menu.home_page": "主頁",
"menu.unread": "未讀",
"menu.starred": "收藏",
"menu.history": "歷史",
@ -32,6 +35,7 @@
"menu.about": "關於",
"menu.export": "匯出",
"menu.import": "匯入",
"menu.search": "搜尋",
"menu.create_category": "新建分類",
"menu.mark_page_as_read": "將此頁面標記為已讀",
"menu.mark_all_as_read": "全部標為已讀",
@ -50,6 +54,7 @@
"menu.shared_entries": "已分享的文章",
"search.label": "搜尋",
"search.placeholder": "搜尋…",
"search.submit": "送出",
"pagination.next": "下一頁",
"pagination.previous": "上一頁",
"entry.status.unread": "標為未讀",
@ -79,36 +84,50 @@
"entry.shared_entry.title": "開啟公共連結",
"entry.shared_entry.label": "分享",
"entry.estimated_reading_time": [
"需要 %d 分鐘閱讀",
"需要 %d 分鐘閱讀"
],
"entry.tags.label": "標籤:",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"%d shared entry"
],
"page.unread.title": "未讀",
"page.unread_entry_count": [
"%d unread entry"
],
"page.total_entry_count": [
"%d entry in total"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d starred entry"
],
"page.categories.title": "分類",
"page.categories.no_feed": "沒有Feed",
"page.categories.entries": "檢視內容",
"page.categories.feeds": "檢視Feeds",
"page.categories.feed_count": [
"有 %d 個Feed",
"有 %d 個Feeds"
"有 %d 個Feed"
],
"page.categories_count": [
"%d category"
],
"page.categories.unread_counter": "未讀文章數",
"page.new_category.title": "新分類",
"page.new_user.title": "新使用者",
"page.edit_category.title": "編輯分類 : %s",
"page.edit_user.title": "編輯使用者 : %s",
"page.feeds.title": "Feeds",
"page.category_label": "Category: %s",
"page.feeds.last_check": "最後檢查時間:",
"page.feeds.next_check": "下次檢查時間:",
"page.feeds.unread_counter": "未讀文章數",
"page.feeds.read_counter": "已讀文章數",
"page.feeds.error_count": [
"%d 錯誤",
"%d 錯誤"
],
"page.history.title": "歷史",
"page.read_entry_count": [
"%d read entry"
],
"page.import.title": "匯入",
"page.search.title": "搜尋結果",
"page.about.title": "關於",
@ -150,6 +169,8 @@
"page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面",
"page.keyboard_shortcuts.go_to_previous_page": "上一頁",
"page.keyboard_shortcuts.go_to_next_page": "下一頁",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.open_item": "開啟選定的文章",
"page.keyboard_shortcuts.open_original": "開啟原始連結",
"page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結",
@ -188,7 +209,6 @@
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.settings.webauthn.delete": [
"刪除 %d 個 Passkey",
"刪除 %d 個 Passkey"
],
"page.login.title": "登入",
@ -228,6 +248,7 @@
"alert.no_bookmark": "目前沒有收藏",
"alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章",
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
"alert.no_feed_entry": "該Feed中沒有文章",
"alert.no_feed": "目前沒有Feed",
"alert.no_history": "目前沒有歷史",
@ -282,10 +303,11 @@
"error.invalid_entry_direction": "無效的輸入方向。",
"error.invalid_display_mode": "無效的網頁應用顯示模式。",
"error.invalid_gesture_nav": "手勢導航無效.",
"error.invalid_default_home_page": "默認主頁無效!",
"error.invalid_default_home_page": "預設主頁無效!",
"form.feed.label.title": "標題",
"form.feed.label.site_url": "網站 URL",
"form.feed.label.feed_url": "訂閱 Feed URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "類別",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.feed_username": "Feed 使用者名稱",
@ -300,6 +322,7 @@
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "透過代理獲取",
"form.feed.label.disabled": "請勿更新此 Feed",
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
@ -341,7 +364,7 @@
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.custom_css": "自定義 CSS",
"form.prefs.label.entry_order": "文章排序依據",
"form.prefs.label.default_home_page": "默認主頁",
"form.prefs.label.default_home_page": "預設主頁",
"form.prefs.label.categories_sorting_order": "分類排序",
"form.prefs.label.mark_read_on_view": "查看時自動將條目標記為已讀",
"form.prefs.fieldset.application_settings": "應用程式設定",
@ -401,16 +424,34 @@
"form.integration.telegram_bot_disable_web_page_preview": "停用網頁預覽",
"form.integration.telegram_bot_disable_notification": "停用通知",
"form.integration.telegram_bot_disable_buttons": "不展示按鈕",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "儲存文章到 Linkding",
"form.integration.linkding_endpoint": "Linkding API 端點",
"form.integration.linkding_api_key": "Linkding API 金鑰",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "標記為未讀",
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden API 端點",
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
"form.integration.matrix_bot_activate": "推送文章到 Matrix",
"form.integration.matrix_bot_user": "Matrix 的用戶名",
"form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "儲存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_api_key": "Readeck API 金鑰",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "仅发送 URL而不是完整内容",
"form.integration.shiori_activate": "儲存文章到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端點",
"form.integration.shiori_username": "Shiori 使用者名稱",
@ -430,36 +471,32 @@
"time_elapsed.yesterday": "昨天",
"time_elapsed.now": "剛剛",
"time_elapsed.minutes": [
"%d 分鐘前",
"%d 分鐘前"
],
"time_elapsed.hours": [
"%d 小時前",
"%d 小時前"
],
"time_elapsed.days": [
"%d 天前",
"%d 天前"
],
"time_elapsed.weeks": [
"%d 周前",
"%d 周前"
],
"time_elapsed.months": [
"%d 月前",
"%d 月前"
],
"time_elapsed.years": [
"%d 年前",
"%d 年前"
],
"alert.too_many_feeds_refresh": "You have triggered too many feed refreshes. Please wait 30 minutes before trying again.",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
@ -478,5 +515,16 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v."
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出範圍",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"net/http"
@ -29,11 +29,11 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -53,11 +53,11 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
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 := ProxyRewriter(r, input)
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 "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -76,11 +76,11 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := input
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -99,11 +99,11 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := input
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -124,11 +124,11 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -149,11 +149,87 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("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 := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<p><img src="http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsScheme(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("HTTPS", "1")
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 := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<p><img src="https://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "audio")
os.Setenv("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 := `<audio src="https://website/folder/audio.mp3"></audio>`
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<audio src="http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM="></audio>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -174,11 +250,61 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_URL", "http://:8080example.com")
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)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
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 := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -198,11 +324,11 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
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 := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -222,11 +348,11 @@ func TestProxyFilterWithHttpsInvalid(t *testing.T) {
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 := ProxyRewriter(r, input)
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 "%s" instead of "%s"`, output, expected)
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
@ -248,7 +374,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
input := `<p><img src="http://website/folder/image.png" srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w" alt="test"></p>`
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w" alt="test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -273,7 +399,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>`
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -298,7 +424,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -323,7 +449,7 @@ func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -347,7 +473,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
input := `<img src="data:image/gif;base64,test">`
expected := `<img src="data:image/gif;base64,test"/>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
@ -371,7 +497,57 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
input := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
expected := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
output := ProxyRewriter(r, input)
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterWithVideo(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "video")
os.Setenv("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 := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestProxyFilterVideoPoster(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("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 := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
output := RewriteDocumentWithRelativeProxyURL(r, input)
if expected != output {
t.Errorf(`Not expected output: got %s`, output)

View File

@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"strings"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
"github.com/PuerkitoBio/goquery"
"github.com/gorilla/mux"
)
type urlProxyRewriter func(router *mux.Router, url string) string
func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string) string {
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument)
}
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, host, htmlDocument string) string {
proxifyFunction := func(router *mux.Router, url string) string {
return ProxifyAbsoluteURL(router, host, url)
}
return genericProxyRewriter(router, proxifyFunction, htmlDocument)
}
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption == "none" {
return htmlDocument
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument))
if err != nil {
return htmlDocument
}
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
switch mediaType {
case "image":
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok {
if shouldProxy(srcAttrValue, proxyOption) {
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, router, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxy(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
})
case "audio":
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
if srcAttrValue, ok := audio.Attr("src"); ok {
if shouldProxy(srcAttrValue, proxyOption) {
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
case "video":
doc.Find("video, video source").Each(func(i int, video *goquery.Selection) {
if srcAttrValue, ok := video.Attr("src"); ok {
if shouldProxy(srcAttrValue, proxyOption) {
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxy(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
})
}
}
output, err := doc.Find("body").First().Html()
if err != nil {
return htmlDocument
}
return output
}
func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFunction urlProxyRewriter, proxyOption, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if shouldProxy(imageCandidate.ImageURL, proxyOption) {
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
}
}
element.SetAttr("srcset", imageCandidates.String())
}
func shouldProxy(attrValue, proxyOption string) bool {
return !strings.HasPrefix(attrValue, "data:") &&
(proxyOption == "all" || !urllib.IsHTTPS(attrValue))
}

View File

@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/url"
"path"
"miniflux.app/v2/internal/http/route"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
mac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())
mac.Write([]byte(mediaURL))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
}
func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
scheme := "http"
if config.Opts.HTTPS {
scheme = "https"
}
return scheme + "://" + host + proxifiedUrl
}
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
if customProxyURL == "" {
return mediaURL
}
proxyUrl, err := url.Parse(customProxyURL)
if err != nil {
slog.Error("Incorrect custom media proxy URL",
slog.String("custom_proxy_url", customProxyURL),
slog.Any("error", err),
)
return mediaURL
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
return proxyUrl.String()
}

View File

@ -67,5 +67,5 @@ type Session struct {
}
func (s *Session) String() string {
return fmt.Sprintf(`ID="%s", Data={%v}`, s.ID, s.Data)
return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
}

View File

@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import "strings"
// Enclosure represents an attachment.
type Enclosure struct {
@ -17,15 +16,8 @@ type Enclosure struct {
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e Enclosure) Html5MimeType() string {
if strings.HasPrefix(e.MimeType, "video") {
switch e.MimeType {
// Solution from this stackoverflow discussion:
// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
// https://www.florenceporcel.com/podcast/lfhdu.xml
case "video/m4v":
return "video/x-m4v"
}
if e.MimeType == "video/m4v" {
return "video/x-m4v"
}
return e.MimeType
}

View File

@ -28,6 +28,7 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
@ -51,6 +52,7 @@ type Feed struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
AppriseServiceURLs string `json:"apprise_service_urls"`
DisableHTTP2 bool `json:"disable_http2"`
// Non persisted attributes
Category *Category `json:"category,omitempty"`
@ -150,6 +152,7 @@ type FeedCreationRequest struct {
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
}
type FeedCreationRequestFromSubscriptionDiscovery struct {
@ -157,24 +160,7 @@ type FeedCreationRequestFromSubscriptionDiscovery struct {
ETag string
LastModified string
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
FeedCreationRequest
}
// FeedModificationRequest represents the request to update a feed.
@ -182,6 +168,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
@ -199,6 +186,7 @@ type FeedModificationRequest struct {
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
HideGlobally *bool `json:"hide_globally"`
DisableHTTP2 *bool `json:"disable_http2"`
}
// Patch updates a feed with modified values.
@ -215,6 +203,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Title = *f.Title
}
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}
@ -282,6 +274,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
if f.HideGlobally != nil {
feed.HideGlobally = *f.HideGlobally
}
if f.DisableHTTP2 != nil {
feed.DisableHTTP2 = *f.DisableHTTP2
}
}
// Feeds is a list of feed

View File

@ -48,11 +48,20 @@ type Integration struct {
TelegramBotDisableWebPagePreview bool
TelegramBotDisableNotification bool
TelegramBotDisableButtons bool
LinkAceEnabled bool
LinkAceURL string
LinkAceAPIKey string
LinkAceTags string
LinkAcePrivate bool
LinkAceCheckDisabled bool
LinkdingEnabled bool
LinkdingURL string
LinkdingAPIKey string
LinkdingTags string
LinkdingMarkAsUnread bool
LinkwardenEnabled bool
LinkwardenURL string
LinkwardenAPIKey string
MatrixBotEnabled bool
MatrixBotUser string
MatrixBotPassword string
@ -61,6 +70,11 @@ type Integration struct {
AppriseEnabled bool
AppriseURL string
AppriseServicesURL string
ReadeckEnabled bool
ReadeckURL string
ReadeckAPIKey string
ReadeckLabels string
ReadeckOnlyURL bool
ShioriEnabled bool
ShioriURL string
ShioriUsername string
@ -76,4 +90,8 @@ type Integration struct {
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
}

View File

@ -3,26 +3,20 @@
package model // import "miniflux.app/v2/internal/model"
// OptionalString populates an optional string field.
type Number interface {
int | int64 | float64
}
func OptionalNumber[T Number](value T) *T {
if value > 0 {
return &value
}
return nil
}
func OptionalString(value string) *string {
if value != "" {
return &value
}
return nil
}
// OptionalInt populates an optional int field.
func OptionalInt(value int) *int {
if value > 0 {
return &value
}
return nil
}
// OptionalInt64 populates an optional int64 field.
func OptionalInt64(value int64) *int64 {
if value > 0 {
return &value
}
return nil
}

View File

@ -12,4 +12,5 @@ type SubscriptionDiscoveryRequest struct {
Password string `json:"password"`
FetchViaProxy bool `json:"fetch_via_proxy"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
DisableHTTP2 bool `json:"disable_http2"`
}

View File

@ -35,6 +35,7 @@ type User struct {
DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"`
MediaPlaybackRate float64 `json:"media_playback_rate"`
}
// UserCreationRequest represents the request to create a user.
@ -48,28 +49,29 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user.
type UserModificationRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
Username *string `json:"username"`
Password *string `json:"password"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"`
MediaPlaybackRate *float64 `json:"media_playback_rate"`
}
// Patch updates the User object with the modification request.
@ -161,6 +163,10 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.MarkReadOnView != nil {
user.MarkReadOnView = *u.MarkReadOnView
}
if u.MediaPlaybackRate != nil {
user.MediaPlaybackRate = *u.MediaPlaybackRate
}
}
// UseTimezone converts last login date to the given timezone.

View File

@ -21,7 +21,7 @@ type UserSession struct {
}
func (u *UserSession) String() string {
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, u.ID, u.UserID, u.IP, u.Token)
return fmt.Sprintf(`ID=%q, UserID=%q, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.

View File

@ -1,122 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
import (
"strings"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
"github.com/PuerkitoBio/goquery"
"github.com/gorilla/mux"
)
type urlProxyRewriter func(router *mux.Router, url string) string
// ProxyRewriter replaces media URLs with internal proxy URLs.
func ProxyRewriter(router *mux.Router, data string) string {
return genericProxyRewriter(router, ProxifyURL, data)
}
// AbsoluteProxyRewriter do the same as ProxyRewriter except it uses absolute URLs.
func AbsoluteProxyRewriter(router *mux.Router, host, data string) string {
proxifyFunction := func(router *mux.Router, url string) string {
return AbsoluteProxifyURL(router, host, url)
}
return genericProxyRewriter(router, proxifyFunction, data)
}
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
proxyOption := config.Opts.ProxyOption()
if proxyOption == "none" {
return data
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
if err != nil {
return data
}
for _, mediaType := range config.Opts.ProxyMediaTypes() {
switch mediaType {
case "image":
doc.Find("img").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, router, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
proxifySourceSet(sourceElement, router, proxifyFunction, proxyOption, srcsetAttrValue)
}
})
case "audio":
doc.Find("audio").Each(func(i int, audio *goquery.Selection) {
if srcAttrValue, ok := audio.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
doc.Find("audio source").Each(func(i int, sourceElement *goquery.Selection) {
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
case "video":
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if srcAttrValue, ok := video.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
doc.Find("video source").Each(func(i int, sourceElement *goquery.Selection) {
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
})
}
}
output, err := doc.Find("body").First().Html()
if err != nil {
return data
}
return output
}
func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFunction urlProxyRewriter, proxyOption, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if !isDataURL(imageCandidate.ImageURL) && (proxyOption == "all" || !urllib.IsHTTPS(imageCandidate.ImageURL)) {
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
}
}
element.SetAttr("srcset", imageCandidates.String())
}
func isDataURL(s string) bool {
return strings.HasPrefix(s, "data:")
}

View File

@ -1,69 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/url"
"path"
"miniflux.app/v2/internal/http/route"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
// ProxifyURL generates a relative URL for a proxified resource.
func ProxifyURL(router *mux.Router, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}
// AbsoluteProxifyURL generates an absolute URL for a proxified resource.
func AbsoluteProxifyURL(router *mux.Router, host, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
path := route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
if config.Opts.HTTPS {
return "https://" + host + path
} else {
return "http://" + host + path
}
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}

View File

@ -6,158 +6,114 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/base64"
"html"
"log/slog"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
type atom03Feed struct {
ID string `xml:"id"`
Title atom03Text `xml:"title"`
Author atomPerson `xml:"author"`
Links atomLinks `xml:"link"`
Entries []atom03Entry `xml:"entry"`
type Atom03Feed struct {
Version string `xml:"version,attr"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
ID string `xml:"http://purl.org/atom/ns# id"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
// atom:feed elements MUST contain exactly one atom:title element.
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"http://purl.org/atom/ns# title"`
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"http://purl.org/atom/ns# link"`
// The "atom:author" element is a Person construct that indicates the default author of the feed.
// atom:feed elements MUST contain exactly one atom:author element,
// UNLESS all of the atom:feed element's child atom:entry elements contain an atom:author element.
// atom:feed elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"http://purl.org/atom/ns# author"`
// The "atom:entry" element's represents an individual entry that is contained by the feed.
// atom:feed elements MAY contain one or more atom:entry elements.
Entries []Atom03Entry `xml:"http://purl.org/atom/ns# entry"`
}
func (a *atom03Feed) Transform(baseURL string) *model.Feed {
var err error
type Atom03Entry struct {
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry.
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
ID string `xml:"id"`
feed := new(model.Feed)
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
// atom:entry elements MUST have exactly one "atom:title" element.
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"title"`
feedURL := a.Links.firstLinkWithRelation("self")
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
if err != nil {
feed.FeedURL = feedURL
}
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
// The content of an atom:modified element MUST have a time zone whose value SHOULD be "UTC".
Modified string `xml:"modified"`
siteURL := a.Links.originalLink()
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
if err != nil {
feed.SiteURL = siteURL
}
// The "atom:issued" element is a Date construct that indicates the time that the entry was issued.
// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one.
// The content of an atom:issued element MAY omit a time zone.
Issued string `xml:"issued"`
feed.Title = a.Title.String()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// The "atom:created" element is a Date construct that indicates the time that the entry was created.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
// The content of an atom:created element MUST have a time zone whose value SHOULD be "UTC".
// If atom:created is not present, its content MUST considered to be the same as that of atom:modified.
Created string `xml:"created"`
for _, entry := range a.Entries {
item := entry.Transform()
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
if err == nil {
item.URL = entryURL
}
// The "atom:link" element is a Link construct that conveys a URI associated with the entry.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"link"`
if item.Author == "" {
item.Author = a.Author.String()
}
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
Summary Atom03Content `xml:"summary"`
if item.Title == "" {
item.Title = sanitizer.TruncateHTML(item.Content, 100)
}
// The "atom:content" element is a Content construct that conveys the content of the entry.
// atom:entry elements MAY contain one or more atom:content elements.
Content Atom03Content `xml:"content"`
if item.Title == "" {
item.Title = item.URL
}
feed.Entries = append(feed.Entries, item)
}
return feed
// The "atom:author" element is a Person construct that indicates the default author of the entry.
// atom:entry elements MUST contain exactly one atom:author element,
// UNLESS the atom:feed element containing them contains an atom:author element itself.
// atom:entry elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"author"`
}
type atom03Entry struct {
ID string `xml:"id"`
Title atom03Text `xml:"title"`
Modified string `xml:"modified"`
Issued string `xml:"issued"`
Created string `xml:"created"`
Links atomLinks `xml:"link"`
Summary atom03Text `xml:"summary"`
Content atom03Text `xml:"content"`
Author atomPerson `xml:"author"`
}
type Atom03Content struct {
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
// When present, this attribute's value MUST be a registered media type [RFC2045].
// If not present, its value MUST be considered to be "text/plain".
Type string `xml:"type,attr"`
func (a *atom03Entry) Transform() *model.Entry {
entry := model.NewEntry()
entry.URL = a.Links.originalLink()
entry.Date = a.entryDate()
entry.Author = a.Author.String()
entry.Hash = a.entryHash()
entry.Content = a.entryContent()
entry.Title = a.entryTitle()
return entry
}
// Content constructs MAY have a "mode" attribute, whose value indicates the method used to encode the content.
// When present, this attribute's value MUST be listed below.
// If not present, its value MUST be considered to be "xml".
//
// "xml": A mode attribute with the value "xml" indicates that the element's content is inline xml (for example, namespace-qualified XHTML).
//
// "escaped": A mode attribute with the value "escaped" indicates that the element's content is an escaped string.
// Processors MUST unescape the element's content before considering it as content of the indicated media type.
//
// "base64": A mode attribute with the value "base64" indicates that the element's content is base64-encoded [RFC2045].
// Processors MUST decode the element's content before considering it as content of the the indicated media type.
Mode string `xml:"mode,attr"`
func (a *atom03Entry) entryTitle() string {
return sanitizer.StripTags(a.Title.String())
}
func (a *atom03Entry) entryContent() string {
content := a.Content.String()
if content != "" {
return content
}
summary := a.Summary.String()
if summary != "" {
return summary
}
return ""
}
func (a *atom03Entry) entryDate() time.Time {
dateText := ""
for _, value := range []string{a.Issued, a.Modified, a.Created} {
if value != "" {
dateText = value
break
}
}
if dateText != "" {
result, err := date.Parse(dateText)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now()
}
return result
}
return time.Now()
}
func (a *atom03Entry) entryHash() string {
for _, value := range []string{a.ID, a.Links.originalLink()} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
type atom03Text struct {
Type string `xml:"type,attr"`
Mode string `xml:"mode,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
}
func (a *atom03Text) String() string {
func (a *Atom03Content) Content() string {
content := ""
switch {

View File

@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type Atom03Adapter struct {
atomFeed *Atom03Feed
}
func NewAtom03Adapter(atomFeed *Atom03Feed) *Atom03Adapter {
return &Atom03Adapter{atomFeed}
}
func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Content()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.Content()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Content()
}
// Populate the entry title.
entry.Title = atomEntry.Title.Content()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
if entry.Title == "" {
entry.Title = entry.URL
}
// Populate the entry author.
entry.Author = atomEntry.Author.PersonName()
if entry.Author == "" {
entry.Author = a.atomFeed.Author.PersonName()
}
// Populate the entry date.
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
if parsedDate, err := date.Parse(value); err == nil {
entry.Date = parsedDate
break
} else {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", value),
slog.String("id", atomEntry.ID),
slog.Any("error", err),
)
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}

View File

@ -27,7 +27,7 @@ func TestParseAtom03(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -36,7 +36,7 @@ func TestParseAtom03(t *testing.T) {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
if feed.FeedURL != "http://diveintomark.org/" {
if feed.FeedURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
}
@ -74,6 +74,28 @@ func TestParseAtom03(t *testing.T) {
}
}
func TestParseAtom03WithoutSiteURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom03WithoutFeedTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
@ -87,7 +109,7 @@ func TestParseAtom03WithoutFeedTitle(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -110,7 +132,7 @@ func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -138,7 +160,7 @@ func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -166,7 +188,7 @@ func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -197,7 +219,7 @@ func TestParseAtom03WithSummaryOnly(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -228,7 +250,7 @@ func TestParseAtom03WithXMLContent(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
@ -259,7 +281,7 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}

View File

@ -6,286 +6,200 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"encoding/xml"
"html"
"log/slog"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/media"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
// The "atom:feed" element is the document (i.e., top-level) element of
// an Atom Feed Document, acting as a container for metadata and data
// associated with the feed. Its element children consist of metadata
// elements followed by zero or more atom:entry child elements.
//
// Specs:
// https://tools.ietf.org/html/rfc4287
// https://validator.w3.org/feed/docs/atom.html
type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
ID string `xml:"id"`
Title atom10Text `xml:"title"`
Authors atomAuthors `xml:"author"`
Icon string `xml:"icon"`
Links atomLinks `xml:"link"`
Entries []atom10Entry `xml:"entry"`
type Atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:feed elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:feed elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:feed elements MUST contain one or more atom:author elements,
// unless all of the atom:feed element's child atom:entry elements
// contain at least one atom:author element.
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:icon" element's content is an IRI reference [RFC3987] that
// identifies an image that provides iconic visual identification for a
// feed.
//
// atom:feed elements MUST NOT contain more than one atom:icon element.
Icon string `xml:"http://www.w3.org/2005/Atom icon"`
// The "atom:logo" element's content is an IRI reference [RFC3987] that
// identifies an image that provides visual identification for a feed.
//
// atom:feed elements MUST NOT contain more than one atom:logo element.
Logo string `xml:"http://www.w3.org/2005/Atom logo"`
// atom:feed elements SHOULD contain one atom:link element with a rel
// attribute value of "self". This is the preferred URI for
// retrieving Atom Feed Documents representing this Atom feed.
//
// atom:feed elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:feed elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
Entries []Atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
}
func (a *atom10Feed) Transform(baseURL string) *model.Feed {
var err error
type Atom10Entry struct {
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:entry elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
feed := new(model.Feed)
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:entry elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
feedURL := a.Links.firstLinkWithRelation("self")
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
if err != nil {
feed.FeedURL = feedURL
}
// The "atom:published" element is a Date construct indicating an
// instant in time associated with an event early in the life cycle of
// the entry.
Published string `xml:"http://www.w3.org/2005/Atom published"`
siteURL := a.Links.originalLink()
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
if err != nil {
feed.SiteURL = siteURL
}
// The "atom:updated" element is a Date construct indicating the most
// recent instant in time when an entry or feed was modified in a way
// the publisher considers significant. Therefore, not all
// modifications necessarily result in a changed atom:updated value.
//
// atom:entry elements MUST contain exactly one atom:updated element.
Updated string `xml:"http://www.w3.org/2005/Atom updated"`
feed.Title = html.UnescapeString(a.Title.String())
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// atom:entry elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
feed.IconURL = strings.TrimSpace(a.Icon)
// atom:entry elements MUST contain an atom:summary element in either
// of the following cases:
// * the atom:entry contains an atom:content that has a "src"
// attribute (and is thus empty).
// * the atom:entry contains content that is encoded in Base64;
// i.e., the "type" attribute of atom:content is a MIME media type
// [MIMEREG], but is not an XML media type [RFC3023], does not
// begin with "text/", and does not end with "/xml" or "+xml".
//
// atom:entry elements MUST NOT contain more than one atom:summary
// element.
Summary Atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
for _, entry := range a.Entries {
item := entry.Transform()
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
if err == nil {
item.URL = entryURL
}
// atom:entry elements MUST NOT contain more than one atom:content
// element.
Content Atom10Text `xml:"http://www.w3.org/2005/Atom content"`
if item.Author == "" {
item.Author = a.Authors.String()
}
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:entry elements MUST contain one or more atom:author elements
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
if item.Title == "" {
item.Title = sanitizer.TruncateHTML(item.Content, 100)
}
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:entry elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
if item.Title == "" {
item.Title = item.URL
}
feed.Entries = append(feed.Entries, item)
}
return feed
}
type atom10Entry struct {
ID string `xml:"id"`
Title atom10Text `xml:"title"`
Published string `xml:"published"`
Updated string `xml:"updated"`
Links atomLinks `xml:"link"`
Summary atom10Text `xml:"summary"`
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
Authors atomAuthors `xml:"author"`
Categories []atom10Category `xml:"category"`
media.Element
}
func (a *atom10Entry) Transform() *model.Entry {
entry := model.NewEntry()
entry.URL = a.Links.originalLink()
entry.Date = a.entryDate()
entry.Author = a.Authors.String()
entry.Hash = a.entryHash()
entry.Content = a.entryContent()
entry.Title = a.entryTitle()
entry.Enclosures = a.entryEnclosures()
entry.CommentsURL = a.entryCommentsURL()
entry.Tags = a.entryCategories()
return entry
}
func (a *atom10Entry) entryTitle() string {
return html.UnescapeString(a.Title.String())
}
func (a *atom10Entry) entryContent() string {
content := a.Content.String()
if content != "" {
return content
}
summary := a.Summary.String()
if summary != "" {
return summary
}
mediaDescription := a.FirstMediaDescription()
if mediaDescription != "" {
return mediaDescription
}
return ""
}
// Note: The published date represents the original creation date for YouTube feeds.
// Example:
// <published>2019-01-26T08:02:28+00:00</published>
// <updated>2019-01-29T07:27:27+00:00</updated>
func (a *atom10Entry) entryDate() time.Time {
dateText := a.Published
if dateText == "" {
dateText = a.Updated
}
if dateText != "" {
result, err := date.Parse(dateText)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now()
}
return result
}
return time.Now()
}
func (a *atom10Entry) entryHash() string {
for _, value := range []string{a.ID, a.Links.originalLink()} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
func (a *atom10Entry) entryEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
duplicates := make(map[string]bool)
for _, mediaThumbnail := range a.AllMediaThumbnails() {
if _, found := duplicates[mediaThumbnail.URL]; !found {
duplicates[mediaThumbnail.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaThumbnail.URL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
}
for _, link := range a.Links {
if strings.ToLower(link.Rel) == "enclosure" {
if link.URL == "" {
continue
}
if _, found := duplicates[link.URL]; !found {
duplicates[link.URL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
}
}
}
for _, mediaContent := range a.AllMediaContents() {
if _, found := duplicates[mediaContent.URL]; !found {
duplicates[mediaContent.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaContent.URL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
}
for _, mediaPeerLink := range a.AllMediaPeerLinks() {
if _, found := duplicates[mediaPeerLink.URL]; !found {
duplicates[mediaPeerLink.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaPeerLink.URL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
}
return enclosures
}
func (r *atom10Entry) entryCategories() []string {
categoryList := make([]string, 0)
for _, atomCategory := range r.Categories {
if strings.TrimSpace(atomCategory.Label) != "" {
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Label))
} else {
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Term))
}
}
return categoryList
}
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
func (a *atom10Entry) entryCommentsURL() string {
commentsURL := a.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
if urllib.IsAbsoluteURL(commentsURL) {
return commentsURL
}
return ""
}
type atom10Text struct {
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
type atom10Category struct {
Term string `xml:"term,attr"`
Label string `xml:"label,attr"`
media.MediaItemElement
}
// A Text construct contains human-readable text, usually in small
// quantities. The content of Text constructs is Language-Sensitive.
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
func (a *atom10Text) String() string {
type Atom10Text struct {
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement AtomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
func (a *Atom10Text) Body() string {
var content string
switch {
case a.Type == "", a.Type == "text", a.Type == "text/plain":
if strings.HasPrefix(strings.TrimSpace(a.InnerXML), `<![CDATA[`) {
content = html.EscapeString(a.CharData)
} else {
content = a.InnerXML
}
case a.Type == "xhtml":
var root = a.XHTMLRootElement
if root.XMLName.Local == "div" {
content = root.InnerXML
} else {
content = a.InnerXML
}
default:
if strings.EqualFold(a.Type, "xhtml") {
content = a.xhtmlContent()
} else {
content = a.CharData
}
return strings.TrimSpace(content)
}
type atomXHTMLRootElement struct {
func (a *Atom10Text) Title() string {
var content string
switch {
case strings.EqualFold(a.Type, "xhtml"):
content = a.xhtmlContent()
case strings.Contains(a.InnerXML, "<![CDATA["):
content = html.UnescapeString(a.CharData)
default:
content = a.CharData
}
content = sanitizer.StripTags(content)
return strings.TrimSpace(content)
}
func (a *Atom10Text) xhtmlContent() string {
if a.XHTMLRootElement.XMLName.Local == "div" {
return a.XHTMLRootElement.InnerXML
}
return a.InnerXML
}
type AtomXHTMLRootElement struct {
XMLName xml.Name `xml:"div"`
InnerXML string `xml:",innerxml"`
}

View File

@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"slices"
"sort"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type Atom10Adapter struct {
atomFeed *Atom10Feed
}
func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
return &Atom10Adapter{atomFeed}
}
func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Body()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// Populate the feed icon.
if a.atomFeed.Icon != "" {
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
feed.IconURL = absoluteIconURL
}
} else if a.atomFeed.Logo != "" {
if absoluteLogoURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
feed.IconURL = absoluteLogoURL
}
}
feed.Entries = a.populateEntries(feed.SiteURL)
return feed
}
func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
entries := make(model.Entries, 0, len(a.atomFeed.Entries))
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.Body()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Body()
if entry.Content == "" {
entry.Content = atomEntry.FirstMediaDescription()
}
}
// Populate the entry title.
entry.Title = atomEntry.Title.Title()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
if entry.Title == "" {
entry.Title = entry.URL
}
}
// Populate the entry author.
authors := atomEntry.Authors.PersonNames()
if len(authors) == 0 {
authors = a.atomFeed.Authors.PersonNames()
}
sort.Strings(authors)
authors = slices.Compact(authors)
entry.Author = strings.Join(authors, ", ")
// Populate the entry date.
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
if value != "" {
if parsedDate, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = parsedDate
break
}
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate categories.
categories := atomEntry.Categories.CategoryNames()
if len(categories) == 0 {
categories = a.atomFeed.Categories.CategoryNames()
}
sort.Strings(categories)
entry.Tags = slices.Compact(categories)
// Populate the commentsURL if defined.
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
commentsURL := atomEntry.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
if urllib.IsAbsoluteURL(commentsURL) {
entry.CommentsURL = commentsURL
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
// Populate the entry enclosures.
uniqueEnclosuresMap := make(map[string]bool)
for _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {
mediaURL := strings.TrimSpace(mediaThumbnail.URL)
if mediaURL == "" {
continue
}
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
}
}
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
absoluteEnclosureURL, err := urllib.AbsoluteURL(siteURL, link.Href)
if err != nil {
slog.Debug("Unable to resolve absolute URL for enclosure",
slog.String("enclosure_url", link.Href),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
}
}
for _, mediaContent := range atomEntry.AllMediaContents() {
mediaURL := strings.TrimSpace(mediaContent.URL)
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
}
}
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
}
}
entries = append(entries, entry)
}
return entries
}

File diff suppressed because it is too large Load Diff

View File

@ -3,77 +3,91 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import "strings"
import (
"strings"
)
type atomPerson struct {
Name string `xml:"name"`
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.2
type AtomPerson struct {
// The "atom:name" element's content conveys a human-readable name for the author.
// It MAY be the name of a corporation or other entity no individual authors can be named.
// Person constructs MUST contain exactly one "atom:name" element, whose content MUST be a string.
Name string `xml:"name"`
// The "atom:email" element's content conveys an e-mail address associated with the Person construct.
// Person constructs MAY contain an atom:email element, but MUST NOT contain more than one.
// Its content MUST be an e-mail address [RFC2822].
// Ordering of the element children of Person constructs MUST NOT be considered significant.
Email string `xml:"email"`
}
func (a *atomPerson) String() string {
name := ""
switch {
case a.Name != "":
name = a.Name
case a.Email != "":
name = a.Email
func (a *AtomPerson) PersonName() string {
name := strings.TrimSpace(a.Name)
if name != "" {
return name
}
return strings.TrimSpace(name)
return strings.TrimSpace(a.Email)
}
type atomAuthors []*atomPerson
type AtomPersons []*AtomPerson
func (a atomAuthors) String() string {
var authors []string
func (a AtomPersons) PersonNames() []string {
var names []string
authorNamesMap := make(map[string]bool)
for _, person := range a {
authors = append(authors, person.String())
personName := person.PersonName()
if _, ok := authorNamesMap[personName]; !ok {
names = append(names, personName)
authorNamesMap[personName] = true
}
}
return strings.Join(authors, ", ")
return names
}
type atomLink struct {
URL string `xml:"href,attr"`
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7
type AtomLink struct {
Href string `xml:"href,attr"`
Type string `xml:"type,attr"`
Rel string `xml:"rel,attr"`
Length string `xml:"length,attr"`
Title string `xml:"title,attr"`
}
type atomLinks []*atomLink
type AtomLinks []*AtomLink
func (a atomLinks) originalLink() string {
func (a AtomLinks) OriginalLink() string {
for _, link := range a {
if strings.ToLower(link.Rel) == "alternate" {
return strings.TrimSpace(link.URL)
if strings.EqualFold(link.Rel, "alternate") {
return strings.TrimSpace(link.Href)
}
if link.Rel == "" && (link.Type == "" || link.Type == "text/html") {
return strings.TrimSpace(link.URL)
return strings.TrimSpace(link.Href)
}
}
return ""
}
func (a atomLinks) firstLinkWithRelation(relation string) string {
func (a AtomLinks) firstLinkWithRelation(relation string) string {
for _, link := range a {
if strings.ToLower(link.Rel) == relation {
return strings.TrimSpace(link.URL)
if strings.EqualFold(link.Rel, relation) {
return strings.TrimSpace(link.Href)
}
}
return ""
}
func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
func (a AtomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
for _, link := range a {
if strings.ToLower(link.Rel) == relation {
if strings.EqualFold(link.Rel, relation) {
for _, contentType := range contentTypes {
if strings.ToLower(link.Type) == contentType {
return strings.TrimSpace(link.URL)
if strings.EqualFold(link.Type, contentType) {
return strings.TrimSpace(link.Href)
}
}
}
@ -81,3 +95,61 @@ func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
return ""
}
func (a AtomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
var links []*AtomLink
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
link.Href = strings.TrimSpace(link.Href)
if link.Href != "" {
links = append(links, link)
}
}
}
return links
}
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.2
type AtomCategory struct {
// The "term" attribute is a string that identifies the category to
// which the entry or feed belongs. Category elements MUST have a
// "term" attribute.
Term string `xml:"term,attr"`
// The "scheme" attribute is an IRI that identifies a categorization
// scheme. Category elements MAY have a "scheme" attribute.
Scheme string `xml:"scheme,attr"`
// The "label" attribute provides a human-readable label for display in
// end-user applications. The content of the "label" attribute is
// Language-Sensitive. Entities such as "&amp;" and "&lt;" represent
// their corresponding characters ("&" and "<", respectively), not
// markup. Category elements MAY have a "label" attribute.
Label string `xml:"label,attr"`
}
type AtomCategories []AtomCategory
func (ac AtomCategories) CategoryNames() []string {
var categories []string
for _, category := range ac {
label := strings.TrimSpace(category.Label)
if label != "" {
categories = append(categories, label)
} else {
term := strings.TrimSpace(category.Term)
if term != "" {
categories = append(categories, term)
}
}
}
return categories
}

View File

@ -4,8 +4,6 @@
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"bytes"
"encoding/xml"
"fmt"
"io"
@ -13,47 +11,20 @@ import (
xml_decoder "miniflux.app/v2/internal/reader/xml"
)
type atomFeed interface {
Transform(baseURL string) *model.Feed
}
// Parse returns a normalized feed struct from a Atom feed.
func Parse(baseURL string, r io.Reader) (*model.Feed, error) {
var buf bytes.Buffer
tee := io.TeeReader(r, &buf)
var rawFeed atomFeed
if getAtomFeedVersion(tee) == "0.3" {
rawFeed = new(atom03Feed)
} else {
rawFeed = new(atom10Feed)
}
if err := xml_decoder.NewXMLDecoder(&buf).Decode(rawFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse feed: %w", err)
}
return rawFeed.Transform(baseURL), nil
}
func getAtomFeedVersion(data io.Reader) string {
decoder := xml_decoder.NewXMLDecoder(data)
for {
token, _ := decoder.Token()
if token == nil {
break
func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error) {
switch version {
case "0.3":
atomFeed := new(Atom03Feed)
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse Atom 0.3 feed: %w", err)
}
if element, ok := token.(xml.StartElement); ok {
if element.Name.Local == "feed" {
for _, attr := range element.Attr {
if attr.Name.Local == "version" && attr.Value == "0.3" {
return "0.3"
}
}
return "1.0"
}
return NewAtom03Adapter(atomFeed).BuildFeed(baseURL), nil
default:
atomFeed := new(Atom10Feed)
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err)
}
return NewAtom10Adapter(atomFeed).BuildFeed(baseURL), nil
}
return "1.0"
}

View File

@ -1,61 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"bytes"
"testing"
)
func TestDetectAtom10(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<author>
<name>John Doe</name>
</author>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>Atom-Powered Robots Run Amok</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewBufferString(data))
if version != "1.0" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
}
func TestDetectAtom03(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<title>dive into mark</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
<issued>2003-12-13T08:29:29-04:00</issued>
<modified>2003-12-13T18:30:02Z</modified>
<summary type="text/plain">This is a test</summary>
<content type="text/html" mode="escaped"><![CDATA[<p>HTML content</p>]]></content>
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewBufferString(data))
if version != "0.3" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
}

View File

@ -6,22 +6,25 @@ package date // import "miniflux.app/v2/internal/reader/date"
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// DateFormats taken from github.com/mjibson/goread
// RFC822, RFC850, and RFC1123 formats should be applied only to local times.
var dateFormatsLocalTimesOnly = []string{
time.RFC822, // RSS
time.RFC850,
time.RFC1123,
}
// dateFormats taken from github.com/mjibson/goread
var dateFormats = []string{
time.RFC822, // RSS
time.RFC822Z, // RSS
time.RFC3339, // Atom
time.UnixDate,
time.RubyDate,
time.RFC850,
time.RFC1123Z,
time.RFC1123,
time.ANSIC,
"Mon, 02 Jan 2006 15:04:05 MST -07:00",
"Mon, January 2, 2006, 3:04 PM MST",
@ -314,34 +317,30 @@ var invalidLocalizedDateReplacer = strings.NewReplacer(
// list of commonly found feed date formats.
func Parse(rawInput string) (t time.Time, err error) {
rawInput = strings.TrimSpace(rawInput)
timestamp, err := strconv.ParseInt(rawInput, 10, 64)
if err == nil {
if rawInput == "" {
return t, errors.New(`date parser: empty value`)
}
if timestamp, err := strconv.ParseInt(rawInput, 10, 64); err == nil {
return time.Unix(timestamp, 0), nil
}
processedInput := invalidLocalizedDateReplacer.Replace(rawInput)
processedInput = invalidTimezoneReplacer.Replace(processedInput)
if processedInput == "" {
return t, errors.New(`date parser: empty value`)
for _, layout := range dateFormatsLocalTimesOnly {
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
return checkTimezoneRange(t), nil
}
}
for _, layout := range dateFormats {
switch layout {
case time.RFC822, time.RFC850, time.RFC1123:
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
t = checkTimezoneRange(t)
return
}
}
if t, err = time.Parse(layout, processedInput); err == nil {
t = checkTimezoneRange(t)
return
return checkTimezoneRange(t), nil
}
}
err = fmt.Errorf(`date parser: failed to parse date "%s"`, rawInput)
return
return t, fmt.Errorf(`date parser: failed to parse date "%s"`, rawInput)
}
// According to Golang documentation:
@ -369,7 +368,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
// Avoid "pq: time zone displacement out of range" errors
func checkTimezoneRange(t time.Time) time.Time {
_, offset := t.Zone()
if math.Abs(float64(offset)) > 14*60*60 {
if float64(offset) > 14*60*60 || float64(offset) < -12*60*60 {
t = t.UTC()
}
return t

View File

@ -7,6 +7,14 @@ import (
"testing"
)
func FuzzParse(f *testing.F) {
f.Add("2017-12-22T22:09:49+00:00")
f.Add("Fri, 31 Mar 2023 20:19:00 America/Los_Angeles")
f.Fuzz(func(t *testing.T, date string) {
Parse(date)
})
}
func TestParseEmptyDate(t *testing.T) {
if _, err := Parse(" "); err == nil {
t.Fatalf(`Empty dates should return an error`)
@ -228,14 +236,19 @@ func TestParseWeirdDateFormat(t *testing.T) {
}
func TestParseDateWithTimezoneOutOfRange(t *testing.T) {
date, err := Parse("2023-05-29 00:00:00-23:00")
if err != nil {
t.Errorf(`Unable to parse date: %v`, err)
inputs := []string{
"2023-05-29 00:00:00-13:00",
"2023-05-29 00:00:00+15:00",
}
for _, input := range inputs {
date, err := Parse(input)
_, offset := date.Zone()
if offset != 0 {
t.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)
if err != nil {
t.Errorf(`Unable to parse date: %v`, err)
}
if _, offset := date.Zone(); offset != 0 {
t.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)
}
}
}

View File

@ -3,28 +3,13 @@
package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
import (
"strings"
"miniflux.app/v2/internal/reader/sanitizer"
)
// DublinCoreFeedElement represents Dublin Core feed XML elements.
type DublinCoreFeedElement struct {
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ channel>creator"`
type DublinCoreChannelElement struct {
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
}
func (feed *DublinCoreFeedElement) GetSanitizedCreator() string {
return strings.TrimSpace(sanitizer.StripTags(feed.DublinCoreCreator))
}
// DublinCoreItemElement represents Dublin Core entry XML elements.
type DublinCoreItemElement struct {
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
}
func (item *DublinCoreItemElement) GetSanitizedCreator() string {
return strings.TrimSpace(sanitizer.StripTags(item.DublinCoreCreator))
}

View File

@ -35,8 +35,3 @@ func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
// Transform document to UTF-8 from the specified encoding in XML prolog.
return charset.NewReaderLabel(charsetLabel, r)
}
// CharsetReaderFromContentType is used when the encoding is not specified for the input document.
func CharsetReaderFromContentType(contentType string, input io.Reader) (io.Reader, error) {
return charset.NewReader(input, contentType)
}

View File

@ -0,0 +1,55 @@
package fetcher
import (
"compress/gzip"
"io"
"github.com/andybalholm/brotli"
)
type brotliReadCloser struct {
body io.ReadCloser
brotliReader io.Reader
}
func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {
return &brotliReadCloser{
body: body,
brotliReader: brotli.NewReader(body),
}
}
func (b *brotliReadCloser) Read(p []byte) (n int, err error) {
return b.brotliReader.Read(p)
}
func (b *brotliReadCloser) Close() error {
return b.body.Close()
}
type gzipReadCloser struct {
body io.ReadCloser
gzipReader io.Reader
gzipErr error
}
func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {
return &gzipReadCloser{body: body}
}
func (gz *gzipReadCloser) Read(p []byte) (n int, err error) {
if gz.gzipReader == nil {
if gz.gzipErr == nil {
gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)
}
if gz.gzipErr != nil {
return 0, gz.gzipErr
}
}
return gz.gzipReader.Read(p)
}
func (gz *gzipReadCloser) Close() error {
return gz.body.Close()
}

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