Compare commits

...

224 Commits

Author SHA1 Message Date
Frédéric Guillot 4d7d554df8 docs: update release notes for version 2.2.11 2025-07-26 13:13:53 -07:00
Frédéric Guillot 66b269e6cd feat(readability): avoid removing elements with content class 2025-07-25 19:59:47 -07:00
Frédéric Guillot 54abd0a736 fix(parser): handle feeds with leading whitespace that exceeds buffer size 2025-07-23 21:06:15 -07:00
Frédéric Guillot 5eab4753e8 refactor(googlereader): rename/unexport response types and functions 2025-07-23 20:36:04 -07:00
Frédéric Guillot bf466425db ci: update commit linter to allow default Git revert message 2025-07-23 20:03:04 -07:00
jvoisin a62b97bddd refactor(readability): get rid of getClassWeight
Its naming was confusing, and its code simple enough that it could be inlined.
2025-07-23 19:55:47 -07:00
jvoisin 1de9cf4241 perf(readability): simplify removeUnlikelyCandidates
- Use an iterator instead of generating a whole slice when iterating on the selection.
- Using an iterator allows to use a for-loop construct, instead of a lambda,
  which is a bit clearer
- Do the filtering Find()'s selector, instead of in the loop, which doesn't
  matter much now that we're using an iterator, but it makes the code a bit
  more obvious/simpler, and likely reduces a bit the number of iterations.
2025-07-23 19:55:47 -07:00
jvoisin 7912b9b8fb perf(readability): avoid materializing text to count commas
There is no need to materialize the whole text content of the selection only to
count its number of commas. As we already have a getLengthOfTextContent
function that is pretty similar, this commit refactors it to make it more
generic, in the form of a map/fold(+).
2025-07-23 19:55:47 -07:00
jvoisin 2d24f5d04e refactor(readability): minor code folding 2025-07-23 19:55:47 -07:00
Frédéric Guillot 20825a92c5 Revert "perf(template): use ParseFS to directly parse the embedded template data"
This reverts commit 4336a0bd85.
2025-07-22 21:31:34 -07:00
Frédéric Guillot 1d1162327e feat(integration): use Bearer token authorization instead of cookie for Linkwarden client 2025-07-22 21:13:48 -07:00
Frédéric Guillot 202de7c787 fix(integration): rename Linkwarden endpoint label to base URL (#3568) 2025-07-22 20:49:54 -07:00
Frédéric Guillot b470b186b3 feat(makefile)!: remove unsupported platforms and stop distributing Windows binary
There is no installation wizard for Windows and running the
command line binary as-is could lead to confusion for some users.

Unit tests still run on Windows, and people can still compile from
source if interested.
2025-07-22 20:24:39 -07:00
dependabot[bot] 5877cf340a build(deps): bump github.com/go-webauthn/webauthn from 0.13.3 to 0.13.4
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.3 to 0.13.4.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.3...v0.13.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 17:31:06 -07:00
dependabot[bot] d417b0ff12 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.9 to 2.23.10
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.9 to 2.23.10.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.9...v2.23.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 17:27:56 -07:00
Frédéric Guillot 703f113fbd fix(storage): ensure strings are truncated to fit tsvector size limit and remain valid UTF-8 2025-07-20 13:42:58 -07:00
Frédéric Guillot f02213a168 refactor(readability): use String explicitly in debug log instead of Any 2025-07-19 10:58:49 -07:00
Frédéric Guillot 410b43a787 chore(contrib): update PostgreSQL image from 15 to latest in docker-compose examples 2025-07-19 10:51:00 -07:00
Frédéric Guillot d9de9d1852 feat(rss): fallback to enclosure URL when entry URL is missing 2025-07-19 10:46:43 -07:00
dependabot[bot] 33d55cc4e9 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.8 to 2.23.9
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.8 to 2.23.9.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.8...v2.23.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-18 17:35:03 -07:00
Julien Voisin 1825320369 refactor(template): remove unused functions and reduce the complexity of truncate function
- Remove unused functions like hasKey, domain, hasPrefix and contains.
- Lower the complexity of truncate from O(n) to O(1).
2025-07-17 20:53:41 -07:00
jvoisin d80fb242db refactor(template): reduce translation-related introspection
Keys in translation maps are always strings, never anything else, so there is
no need to introspect them.
2025-07-17 20:48:36 -07:00
Julien Voisin 4336a0bd85 perf(template): use ParseFS to directly parse the embedded template data
Use ParseFS to directly parse the embedded template data, instead of manually
reading it and then using Parse.
2025-07-17 20:46:33 -07:00
Frédéric Guillot dc81725788 fix(filter): remove \r\n in rule parsing 2025-07-16 21:03:53 -07:00
Julien Voisin 86e2ce6d0b perf(readability): move transformMisusedDivsIntoParagraphs call after removeUnlikelyCandidates 2025-07-13 14:34:14 -07:00
Frédéric Guillot 4679691c94 refactor(googlereader): rename stream suffix constants for clarity 2025-07-13 14:28:25 -07:00
Julien Voisin 0d5f4a710f refactor(googlereader): unexport a lot of symbols 2025-07-13 14:16:08 -07:00
dependabot[bot] 92d2ac4f58 build(deps): bump github.com/go-webauthn/webauthn from 0.13.1 to 0.13.3
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.1 to 0.13.3.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.1...v0.13.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-11 17:09:37 -07:00
dependabot[bot] 1cfee27a50 build(deps): bump golang.org/x/image from 0.28.0 to 0.29.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/image/compare/v0.28.0...v0.29.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 19:32:49 -07:00
jvoisin 0e9da3a090 refactor(icon): simplify findIconURLsFromHTMLDocument
- Don't define the queries before possible early returns
- Check for the presence of the href attribute in the queries, instead of later
  on iterating on the selection
- Add two edge-cases to the tests
- Use EachIter instead of Each, if only to avoid the lambda
2025-07-10 19:32:29 -07:00
jvoisin 57bd384951 refactor(icon): unexport a bunch of symbols 2025-07-10 19:32:29 -07:00
dependabot[bot] fdbd5b08a1 build(deps): bump golang.org/x/net from 0.41.0 to 0.42.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.41.0 to 0.42.0.
- [Commits](https://github.com/golang/net/compare/v0.41.0...v0.42.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 19:24:08 -07:00
jvoisin f455c18c66 perf(rewrite): anchor the rewrite regex
There is no need to try to match the regexp over the whole input, having it
anchored is enough. If we feel extra-lenient, we might strip spaces in
front/tail, but I don't think it's necessary.

This commit also invert a condition to reduce the level of nested indentation,
and make a condition stricter.
2025-07-10 19:23:54 -07:00
dependabot[bot] 9dea26c923 build(deps): bump golang.org/x/crypto from 0.39.0 to 0.40.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/crypto/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-10 17:33:04 -07:00
jvoisin 46adb0ffad refactor(fetcher): simplification of ExecuteRequest
Instead of doing some ciphers manipulation before instantiating the http.Transport
and then assigning them, instantiate http.Transport, and then in an if do the
manipulation. This makes the code a bit clearer, which is always nice when it
comes to cryptographic shenanigans.
2025-07-09 19:36:36 -07:00
jvoisin 61583d53d5 refactor(config): simplify SortedOptions
Make use of the slices and maps packages instead of doing things by hand,
and pre-allocated sortedOptions.
2025-07-09 19:29:53 -07:00
jvoisin 7c42e777ec refactor(config): minor improvements of the config parser
- Surface the faulty line number when trying to parse it
- Use strings.Cut instead of strings.SplitN
- Use strings.TrimSuffix instead of an if
- Simplify parseStringList and make its code more compact
2025-07-09 19:28:02 -07:00
dependabot[bot] 335dffbb75 build(deps): bump golang.org/x/term from 0.32.0 to 0.33.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.32.0 to 0.33.0.
- [Commits](https://github.com/golang/term/compare/v0.32.0...v0.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-09 19:18:08 -07:00
Qeynos f0cdfb33dd feat(locale): update Chinese translations 2025-07-09 19:14:40 -07:00
jvoisin 13ef89f785 fix(storage): don't truncate title/content fields
As found out by @fguillot in https://github.com/miniflux/v2/pull/3461#issuecomment-3047204354
2025-07-08 19:36:18 -07:00
jvoisin 32fbb4e882 fix(ui): improve margins consistency wrt. header/footer 2025-07-08 19:30:13 -07:00
Julien Voisin 135ce1d546 feat(cookie): use SameSiteStrictMode when not using OAuth2/OIDC 2025-07-08 19:20:24 -07:00
Cthulhux abed7b11ce feat(locale): update German translation
Missed two weird translations and one typo
2025-07-08 19:17:28 -07:00
Frédéric Guillot 2e26f5ca75 test(reader): ensure consistent tags parsing across feed formats 2025-07-07 20:07:35 -07:00
jvoisin d6d18a2d61 perf(reader): shrink the json detection buffer
There is no need to allocate half a kilobyte of memory only check that a buffer
starts with a bunch of spaces and a `{`, 32b should be more than enough. Also,
no need to allocate it on the heap, having it on the stack works perfectly.
2025-07-07 19:21:59 -07:00
Frédéric Guillot 63891501e5 refactor(model): add test coverage and simplify ProxifyEnclosureURL 2025-07-07 18:41:44 -07:00
dependabot[bot] 7107ff985f build(deps): bump github.com/go-webauthn/webauthn from 0.13.0 to 0.13.1
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.13.0 to 0.13.1.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.13.0...v0.13.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 18:39:51 -07:00
jvoisin 50d5cb96c8 refactor(enclosure): simplify model/enclosure.go
- Reuse isAudio and isVideo instead of re-implementing them
- In IsImage, return early is the mimetype is enough, instead of systematically
  lowercasing the url as well.
- Extract the common parts of the two ProxifyAbsoluteURL implementations into a
  private function, make the code smaller and clearer.
- Fix a logic error where `A && B || C` was used instead of `A && (B || C)
2025-07-07 17:42:44 -07:00
Frédéric Guillot f860daef7f test(locale): increase test coverage to 100% 2025-07-07 17:39:56 -07:00
Frédéric Guillot e7b98afdbe refactor(subscription): avoid using Sprintf to construct Youtube playlist feed URL 2025-07-07 17:08:47 -07:00
Frédéric Guillot 2cfeefc8d2 test(processor): increase test coverage for parseISO8601Duration 2025-07-07 17:01:10 -07:00
jvoisin b48e6472f5 refactor(processor): parse ~ISO8601 in a proper way
Instead of using an ugly (and incomplete) regex, let's use a simple for-loop to
parse ISO8601 dates, and make it explicit that we're only supporting a subset
of the spec, as we only care about youtube video durations.
2025-07-07 16:28:58 -07:00
jvoisin 24043ece07 refactor(config): unexport some symbols 2025-07-07 16:01:21 -07:00
jvoisin a09129d220 refactor(proxyrotator): simplify mutex handling
There is no need to use a mutex to check the length of the proxies list,
as it's read-only during the whole lifetime of a ProxyRotator structure.
Moreover, it's a bit clearer to explicitly wrap the 2 lines of mutex-needing
operations between a Lock/Unlock instead of using a defer.
2025-07-07 15:52:16 -07:00
jvoisin f864a2ed70 refactor(mediaproxy): simplify shouldProxy
The original function was non-trivial to understand, as `!A && (B || !C)` isn't
easily grokable by humans.
2025-07-07 15:50:13 -07:00
jvoisin 052e8dd0aa refactor(config): remove two unused constants 2025-07-07 15:45:20 -07:00
jvoisin 7a394b0bf8 refactor(subscription): replace a regex with strings.CutPrefix 2025-07-07 15:44:45 -07:00
jvoisin dcfe0a7d94 refactor(locale): simplify pluralForm
Instead of having a switch-case returning a function to be executed, it's
simpler/faster to have a single function containing a switch-case. It also
allows to group languages with identical plural form in a single
implementation, and remove the "default" guard value, as switch-case already
have a `default:` case.
2025-07-07 15:30:41 -07:00
jvoisin 33c648825f refactor(locale): make Printf's code structure similar to Print's
And also change the order of the cases in the Plural function, to make it
explicit that []string shouldn't match []any.
2025-07-07 15:30:41 -07:00
jvoisin 78c7f66df7 refactor(locale): remove a call to fmt.Sprintf 2025-07-07 15:30:41 -07:00
jvoisin f2b805850c refactor(locale): remove an unused function 2025-07-07 15:30:41 -07:00
jvoisin 915b7b3cf7 refactor(locale): unexport a symbol 2025-07-07 15:30:41 -07:00
jvoisin 8e86004936 refactor(locale): use any instead of interface{} 2025-07-07 15:30:41 -07:00
Julien Voisin a8b4e88742 perf(sanitizer): improve the performances of the sanitizer (#3497)
- Grow the underlying buffer of SanitizeHTML's strings.Builder to 3/4 of the
  raw HTML from the start, to reduce the amount of iterative allocations. This
  number is a complete guesstimation, but it sounds reasonable to me.
- Add a `absoluteURLParsedBase` function to avoid parsing baseURL over and over.
2025-07-07 15:21:13 -07:00
jvoisin 15e4c3a374 refactor(database): get rid of the sqlite tentative 2025-07-02 17:18:03 -07:00
jvoisin 69a74c4abf refactor(readability): minor clean up
Remove a now-useless regex and its associated test.
2025-07-02 16:50:49 -07:00
jvoisin 766d4ab834 refactor(readability): make use of getSelectionLength 2025-07-02 16:47:27 -07:00
Frédéric Guillot cb617ff6e0 test(sanitizer): enhance tests for image width and height attributes 2025-07-01 20:52:45 -07:00
Frédéric Guillot 8c3f280f32 test(readability): add test case for ExtractContent with broken reader 2025-07-01 20:14:52 -07:00
jvoisin 8a98926674 refactor(readability): add a getSelectionLength function
When we're only interested in the length of contained Text, there is no need to
materialize it fully to then call len() on the result: we can simply iterate
over the text element and sum their length instead.
2025-07-01 19:52:53 -07:00
jvoisin 435a950d64 refactor(sanitizer): minor refactorization
Use a proper switch-case instead of a bunch of if.
2025-07-01 19:48:55 -07:00
jvoisin 89c32d518d perf(readability): significantly improve transformMisusedDivsIntoParagraphs 2025-07-01 19:44:58 -07:00
jvoisin 2f7b2e7375 perf(readability): improve getLinkDensity
- There is no need to materialize all the content of a given Node when we can
  simply compute its length directly, saving a lot of memory, on the order of
  several megabytes on my instance, with peaks at a couple of dozen.
- One might object to the usage of a recursive construct, but this is a direct
  port of goquery's Text method, so this change doesn't make anything worse.
- The computation of linkLength can be similarly computed, but this can go in
  another commit, as it's a bit trickier, since we need to get the length of
  every Node that has a `a` Node as parent, without iterating on the whole
  parent chain every time.
2025-07-01 19:40:47 -07:00
Frédéric Guillot 6eeccae7cd test(readability): increase test coverage 2025-06-30 21:29:07 -07:00
jvoisin 99c5bcdb01 perf(storage): truncate strings on go's side instead of pgsql's
There is no need to send the whole title and content to have them truncated on
postgresql's side when we can do this client-side. This should save some
memory on the database's side, as well as some bandwidth
if it's located on another server. And it makes the SQL queries a tad more
readable as well.
2025-06-30 19:45:45 -07:00
jvoisin aed99e65c1 perf(readability): improve getClassWeight speed
Before

```console
$ go test -bench=.
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/readability
BenchmarkExtractContent-8   	     34	 86102474 ns/op
BenchmarkGetWeight-8        	  10573	    103045 ns/op
PASS
ok  	miniflux.app/v2/internal/reader/readability	5.409s
```

After

```console
$ go test -bench=.
goos: linux
goarch: arm64
pkg: miniflux.app/v2/internal/reader/readability
BenchmarkExtractContent-8   	     56	 83130924 ns/op
BenchmarkGetWeight-8        	 246541	     5241 ns/op
PASS
ok  	miniflux.app/v2/internal/reader/readability	6.026s
```

This should make ProcessFeedEntries marginally faster, while saving
some memory.
2025-06-30 19:28:20 -07:00
jvoisin d1a3f98df9 perf(fetcher): save 8 bytes in the RequestBuilder struct
before:

```
  // request_builder.go:25 | Size: 64 (Optimal: 56)
  type RequestBuilder struct {
    headers          http.Header                 ■ ■ ■ ■ ■ ■ ■ ■
    clientProxyURL   *url.URL                    ■ ■ ■ ■ ■ ■ ■ ■
    useClientProxy   bool                        ■ □ □ □ □ □ □ □
    clientTimeout    int                         ■ ■ ■ ■ ■ ■ ■ ■
    withoutRedirects bool                        ■
    ignoreTLSErrors  bool                          ■
    disableHTTP2     bool                            ■ □ □ □ □ □
    proxyRotator     *proxyrotator.ProxyRotator  ■ ■ ■ ■ ■ ■ ■ ■
    feedProxyURL     string                      ■ ■ ■ ■ ■ ■ ■ ■
                                                 ■ ■ ■ ■ ■ ■ ■ ■
  }
```

after:

```
  // request_builder.go:25 | Size: 56
  type RequestBuilder struct {
    headers          http.Header                 ■ ■ ■ ■ ■ ■ ■ ■
    clientProxyURL   *url.URL                    ■ ■ ■ ■ ■ ■ ■ ■
    clientTimeout    int                         ■ ■ ■ ■ ■ ■ ■ ■
    useClientProxy   bool                        ■
    withoutRedirects bool                          ■
    ignoreTLSErrors  bool                            ■
    disableHTTP2     bool                              ■ □ □ □ □
    proxyRotator     *proxyrotator.ProxyRotator  ■ ■ ■ ■ ■ ■ ■ ■
    feedProxyURL     string                      ■ ■ ■ ■ ■ ■ ■ ■
                                                 ■ ■ ■ ■ ■ ■ ■ ■
  }
```
2025-06-29 16:10:35 -07:00
jvoisin 112494bb66 perf(feed): save 16 bytes in the Feed struct
before:

```
  // feed.go:25 | Size: 560 (Optimal: 544)
  type Feed struct {
    ID                          int64      ■ ■ ■ ■ ■ ■ ■ ■
    UserID                      int64      ■ ■ ■ ■ ■ ■ ■ ■
    FeedURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    SiteURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Title                       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Description                 string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    CheckedAt                   time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NextCheckAt                 time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    EtagHeader                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    LastModifiedHeader          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorMsg             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorCount           int        ■ ■ ■ ■ ■ ■ ■ ■
    ScraperRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Crawler                     bool       ■ □ □ □ □ □ □ □
    BlocklistRules              string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UrlRewriteRules             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Disabled                    bool       ■
    NoMediaPlayer               bool         ■
    IgnoreHTTPCache             bool           ■
    AllowSelfSignedCertificates bool             ■
    FetchViaProxy               bool               ■
    HideGlobally                bool                 ■
    DisableHTTP2                bool                   ■ □
    AppriseServiceURLs          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    WebhookURL                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NtfyEnabled                 bool       ■ □ □ □ □ □ □ □
    NtfyPriority                int        ■ ■ ■ ■ ■ ■ ■ ■
    NtfyTopic                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    PushoverEnabled             bool       ■ □ □ □ □ □ □ □
    PushoverPriority            int        ■ ■ ■ ■ ■ ■ ■ ■
    ProxyURL                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Category                    *Category  ■ ■ ■ ■ ■ ■ ■ ■
    Icon                        *FeedIcon  ■ ■ ■ ■ ■ ■ ■ ■
    Entries                     Entries    ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    TTL                         int        ■ ■ ■ ■ ■ ■ ■ ■
    IconURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UnreadCount                 int        ■ ■ ■ ■ ■ ■ ■ ■
    ReadCount                   int        ■ ■ ■ ■ ■ ■ ■ ■
    NumberOfVisibleEntries      int        ■ ■ ■ ■ ■ ■ ■ ■
  }
```

after:

```
// feed.go:25 | Size: 544
  type Feed struct {
    ID                          int64      ■ ■ ■ ■ ■ ■ ■ ■
    UserID                      int64      ■ ■ ■ ■ ■ ■ ■ ■
    FeedURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    SiteURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Title                       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Description                 string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    CheckedAt                   time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NextCheckAt                 time.Time  ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    EtagHeader                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    LastModifiedHeader          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorMsg             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    ParsingErrorCount           int        ■ ■ ■ ■ ■ ■ ■ ■
    ScraperRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    BlocklistRules              string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UrlRewriteRules             string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Disabled                    bool       ■
    NoMediaPlayer               bool         ■
    IgnoreHTTPCache             bool           ■
    AllowSelfSignedCertificates bool             ■
    FetchViaProxy               bool               ■
    HideGlobally                bool                 ■
    DisableHTTP2                bool                   ■
    PushoverEnabled             bool                     ■
    NtfyEnabled                 bool       ■
    Crawler                     bool         ■ □ □ □ □ □ □
    AppriseServiceURLs          string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    WebhookURL                  string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    NtfyPriority                int        ■ ■ ■ ■ ■ ■ ■ ■
    NtfyTopic                   string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    PushoverPriority            int        ■ ■ ■ ■ ■ ■ ■ ■
    ProxyURL                    string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    Category                    *Category  ■ ■ ■ ■ ■ ■ ■ ■
    Icon                        *FeedIcon  ■ ■ ■ ■ ■ ■ ■ ■
    Entries                     Entries    ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    TTL                         int        ■ ■ ■ ■ ■ ■ ■ ■
    IconURL                     string     ■ ■ ■ ■ ■ ■ ■ ■
                                           ■ ■ ■ ■ ■ ■ ■ ■
    UnreadCount                 int        ■ ■ ■ ■ ■ ■ ■ ■
    ReadCount                   int        ■ ■ ■ ■ ■ ■ ■ ■
    NumberOfVisibleEntries      int        ■ ■ ■ ■ ■ ■ ■ ■
  }
```
2025-06-29 16:10:35 -07:00
jvoisin 9f7ecdb75a perf(model): save 16 bytes in the FeedCreationRequest struct
before:

```
  // feed.go:154 | Size: 240 (Optimal: 224)
  type FeedCreationRequest struct {
    FeedURL                     string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    CategoryID                  int64   ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Crawler                     bool    ■
    Disabled                    bool      ■
    NoMediaPlayer               bool        ■
    IgnoreHTTPCache             bool          ■
    AllowSelfSignedCertificates bool            ■
    FetchViaProxy               bool              ■ □ □
    UrlRewriteRules             string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    DisableHTTP2                bool    ■ □ □ □ □ □ □ □
    ScraperRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlocklistRules              string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    HideGlobally                bool    ■ □ □ □ □ □ □ □
    ProxyURL                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
  }
```

after:

```
  // feed.go:154 | Size: 224
  type FeedCreationRequest struct {
    FeedURL                     string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    CategoryID                  int64   ■ ■ ■ ■ ■ ■ ■ ■
    UserAgent                   string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Cookie                      string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Username                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Password                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    Crawler                     bool    ■
    Disabled                    bool      ■
    NoMediaPlayer               bool        ■
    IgnoreHTTPCache             bool          ■
    AllowSelfSignedCertificates bool            ■
    FetchViaProxy               bool              ■
    HideGlobally                bool                ■
    DisableHTTP2                bool                  ■
    ScraperRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    RewriteRules                string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlocklistRules              string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeeplistRules               string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    BlockFilterEntryRules       string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    KeepFilterEntryRules        string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    UrlRewriteRules             string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
    ProxyURL                    string  ■ ■ ■ ■ ■ ■ ■ ■
                                        ■ ■ ■ ■ ■ ■ ■ ■
  }
```
2025-06-29 16:10:35 -07:00
jvoisin 4e1f836266 refactor(readability): simplify a bit getArticle
- Use a proper division instead of multiplying by a float.
- Extract a condition in the parent scope
- Use an else-if construct instead of a simple if
2025-06-29 16:06:34 -07:00
Frédéric Guillot a68de4ee6a test(readability): add tests for getArticle function 2025-06-29 16:03:17 -07:00
jvoisin c064891314 perf(readability): Simplify removeUnlikelyCandidates
- Use an array of strings instead of a regex, like done in ef13756b1a7a7ba30fd34174a5367381fd8b4849
- Extract the `shouldRemove` function from `removeUnlikelyCandidates`, as there
  is no reason to have it there instead of being a proper standalone function.
- Improve a condition, where the goquery selection would have its `id`
  attribute left unchecked if a `class` one was present, regardless of if
  `class` was a candidate to removal or not.
- Add some comments
2025-06-29 15:31:01 -07:00
Frédéric Guillot 5129f53d58 test(readability): add tests for removeUnlikelyCandidates function 2025-06-29 15:23:56 -07:00
Frédéric Guillot e60f0fd142 test(readability): add tests for getClassWeight function 2025-06-29 13:24:06 -07:00
Julien Voisin 2b26a345cd perf(processor): minify content even further
There is no need to keep comments (conditionals or not, as IE isn't a thing
anymore), nor default attribute values.
2025-06-29 12:55:34 -07:00
Frédéric Guillot 3de31a1a4d test(processor): add more unit tests for minifyContent function 2025-06-29 12:53:23 -07:00
jvoisin 560be66147 refactor(misc): Use proper slog.XXX instead of slog.Any
This has close to no impact for now, as our slog.Debug/Info/... are leaking
their parameters to the heap, but using proper typing instead of Any allows
to skip some reflection-based computation, making things marginally faster,
and removing the corresponding heap leak.
2025-06-29 12:30:17 -07:00
Ingmar Stein fcf86e33b9 feat: TLS support for Unix socket listeners
This change enables Miniflux to serve TLS over Unix domain sockets.

If `CERT_FILE` and `KEY_FILE` are configured, Unix socket listeners
specified via `LISTEN_ADDR` will now automatically start with TLS enabled,
using the provided certificates. This uses the existing `http.Server.ServeTLS`
method.

If no certificates are provided, Unix socket listeners will continue to
operate as plain, non-TLS sockets.
2025-06-24 21:25:55 -07:00
dependabot[bot] 113f6b8982 build(deps): bump github.com/andybalholm/brotli from 1.1.1 to 1.2.0
Bumps [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) from 1.1.1 to 1.2.0.
- [Commits](https://github.com/andybalholm/brotli/compare/v1.1.1...v1.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-24 19:52:21 -07:00
jvoisin cbdcf1a56c Revert "perf(storage): take advantage of entries_feed_id_hash_key in updateEntry"
This reverts commit 6527c04307.
2025-06-24 19:51:21 -07:00
Frédéric Guillot 95eb6c1230 chore(docker): update golang base image to alpine 3.22 2025-06-23 19:11:03 -07:00
jvoisin 643b89ec89 perf(storage): take advantage of entries_feed_id_hash_key in updateEntry 2025-06-23 17:59:17 -07:00
Frédéric Guillot 84ebf1a033 docs(manpage): update LISTEN_ADDR description 2025-06-23 17:51:02 -07:00
Frédéric Guillot 875618d786 docs(changelog): update release notes for version 2.2.10 2025-06-23 16:57:29 -07:00
Frédéric Guillot 1503a5c946 docs: add CONTRIBUTING.md file 2025-06-22 12:44:02 -07:00
jvoisin 8641f5f2a3 refactor(database): drop 3 columns in a single transaction 2025-06-20 16:23:20 -07:00
jvoisin 93b17af78b refactor(appjs): no need to check if always present elements are always present 2025-06-20 13:16:57 -07:00
Frédéric Guillot 92876a0c61 refactor(http): rename package from httpd to server for consistency 2025-06-20 13:15:13 -07:00
Frédéric Guillot d62df4e02a refactor(server): avoid double call to Sprintf 2025-06-20 13:05:21 -07:00
Ingmar Stein 8fa5041c37 feat: Allow multiple listen addresses
This change implements the ability to specify multiple listen addresses.
This allows the application to listen on different interfaces or ports simultaneously,
or a combination of IP addresses and Unix sockets.

Closes #3343
2025-06-20 12:46:34 -07:00
Julien Voisin dc05965895 chore(template): remove X-UA-Compatible meta tag specific to Internet Explorer 2025-06-20 11:59:08 -07:00
jvoisin 109e668ac7 perf(storage): pre-allocate a slice in RefreshFeedEntries 2025-06-20 11:57:04 -07:00
Frédéric Guillot 6d58052504 fix(readability): do not remove elements within code blocks
`<span class="hljs-comment"># exit 1</span>` will match the `unlikelyCandidatesRegexp` because it contains the `comment` string.
2025-06-19 21:03:53 -07:00
Matthaiks 491d51c95f feat(locale): update Polish translation 2025-06-19 18:47:28 -07:00
Frédéric Guillot db49e41acf refactor(processor): move FilterEntryMaxAgeDays filter to filter package 2025-06-19 17:56:45 -07:00
Frédéric Guillot e6b814199b feat(filter): add EntryDate=max-age:duration filter
Example: `EntryDate=max-age:30d` or `EntryDate=max-age:1h`

Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h", "d".
2025-06-19 17:25:19 -07:00
Frédéric Guillot b0a10f02fd feat(css): add margin-bottom to input for consistent spacing 2025-06-19 16:35:17 -07:00
Frédéric Guillot 9c05c3c493 feat(filter): merge user and feed entry filter rules 2025-06-19 16:24:57 -07:00
Frédéric Guillot 2a9d91c783 feat: add entry filters at the feed level 2025-06-19 15:15:16 -07:00
Frédéric Guillot cb59944d6b refactor(processor): move RewriteEntryURL function to rewrite package 2025-06-19 13:22:29 -07:00
Frédéric Guillot c12476c1a9 refactor(filter): avoid code duplication between IsBlockedEntry and IsAllowedEntry functions 2025-06-19 12:55:00 -07:00
Frédéric Guillot bc6ab44ff2 fix(filter): skip invalid rules instead of exiting the loop 2025-06-19 12:36:35 -07:00
Frédéric Guillot 6282ac1f38 refactor(processor): move filters to a filter package 2025-06-19 12:06:30 -07:00
jvoisin 96c0ef4efd refactor(processor): massive refactoring of filters.go
- Use proper variable names for `key=value` strings parts
- Explicitly assign false to the `match` boolean
- Use an explicit `len(parts) == 2` assertion to help the compiler remove
  `isSliceInBounds` calls.
- Refactor identical code into a containsRegexPattern function.
- Early exit when parsing the first date fails when using the `Between`
  operator, instead of trying to parse the second one.
2025-06-19 11:43:47 -07:00
jvoisin b139ac4a2c refactor(youtube): Remove a regex and make use of fetchWatchTime 2025-06-19 11:43:47 -07:00
jvoisin c818d5bbb8 refactor(youtube): initiliaze two maps to the proper length 2025-06-19 11:43:47 -07:00
jvoisin e366710529 refactor(processor): remove a useless type declaration 2025-06-19 11:43:47 -07:00
jvoisin 5cff4d7117 refactor(processor): remove a duplication function call
As youtubeVideoID is assigned to getVideoIDFromYouTubeURL(entry.URL),
there is no need to call the latter again when we can simly use youtubeVideoID
instead.
2025-06-19 11:43:47 -07:00
jvoisin f31a784eaa refactor(processor): refactor common code into a fetchWatchTime function
Both nebula and odysee were using the same function to parse time.
2025-06-19 11:43:47 -07:00
jvoisin 7edfcc3cf7 refactor(processor): remove a useless type declaration 2025-06-19 11:43:47 -07:00
jvoisin fe4b00b9f8 refactor(processor): extract some functions into an utils.go file 2025-06-19 11:43:47 -07:00
jvoisin 46b159ac58 refactor(processor): simplify bilibili processing
- Use strings.Contains instead of a regex
- Use strings concatenation instead of a call to fmt.Sprintf
- Use `any` instead of `interface{}`
2025-06-19 11:43:47 -07:00
jvoisin 86c58e11f6 perf(reader): use a non-cryptographic hash when possible
There is no need to use SHA256 everywhere, especially on small inputs where we
don't care about its cryptographic properties. We're using FNV as it's the
faster available hash in go's standard library, and we're picking its "a"
version as it's slightly better avalanche characteristics, which are
relevant for small inputs.

This commit has the side-effect of invalidating all favicons saved in the
database, which is desirable to benefit from the resize process implemented in
777d0dd2, as it didn't apply retro-actively.

We're also making use of hex.EncodeToString instead of fmt.Sprintf, as it's
marginally faster.

Note that we can't change the usage of sha256 for feed.Hash as it's used to
deduplicate entries in the database.
2025-06-18 20:28:23 -07:00
jvoisin 9a1d9593b3 refactor(crypto): use rand.Text() instead of a custom implementation
Go 1.24 provides the helpful rand.Text() function, returning a base32-encoded
string containing at least 128 bits of randomness. We should make use of it
everywhere it makes sense to do so, if only to not having to think about much
entropy do we need for each cases, and just trust the go crypto team.

Also, rand.Read() can't fail, so no need to check its return value:
https://pkg.go.dev/crypto/rand#Read This behaviour is consistent with go's
standard library itself.
2025-06-18 20:12:55 -07:00
jvoisin 43546976d2 refactor(tests): use b.Loop() instead of for range b.N
See https://tip.golang.org/doc/go1.24#new-benchmark-function
2025-06-18 20:12:55 -07:00
jvoisin 3ab9ca9e4d refactor(http): Don't hardcode TLS configuration
- TLS 1.2 is used as MinVersion by default
- With regard to CipherSuites, in Go 1.22 RSA key exchange based cipher suites
  were removed from the default list, and in Go 1.23 3DES cipher suites were
  removed as well. Ciphers for TLS1.3 aren't configurable.
- No need to specify CurveP25, as the servers will likely disable the weird
  ones like CurveP384 and CurveP521. Removing the explicit specification also
  enables the post-quantum X25519MLKEM768, wow!

I trust the go team to make better choices on the long term than us keeping
miniflux up to date with the latest TLS trend.
2025-06-18 20:12:55 -07:00
jvoisin 1af6df7cb9 perf(api): use math/rand/v2 2025-06-18 20:12:55 -07:00
jvoisin 50dff576b0 build(go): bump to go 1.24 2025-06-18 20:12:55 -07:00
Frédéric Guillot 6af4d69c39 test(sanitizer): add test case to cover Vimeo iframe rewrite without query string 2025-06-17 17:55:39 -07:00
Frédéric Guillot 27015a5e34 test(sanitizer): add unit test for 0x0 pixel tracker 2025-06-17 17:42:55 -07:00
jvoisin cdb57b3843 perf(sanitizer): minor simplifications of the sanitizer
- Factorize some conditions
- Remove useless `default` case and move the return at the end of the functions
- Use strings.CutPrefix instead of strings.HasPrefix + strings.TrimPrefix
- Use switch-case constructs instead of slices.Contains, as this reduces the
  complexity of the functions and allows them to be inlined, as well as helping
  the compiler to optimize them, as it sucks at interprocedural optimizations.
2025-06-17 17:42:45 -07:00
jvoisin 152ef578d2 feat(sanitizer): consider images of size 0x0 as pixel trackers 2025-06-17 17:32:00 -07:00
jvoisin 72486b9bd1 refactor(processor): minor simplification of a loop
This makes the code a tad clearer.
2025-06-17 17:30:13 -07:00
jvoisin 81df0b2a16 perf(rewrite): make getPredefinedRewriteRules O(1) 2025-06-17 17:27:36 -07:00
jvoisin b296f21e98 refactor(internal): add an urllib.DomainWithoutWWW function 2025-06-17 17:27:36 -07:00
jvoisin af15032145 perf(fetcher): pre-allocate the cipherSuites 2025-06-17 16:53:00 -07:00
jvoisin 6a6a88d06d docs(readme): document a couple of nifty features 2025-06-17 16:50:38 -07:00
jvoisin 8660f5e3c7 perf(media): minor regex simplification
The previous regex was using the [ABC..D]*[ABC] pattern, resulting in a lot of
backtracking. The new regex is stopping the matching at the first space or end
of text (and removes the trailing `.` should one be present).

The backtracking was taking around 50% of the CPU time spent in atom.Parse
2025-06-17 16:49:07 -07:00
Frédéric Guillot da4ab4263c feat(rewrite): add parkablogs.com to the referer override list 2025-06-16 20:28:11 -07:00
jvoisin 237672a62c perf(sanitizer): use a switch-case instead of a map
This removes a heap allocation, and should be way faster. It also makes the
code shorted/simpler.
2025-06-16 14:54:48 -07:00
jvoisin e9d4a130fd refactor(sanitizer): remove two useless www. prefixes
No need to have those prefixes, as the check is for substrings, so removing
them will improve the amount of matches.
2025-06-16 14:53:15 -07:00
Frédéric Guillot d291d6a74d refactor(config): remove deprecated config options 2025-06-15 14:42:28 -07:00
Frédéric Guillot e0f7e6f2a8 feat(config)!: remove SERVER_TIMING_HEADER config option
BREAKING CHANGE: This option is not really useful and it's used only on
the unread page.
2025-06-15 14:17:28 -07:00
jvoisin ef3dbd3707 perf(database): use TRUNCATE instead of DELETE FROM in migrations
This is marginally faster.
2025-06-15 13:58:46 -07:00
jvoisin 32f08053aa perf(database): marginally speeds migrations up
PostgreSQL allows table alterations to be done in a single query, so let's take
advantage of it. This should marginally speed the CI up.
2025-06-15 13:58:46 -07:00
jvoisin 117c031f1c feat(integration)!: remove Pocket integration
BREAKING CHANGE: Pocket will no longer be available after July 8, 2025.

https://support.mozilla.org/en-US/kb/future-of-pocket#w_when-is-pocket-shutting-down
2025-06-15 13:29:55 -07:00
Frédéric Guillot b95c9023ee refactor(sanitizer): make isValidAttribute() check O(1) 2025-06-13 21:44:25 -07:00
Frédéric Guillot 3538c4271b refactor(sanitizer): use global variables to avoid recreating slices on every call 2025-06-13 21:34:07 -07:00
Frédéric Guillot ac44507af2 refactor(sanitizer): use a map for iframe allow list 2025-06-13 21:05:23 -07:00
jvoisin 44c48d109f perf(sanitizer): extract a call to url.Parse and make intensive use of it
Previously, url.Parse(baseUrl) was called on every self-closing tags, and on
most opening tags, accounting for around 15% of the CPU time spent in
processor.ProcessFeedEntries
2025-06-13 17:05:17 -07:00
Frédéric Guillot 40727704c2 feat(rewrite): add support for YouTube Shorts video URL pattern 2025-06-12 21:02:46 -07:00
jvoisin 8a014c6abc perf(readability): minor regex improvement
- Improve the check for tags by matching only if its name is followed either by
  a space, a slash or a closing angle
- Use an anonymous group
2025-06-12 19:13:58 -07:00
jvoisin 60ad19c427 perf(rss): early return when looking for an item's author
The `sanitizer.StripTags` function is calling `html.NewTokenizer`, which is
allocating a 4096 bytes buffer on the heap, as well a running a complex state
machine to tokenize html. There is no need to do all of this for empty strings.

This commit also fixes a TrimSpace/StripTags call inversion.
2025-06-11 19:06:15 -07:00
jvoisin f40c1e7f63 fix(reader): fix a crash introduced by d59990f1
And add a fuzzer and a testcase as well to validate that nothing breaks.
2025-06-11 19:04:46 -07:00
Frédéric Guillot a4d16cc5c1 refactor(rewrite): rename Rewriter function to ApplyContentRewriteRules 2025-06-10 20:28:15 -07:00
jvoisin 7c857bdc72 perf(reader): optimize RemoveTrackingParameters
A bit more than 10% of processor.ProcessFeedEntries' CPU time is spent in
urlcleaner.RemoveTrackingParameters, specifically calling url.Parse, so let's
extract this operation outside of it, and do it once before calling
urlcleaner.RemoveTrackingParameters multiple times.

Co-authored-by: Frédéric Guillot <f@miniflux.net>
2025-06-10 19:29:25 -07:00
jvoisin 0caadf82f2 perf(rss): optimize a bit BuildFeed
Calls to urllib.AbsoluteURL take a bit less than 10% of the time spent in
parser.ParseFeed, completely parsing an url only to check if it's absolute, and
if not, to make it so.

Checking if it starts with `https://` or `http://` is usually enough to find if
an url is absolute, and if is doesn't, it's always possible to fall back to
urllib.AbsoluteURL.

This also comes with the advantage of reducing heap allocations, as most of the
time spent in urllib.AbsoluteURL is heap-related (de)allocations.
2025-06-10 19:23:16 -07:00
jvoisin 0086e0b356 perf(validator): slightly optimize a regex
- There is no need to have groups as we're only using this regex for
  `MatchString`.
- Since the only place where this regex is used is already calling
  strings.ToLower, there is no need to check for `A-Z`.
2025-06-10 19:20:58 -07:00
Frédéric Guillot 70b513b8db feat(ui): display external URL in single entry view
Display the article's external URL directly in the single entry view.

Rationale: On mobile devices, users couldn't see where a link pointed before tapping it.
Previously, the only way to view the external URL was by hovering - an action not available on touch devices.
2025-06-09 21:14:55 -07:00
Frédéric Guillot cecc18420d feat(sanitizer): add validation for empty width and height attributes in img tags 2025-06-09 20:38:17 -07:00
Frédéric Guillot d53fd17e10 feat(sanitizer): validate MathML XML namespace 2025-06-09 20:28:54 -07:00
Frédéric Guillot 21d22d7f0b feat(sanitizer): add support for fetchpriority and decoding attributes in img tags 2025-06-09 20:12:15 -07:00
jvoisin d59990f1dd perf(xml): optimize xml filtering
Instead of using bytes.Map which is returning a copy of the provided []byte,
use a custom in-place implementation, as the bytes.Map call is taking around
25% of rss.Parse
2025-06-09 13:49:10 -07:00
jvoisin 49085daefe perf(xml): optimized NewXMLDecoder
io.ReadAll is growing the underlying buffer progressively, while
io.Copy is able to allocate it in one go, which is significantly faster.
io.ReadAll is currently accounting for around 10% of the CPU time of rss.Parse
2025-06-09 13:49:10 -07:00
jvoisin 5872710d22 perf(storage): optimize away two Sprintf calls
The call to fmt.Sprintf in WithFeedID accounts for more than 20% of the time
spent in GetFeed. Use strconv.Itoa instead, as it's much much faster.
Also change WithCategoryID in the same way, for consistency's sake.
2025-06-09 13:10:51 -07:00
Qeynos d2212dee12 feat(locale): update Chinese translations 2025-06-08 21:11:04 -07:00
Frédéric Guillot 8db637cb39 feat(ui): add user setting to control target="_blank" on links
Rationale: Opening links in the current tab is the default browser behavior.

Using `target="_blank"` on external links can lead to accessibility issues and override user preferences. It may also interfere with assistive technologies and expected browser behavior.

To maintain backward compatibility, this option is enabled by default (`true`), which adds `target="_blank"` to links.
2025-06-08 21:07:11 -07:00
Frédéric Guillot 699deea72c feat(oidc): use preferred_username first instead of email claim 2025-06-08 18:05:47 -07:00
Frédéric Guillot c41d189a7a fix(karakeep): correct method name and improve error handling in SaveURL 2025-06-08 17:47:20 -07:00
Frédéric Guillot adfc38d237 feat(locale): update locales using machine translation 2025-06-08 17:14:45 -07:00
Frédéric Guillot a8bb7a48d7 feat(ui): avoid showing an excessive number of tags 2025-06-08 15:29:09 -07:00
jvoisin f9dce3d10f perf(timzone): cache getLocation's results
Every time getLocation is called, it's opening and parsing a file on disc,
sometimes a zip file depending on the system. We can cache the results instead
of doing this.

See https://github.com/golang/go/issues/24844 and https://github.com/golang/go/issues/26106
2025-06-08 13:50:18 -07:00
dependabot[bot] 567e8cfc89 build(deps): bump golang.org/x/image from 0.27.0 to 0.28.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.27.0 to 0.28.0.
- [Commits](https://github.com/golang/image/compare/v0.27.0...v0.28.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 19:09:51 -07:00
dependabot[bot] a8e73d6875 build(deps): bump golang.org/x/net from 0.40.0 to 0.41.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/net/compare/v0.40.0...v0.41.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 19:04:04 -07:00
dependabot[bot] fa7f2b18a0 build(deps): bump golang.org/x/crypto from 0.38.0 to 0.39.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 17:48:47 -07:00
Jesse Jaggars 43d302e768 feat: adding support for saving entries to karakeep
Signed-off-by: Jesse Jaggars <jhjaggars@gmail.com>
2025-06-04 21:10:26 -07:00
dependabot[bot] 4e181330d0 build(deps): bump library/alpine in /packaging/docker/alpine
Bumps library/alpine from 3.21 to 3.22.

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 19:17:39 -07:00
the7thNightmare 0369f03940 feat(locale): update Indonesian translations 2025-05-28 20:45:45 -07:00
Qeynos 4597d9b289 feat(locale): update Chinese translations 2025-05-28 20:44:40 -07:00
Cthulhux 7bfd22aab7 feat(locale): update German translation
Translated one string, found a good wording for the other.
2025-05-27 19:17:23 -07:00
Frédéric Guillot 325c505b88 docs(changelog): update release notes for version 2.2.9 2025-05-26 18:13:05 -07:00
Frédéric Guillot bfd8860398 feat(api): add new endpoints to manage API keys 2025-05-25 15:50:13 -07:00
Matthaiks ebd65da3b6 feat(locale): update Polish translation 2025-05-25 15:30:36 -07:00
Frédéric Guillot 83191b0c1d fix(storage): remove extra comma introduced by commit 09fb05a 2025-05-25 13:33:41 -07:00
Frédéric Guillot 8142268799 feat: populate feed description automatically 2025-05-24 21:15:52 -07:00
Frédéric Guillot 5920e02562 feat: add liveness and readiness probes
- Added new routes: /liveness, /healthz, /readiness, /readyz
- These routes do not take the base path into consideration and are always available at the root of the server
2025-05-24 20:36:05 -07:00
Kelly Norton 09fb05aaaf feat: add option to always open articles externally 2025-05-24 19:46:01 -07:00
Frédéric Guillot 52b184394f fix(migrations): prevent failure at v45 with long entry URLs
Fixes an issue where upgrading from older versions of Miniflux could fail with the following PostgreSQL error:

```
[FATAL] [Migration v45] pq: index row size 2744 exceeds btree version 4 maximum 2704 for index "entries_feed_url_idx"
```
2025-05-23 13:27:05 -07:00
Matthaiks 7c8c7c2711 feat(locale): update Polish translation 2025-05-23 12:21:28 -07:00
Frédéric Guillot 9768eb9fb9 feat(locale): update French translations 2025-05-22 20:28:38 -07:00
Tianzhi Jin b65373db7e feat(webauthn): perfer creation of a client-side discoverable credential 2025-05-22 20:14:00 -07:00
dependabot[bot] 596d22c02c build(deps): bump github.com/tdewolff/minify/v2 from 2.23.6 to 2.23.8
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.6 to 2.23.8.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.6...v2.23.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 19:32:06 -07:00
Anton Larionov 4b86570b7c chore(gitignore): ignore miniflux binary in root directory 2025-05-22 19:31:52 -07:00
Anton Larionov e99864a456 fix(locale): localize Git commit label at about page 2025-05-22 19:30:10 -07:00
Anton Larionov 225463817c feat(locale): complete Russian translation 2025-05-20 19:37:41 -07:00
Matthaiks 3db6e822cb feat(locale): update Polish translation 2025-05-20 19:36:44 -07:00
dependabot[bot] 1c19151925 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.5 to 2.23.6
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.5 to 2.23.6.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.5...v2.23.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-20 19:36:20 -07:00
Anton Larionov 553c578f2e feat(rssbridge): support auth token for RSS-Bridge 2025-05-19 20:47:12 -07:00
Tianzhi Jin 81ec32a8b6 fix(webauthn): correct arg in debug log 2025-05-14 21:01:52 -07:00
dependabot[bot] 3818a8a4fb build(deps): bump github.com/go-webauthn/webauthn from 0.12.3 to 0.13.0
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.12.3 to 0.13.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.12.3...v0.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-13 19:17:05 -07:00
Frédéric Guillot 036704b3e4 feat(response): change error response content type to plain text and escape HTML
Adding another layer of security in addition to the existing CSP cannot
hurt.
2025-05-11 19:15:54 -07:00
Frédéric Guillot 327d027d38 feat(settings): replace div.panel with paragraph tags for OAuth2 links 2025-05-11 18:06:16 -07:00
Frédéric Guillot 5ae2cbd943 feat(settings): add validation for entry order and categories sorting order 2025-05-11 17:52:59 -07:00
dependabot[bot] f15d29deb3 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.3 to 2.23.5
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.3 to 2.23.5.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.3...v2.23.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-08 19:03:12 -07:00
Frédéric Guillot 828a4334db fix(sanitizer): MathML tags are not fully supported by golang.org/x/net/html
See https://github.com/golang/net/blob/master/html/atom/gen.go
and https://github.com/golang/net/blob/master/html/atom/table.go
2025-05-06 21:18:19 -07:00
jvoisin d1dc369bb2 feat(sanitizer): add MathML tags to the sanitizer
This was found by reading the article pointed by https://lobste.rs/s/nobvmp/how_prime_factorizations_govern_collatz
2025-05-06 20:19:56 -07:00
Frédéric Guillot a8076e1891 ci: remove deprecated reviewers field from dependantbot.yml 2025-05-06 20:17:19 -07:00
dependabot[bot] 3448d6267c build(deps): bump golang.org/x/net from 0.39.0 to 0.40.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 20:10:57 -07:00
dependabot[bot] 159261f2f8 build(deps): bump golang.org/x/oauth2 from 0.29.0 to 0.30.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 20:07:15 -07:00
jvoisin ff2dfe977b feat: remove the ref parameter from url
This is used by (at least) Ghost (https://forum.ghost.org/t/ref-parameter-being-added-to-links/38335)

Examples:
- https://blog.exploits.club/exploits-club-weekly-newsletter-66-mitigations-galore-dirtycow-revisited-program-analysis-for-uafs-and-more/
- https://labs.watchtowr.com/is-the-sofistication-in-the-room-with-us-x-forwarded-for-and-ivanti-connect-secure-cve-2025-22457/
2025-05-06 19:59:55 -07:00
dependabot[bot] a5e3719773 build(deps): bump golang.org/x/crypto from 0.37.0 to 0.38.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:32:46 -07:00
dependabot[bot] cdadb87203 build(deps): bump golang.org/x/image from 0.26.0 to 0.27.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/image/compare/v0.26.0...v0.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:28:02 -07:00
dependabot[bot] 5284d61fe3 build(deps): bump golangci/golangci-lint-action from 7 to 8
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 7 to 8.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 08:25:14 -07:00
Frédéric Guillot 3de9629a49 feat(googlereader): avoid SQL query to fetch username in streamItemContentsHandler 2025-05-04 20:38:53 -07:00
Frédéric Guillot 8d821dfc3b fix(googlereader): handle various item ID formats
- Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
- NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
- Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
- Liferea uses this format: "12345" (decimal string)
2025-05-04 20:11:37 -07:00
Frédéric Guillot cb775bc79e refactor(googlereader): move constants to separate files 2025-05-04 13:02:54 -07:00
Frédéric Guillot 6cc8d8abf1 fix(googlereader): /items/contents should accept short form item IDs 2025-05-03 21:48:41 -07:00
Frédéric Guillot 50395f13ca feat(googlereader): add mark-all-as-read endpoint 2025-05-03 18:38:54 -07:00
Frédéric Guillot e8c3435bb9 fix(googlereader): return a 400 instead of 500 for invalid edit requests 2025-05-02 18:15:00 -07:00
Frédéric Guillot 9a8a8bdca3 refactor(googlreader): remove redundant log message 2025-05-02 17:56:21 -07:00
Frédéric Guillot 63f0a17388 fix(googlereader): avoid panic for inexisting feed or category 2025-05-02 17:42:25 -07:00
NoelNegash 81c7669945 feat(sanitized): allow Spotify iframes 2025-05-02 16:25:17 -07:00
dependabot[bot] 2b000d1022 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.2 to 2.23.3
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.2 to 2.23.3.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.2...v2.23.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-02 16:20:29 -07:00
dependabot[bot] 27253c8a97 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.1 to 2.23.2
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.1 to 2.23.2.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.1...v2.23.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 16:54:23 -07:00
211 changed files with 11607 additions and 5627 deletions
@@ -63,3 +63,5 @@ body:
required: true
- label: "I understand that feature requests are not guaranteed to be implemented."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
+2
View File
@@ -84,3 +84,5 @@ body:
required: true
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
required: true
- label: "I agree to follow the project's contribution guidelines."
required: true
-24
View File
@@ -4,52 +4,28 @@ updates:
directory: "/"
schedule:
interval: "daily"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/alpine"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "/packaging/docker/distroless"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/debian"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "docker"
directory: "packaging/rpm"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "fguillot"
assignees:
- "fguillot"
+1 -1
View File
@@ -4,4 +4,4 @@ Have you followed these guidelines?
- [ ] There are no breaking changes
- [ ] I have thoroughly tested my changes and verified there are no regressions
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I have read this document: https://miniflux.app/faq.html#pull-request
- [ ] I have read and understood the [contribution guidelines](https://github.com/miniflux/v2/blob/main/CONTRIBUTING.md)
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
- uses: golangci/golangci-lint-action@v7
- uses: golangci/golangci-lint-action@v8
with:
args: >
--timeout 10m
+10 -20
View File
@@ -4,8 +4,10 @@ import sys
import argparse
from typing import Match
# Conventional commit pattern
CONVENTIONAL_COMMIT_PATTERN: str = r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}"
# Conventional commit pattern (including Git revert messages)
CONVENTIONAL_COMMIT_PATTERN: str = (
r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .{1,100}|Revert .+)"
)
def get_commit_message(commit_hash: str) -> str:
@@ -23,9 +25,7 @@ def get_commit_message(commit_hash: str) -> str:
sys.exit(1)
def check_commit_message(
message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN
) -> bool:
def check_commit_message(message: str, pattern: str = CONVENTIONAL_COMMIT_PATTERN) -> bool:
"""Check if commit message follows conventional commit format."""
first_line: str = message.split("\n")[0]
match: Match[str] | None = re.match(pattern, first_line)
@@ -50,9 +50,7 @@ def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
for commit_hash in commit_hashes:
message: str = get_commit_message(commit_hash)
if not check_commit_message(message):
non_compliant.append(
{"hash": commit_hash, "message": message.split("\n")[0]}
)
non_compliant.append({"hash": commit_hash, "message": message.split("\n")[0]})
return non_compliant
except subprocess.CalledProcessError as e:
@@ -61,15 +59,9 @@ def check_commit_range(base_ref: str, head_ref: str) -> list[dict[str, str]]:
def main() -> None:
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Check conventional commit compliance"
)
parser.add_argument(
"--base", required=True, help="Base ref (starting commit, exclusive)"
)
parser.add_argument(
"--head", required=True, help="Head ref (ending commit, inclusive)"
)
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Check conventional commit compliance")
parser.add_argument("--base", required=True, help="Base ref (starting commit, exclusive)")
parser.add_argument("--head", required=True, help="Head ref (ending commit, inclusive)")
args: argparse.Namespace = parser.parse_args()
non_compliant: list[dict[str, str]] = check_commit_range(args.base, args.head)
@@ -80,9 +72,7 @@ def main() -> None:
print(f"- {commit['hash'][:8]}: {commit['message']}")
print("\nPlease ensure your commit messages follow the format:")
print("type(scope): subject")
print(
"\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test"
)
print("\nWhere type is one of: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test")
sys.exit(1)
else:
print("All commits follow the conventional commit format!")
+2 -2
View File
@@ -1,7 +1,7 @@
./*.sha256
./miniflux
/miniflux
.idea
.vscode
*.deb
*.rpm
miniflux-*
miniflux-*
+178
View File
@@ -0,0 +1,178 @@
# Contributing to Miniflux
This document outlines how to contribute effectively to Miniflux.
## Philosophy
Miniflux follows a **minimalist philosophy**. The feature set is intentionally kept limited to avoid bloatware. Before contributing, please understand that:
- **Improving existing features takes priority over adding new ones**
- **Quality over quantity** - well-implemented, focused features are preferred
- **Simplicity is key** - complex solutions are discouraged in favor of simple, maintainable code
## Before You Start
### Feature Requests
Before implementing a new feature:
- Check if it aligns with Miniflux's philosophy
- Consider if the feature could be implemented differently to maintain simplicity
- Remember that developing software takes significant time, and this is a volunteer-driven project
- If you need a specific feature, the best approach is to contribute it yourself
### Bug Reports
When reporting bugs:
- Search existing issues first to avoid duplicates
- Provide clear reproduction steps
- Include relevant system information (OS, browser, Miniflux version)
- Include error messages, screenshots, and logs when applicable
## Development Setup
### Requirements
- **Git**
- **Go >= 1.24**
- **PostgreSQL**
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork locally:**
```bash
git clone https://github.com/YOUR_USERNAME/miniflux.git
cd miniflux
```
3. **Build the application binary:**
```bash
make miniflux
```
4. **Run locally in debug mode:**
```bash
make run
```
### Database Setup
For development and testing, you can run a local PostgreSQL database with Docker:
```bash
# Start PostgreSQL container
docker run --rm --name miniflux2-db -p 5432:5432 \
-e POSTGRES_DB=miniflux2 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
postgres
```
You can also use an existing PostgreSQL instance. Make sure to set the `DATABASE_URL` environment variable accordingly.
## Development Workflow
### Code Quality
1. **Run the linter:**
```bash
make lint
```
Requires `staticcheck` and `golangci-lint` to be installed.
2. **Run unit tests:**
```bash
make test
```
3. **Run integration tests:**
```bash
make integration-test
make clean-integration-test
```
### Building
- **Current platform:** `make miniflux`
- **All platforms:** `make build`
- **Specific platforms:** `make linux-amd64`, `make darwin-arm64`, etc.
- **Docker image:** `make docker-image`
### Cross-Platform Support
Miniflux supports multiple architectures. When making changes, ensure compatibility across:
- Linux (amd64, arm64, armv7, armv6, armv5)
- macOS (amd64, arm64)
- FreeBSD, OpenBSD, Windows (amd64)
## Pull Request Guidelines
### What Is Preferred
✅ **Good Pull Requests:**
- Focus on a single issue or feature
- Include tests for new functionality
- Maintain or improve performance
- Follow existing code style and patterns
- The commit messages follow the [conventional commit format](https://www.conventionalcommits.org/) (e.g., `feat: add new feature`, `fix: resolve bug`)
- Update documentation when necessary
### What to Avoid
❌ **Pull Requests That Cannot Be Accepted:**
- **Too many changes** - makes review difficult
- **Breaking changes** - disrupts existing functionality
- **New bugs or regressions** - reduces software quality
- **Unnecessary dependencies** - conflicts with minimalist approach
- **Performance degradation** - slows down the software
- **Poor-quality code** - hard to maintain
- **Dependent PRs** - creates review complexity
- **Radical UI changes** - disrupts user experience
- **Conflicts with philosophy** - doesn't align with minimalist approach
### Pull Request Template
When creating a pull request, please include:
- **Description:** What does this PR do?
- **Motivation:** Why is this change needed?
- **Testing:** How was this tested?
- **Breaking Changes:** Are there any breaking changes?
- **Related Issues:** Link to any related issues
## Code Style
- Follow Go conventions and best practices
- Use `gofmt` to format your Go code, and `jshint` for JavaScript
- Write clear, descriptive variable and function names
- Include comments for complex logic
- Keep functions small and focused
## Testing
### Unit Tests
- Write unit tests for new functions and methods
- Ensure tests are fast and don't require external dependencies
- Aim for good test coverage
### Integration Tests
- Add integration tests for new API endpoints
- Tests run against a real PostgreSQL database
- Ensure tests clean up after themselves
## Communication
- **Discussions:** Use GitHub Discussions for general questions and community interaction
- **Issues:** Use GitHub issues for bug reports and feature requests
- **Pull Requests:** Use PR comments for code-specific discussions
- **Philosophy Questions:** Refer to the FAQ for common questions about project direction
## Questions?
- Check the [FAQ](https://miniflux.app/faq.html) for common questions
- Review the [development documentation](https://miniflux.app/docs/development.html) and [internationalization guide](https://miniflux.app/docs/i18n.html)
- Look at existing issues and pull requests for examples
+184
View File
@@ -1,3 +1,187 @@
Version 2.2.11 (July 26, 2025)
------------------------------
### ✨ New Features
* **TLS support for Unix sockets**: Miniflux can now serve TLS over Unix domain sockets using `CERT_FILE` and `KEY_FILE` ([#fcf86e3](https://github.com/miniflux/v2/commit/fcf86e3)).
* **RSS fallback**: If a feed entry has no URL, Miniflux now uses the enclosure URL as a fallback ([#d9de9d1](https://github.com/miniflux/v2/commit/d9de9d1)).
* **Bearer token for Linkwarden**: The Linkwarden integration now uses Bearer token authorization instead of cookies ([#1d11623](https://github.com/miniflux/v2/commit/1d11623)).
* **Cookie policy improvement**: `SameSiteStrictMode` is enforced for cookies when OAuth2/OIDC is not used ([#135ce1d](https://github.com/miniflux/v2/commit/135ce1d)).
* **Readability engine**: Avoid removing elements with the `content` class during readability parsing ([#66b269e](https://github.com/miniflux/v2/commit/66b269e)).
### 🛠️ Improvements
* **Massive readability engine refactoring** and performance optimizations:
* Improved performance of `getClassWeight`, `getLinkDensity`, and `transformMisusedDivsIntoParagraphs`.
* Simplified and optimized internal logic of `removeUnlikelyCandidates`, `getSelectionLength`, and `getArticle`.
* Reduced memory allocation in sanitizer and readability components.
* **Storage optimization**: Strings are now truncated on the Go side to respect `tsvector` limits, reducing DB load and ensuring valid UTF-8 ([#703f113](https://github.com/miniflux/v2/commit/703f113)).
* **Simplified and clarified internal code structure**:
* Major cleanup and size optimization of internal structs (`Feed`, `FeedCreationRequest`, etc.).
* Reduced memory use and improved CPU cache locality.
* Numerous refactors across `config`, `template`, `locale`, `subscription`, and `fetcher` modules.
### 🐛 Bug Fixes
* Fixed an issue with feeds having excessive leading whitespace causing parser buffer issues ([#54abd0a](https://github.com/miniflux/v2/commit/54abd0a)).
* Properly preserve UTF-8 when truncating strings for full-text search ([#703f113](https://github.com/miniflux/v2/commit/703f113)).
* Fixed logic error in enclosure type detection ([#50d5cb9](https://github.com/miniflux/v2/commit/50d5cb9)).
* Fixed incorrect filter rule parsing of Windows-style newlines ([#dc81725](https://github.com/miniflux/v2/commit/dc81725)).
* Fixed a panic in `startAutoCertTLSServer` function when using Let's Encrypt automatic certificates ([#f7a6b02](https://github.com/miniflux/v2/commit/f7a6b02))
* Improved UI spacing consistency around header/footer ([#32fbb4e](https://github.com/miniflux/v2/commit/32fbb4e)).
### ⚠️ Breaking Changes
* **Windows binary no longer distributed**: Windows is no longer a supported platform for binary distribution. Users must build from source if needed ([#b470b18](https://github.com/miniflux/v2/commit/b470b18)).
### 🧪 Tests & CI
* Test coverage significantly increased for modules like `readability`, `sanitizer`, `processor`, `locale`, and `storage`.
* Commit linter updated to support new Git revert message format.
### 🐘 Docker & Environment
* Base Docker image updated to Alpine 3.22.
* PostgreSQL Docker example updated to use the latest version.
### 🌐 Localization
* Updated Chinese and German translations.
### 🔒 Dependency Updates
* Bumped `github.com/go-webauthn/webauthn` to `0.13.4`
* Bumped `github.com/tdewolff/minify/v2` to `2.23.10`
* Bumped `golang.org/x/*` modules: `image`, `net`, `term`, `crypto`
* Bumped `github.com/andybalholm/brotli` to `1.2.0`
Version 2.2.10 (June 23, 2025)
------------------------------
* test(sanitizer): add unit test for 0x0 pixel tracker
* test(sanitizer): add test case to cover Vimeo iframe rewrite without query string
* refactor(youtube): Remove a regex and make use of `fetchWatchTime`
* refactor(youtube): initialize two maps to the proper length
* refactor(tests): use `b.Loop()` instead of for range `b.N`
* refactor(server): avoid double call to `Sprintf`
* refactor(sanitizer): use global variables to avoid recreating slices on every call
* refactor(sanitizer): use a map for iframe allow list
* refactor(sanitizer): remove two useless `www.` prefixes
* refactor(sanitizer): make `isValidAttribute()` check O(1)
* refactor(rewrite): rename `Rewriter` function to `ApplyContentRewriteRules`
* refactor(processor): simplify Bilibili processing
* refactor(processor): remove a useless type declaration
* refactor(processor): remove a duplicated function call
* refactor(processor): refactor common code into a `fetchWatchTime` function
* refactor(processor): move filters to a `filter` package
* refactor(processor): move `FilterEntryMaxAgeDays` filter to filter package
* refactor(processor): move `RewriteEntryURL` function to `rewrite` package
* refactor(processor): minor simplification of a loop
* refactor(internal): add an `urllib.DomainWithoutWWW` function
* refactor(http): rename package from `httpd` to `server` for consistency
* refactor(http): Don't hardcode TLS configuration
* refactor(filter): avoid code duplication between `IsBlockedEntry` and `IsAllowedEntry` functions
* refactor(database): drop 3 columns in a single transaction
* refactor(crypto): use `rand.Text()` instead of a custom implementation
* refactor(config): remove deprecated config options
* refactor(appjs): no need to check if always present elements are always present
* perf(xml): optimized `NewXMLDecoder`
* perf(xml): optimize XML filtering
* perf(validator): slightly optimize a regex
* perf(timezone): cache `getLocation`'s results
* perf(storage): pre-allocate a slice in `RefreshFeedEntries`
* perf(storage): optimize away two `Sprintf` calls
* perf(sanitizer): use a switch-case instead of a map
* perf(sanitizer): minor simplifications of the sanitizer
* perf(sanitizer): extract a call to `url.Parse` and make intensive use of it
* perf(rss): optimize a bit `BuildFeed`
* perf(rss): early return when looking for an item's author
* perf(rewrite): make `getPredefinedRewriteRules` O(1)
* perf(reader): use a non-cryptographic hash when possible
* perf(reader): optimize `RemoveTrackingParameters`
* perf(readability): minor regex improvement
* perf(media): minor regex simplification
* perf(fetcher): pre-allocate the cipherSuites
* perf(database): use `TRUNCATE` instead of `DELETE FROM` in migrations
* perf(database): marginally speeds migrations up
* perf(api): use `math/rand/v2` instead of `math/rand` for better performance
* fix(readability): do not remove elements within code blocks
* fix(karakeep): correct method name and improve error handling in `SaveURL`
* fix(filter): skip invalid rules instead of exiting the loop
* feat(ui): display external link in single entry view because the URL was not visible on mobile (no mouse over)
* feat(ui): avoid showing an excessive number of tags
* feat(ui): add user setting to control `target="_blank"` on links
* feat(sanitizer): validate MathML XML namespace
* feat(sanitizer): consider images of size 0x0 as pixel trackers
* feat(sanitizer): add validation for empty `width` and `height` attributes in img tags
* feat(sanitizer): add support for `fetchpriority` and `decoding` attributes in img tags
* feat(rewrite): add support for YouTube Shorts video URL pattern
* feat(rewrite): add `parkablogs.com` to the referer override list
* feat(oidc): use `preferred_username` first instead of `email` claim
* feat(locale): update Polish translations
* feat(locale): update locales using machine translation
* feat(locale): update Indonesian translations
* feat(locale): update German translations
* feat(locale): update Chinese translations
* feat(integration)!: remove Pocket integration (Pocket will no longer be available after July 8, 2025)
* feat(filter): add `EntryDate=max-age:duration` filter
* feat(css): add margin-bottom to input for consistent spacing
* feat(config)!: remove `SERVER_TIMING_HEADER` config option
* feat: Allow multiple listen addresses
* feat: adding support for saving entries to Karakeep
* feat: add entry filters at the feed level
* docs(readme): document a couple of nifty features
* docs: add `CONTRIBUTING.md` file
* chore(template): remove `X-UA-Compatible` meta tag specific to Internet Explorer
* build(go): bump to go 1.24
* build(deps): bump `library/alpine` in `/packaging/docker/alpine`
* build(deps): bump `golang.org/x/net` from `0.40.0` to `0.41.0`
* build(deps): bump `golang.org/x/image` from `0.27.0` to `0.28.0`
* build(deps): bump `golang.org/x/crypto` from `0.38.0` to `0.39.0`
Version 2.2.9 (May 26, 2025)
----------------------------
* refactor(googlereader): remove redundant log message
* refactor(googlereader): move constants to separate files
* fix(webauthn): correct argument in debug log
* fix(sanitizer): MathML tags are not fully supported by `golang.org/x/net/html`
* fix(migrations): prevent failure at version 45 with long entry URLs
* fix(locale): localize Git commit label in about page
* fix(googlereader): return a 400 instead of 500 for invalid edit requests
* fix(googlereader): handle various item ID formats
* fix(googlereader): avoid panic for inexisting feed or category
* fix(googlereader): `/items/contents` should accept short form item IDs
* feat(webauthn): prefer creation of a client-side discoverable credential
* feat(urlcleaner): remove the `ref` parameter from url
* feat(settings): replace `div.panel` with paragraph tags for OAuth2 links
* feat(settings): add validation for entry order and categories sorting order
* feat(settings): add option to always open articles externally
* feat(server): add liveness and readiness probes
* feat(sanitizer): add MathML tags to the sanitizer
* feat(sanitized): allow Spotify iframes
* feat(rssbridge): support authentication token for RSS-Bridge
* feat(response): change error response content type to plain text and escape HTML
* feat(reader): populate feed description automatically
* feat(locale): update Russian translation
* feat(locale): update Polish translation
* feat(locale): update French translation
* feat(googlereader): avoid SQL query to fetch username in streamItemContentsHandler
* feat(googlereader): add `mark-all-as-read` endpoint
* feat(api): add new endpoints to manage API keys
* ci: remove deprecated `reviewers` field from `dependantbot.yml`
* chore(gitignore): ignore miniflux binary in root directory
* build(deps): bump `golangci/golangci-lint-action` from `7` to `8`
* build(deps): bump `golang.org/x/oauth2` from `0.29.0` to `0.30.0`
* build(deps): bump `golang.org/x/net` from `0.39.0` to `0.40.0`
* build(deps): bump `golang.org/x/image` from `0.26.0` to `0.27.0`
* build(deps): bump `golang.org/x/crypto` from `0.37.0` to `0.38.0`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.23.3` to `2.23.8`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.23.1` to `2.23.3`
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.12.3` to `0.13.0`
Version 2.2.8 (April 22, 2025)
------------------------------
+2 -31
View File
@@ -22,16 +22,12 @@ export PGPASSWORD := postgres
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
freebsd-x86 \
openbsd-amd64 \
openbsd-x86 \
netbsd-x86 \
netbsd-amd64 \
windows-amd64 \
windows-x86 \
build \
run \
clean \
add-string \
test \
lint \
integration-test \
@@ -85,30 +81,7 @@ openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
@ sha256sum $(APP)-$@.exe > $(APP)-$@.exe.sha256
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
# NOTE: unsupported targets
netbsd-amd64:
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
linux-x86:
@ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
freebsd-x86:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
netbsd-x86:
@ CGO_ENABLED=0 GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
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)-$@.exe main.go
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64
run:
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
@@ -116,7 +89,6 @@ run:
clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
.PHONY: add-string
add-string:
cd internal/locale/translations && \
for file in *.json; do \
@@ -125,7 +97,6 @@ add-string:
mv tmp "$$file"; \
done
test:
go test -cover -race -count=1 ./...
+8 -4
View File
@@ -22,7 +22,7 @@ Features
- Provides full-text search (powered by Postgres).
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
### Privacy
### Privacy and Security
- Removes pixel trackers.
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
@@ -33,6 +33,8 @@ Features
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
- Blocks external JavaScript to prevent tracking and enhance security.
- Sanitizes external content before rendering it.
- Enforces a [Content Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) and a [Trusted Types Policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) to only application JavaScript and blocks inline scripts and styles.
### Bot Protection Bypass Mechanisms
@@ -70,7 +72,7 @@ Features
### Integrations
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pocket](https://getpocket.com/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
- Bookmarklet for subscribing to websites directly from any web browser.
- Webhooks for real-time notifications or custom integrations.
- Compatibility with existing mobile applications using the Fever or Google Reader API.
@@ -97,13 +99,15 @@ Features
- Allows the use of custom <abbr title="Secure Sockets Layer">SSL</abbr> certificates.
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
- Updates feeds in the background using an internal scheduler or a traditional cron job.
- Sanitizes external content before rendering it.
- Enforces a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that permits only application JavaScript and blocks inline scripts and styles.
- Uses native lazy loading for images and iframes.
- Compatible only with modern browsers.
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
- Provides official Debian/RPM packages and pre-built binaries.
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
- Uses a limited amount of third-party go dependencies
- Has a comprehensive testsuite, with both unit tests and integration tests.
- Only uses a couple of MB of memory and a negligible amount of CPU, even with several hundreds of feeds.
- Respects/sends Last-Modified, If-Modified-Since, If-None-Match, Cache-Control, Expires and ETags headers, and has a default polling interval of 1h.
Documentation
-------------
+39
View File
@@ -180,6 +180,45 @@ func (c *Client) DeleteUser(userID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID))
}
// APIKeys returns all API keys for the authenticated user.
func (c *Client) APIKeys() (APIKeys, error) {
body, err := c.request.Get("/v1/api-keys")
if err != nil {
return nil, err
}
defer body.Close()
var apiKeys APIKeys
if err := json.NewDecoder(body).Decode(&apiKeys); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKeys, nil
}
// CreateAPIKey creates a new API key for the authenticated user.
func (c *Client) CreateAPIKey(description string) (*APIKey, error) {
body, err := c.request.Post("/v1/api-keys", &APIKeyCreationRequest{
Description: description,
})
if err != nil {
return nil, err
}
defer body.Close()
var apiKey *APIKey
if err := json.NewDecoder(body).Decode(&apiKey); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return apiKey, nil
}
// DeleteAPIKey removes an API key for the authenticated user.
func (c *Client) DeleteAPIKey(apiKeyID int64) error {
return c.request.Delete(fmt.Sprintf("/v1/api-keys/%d", apiKeyID))
}
// MarkAllAsRead marks all unread entries as read for a given user.
func (c *Client) MarkAllAsRead(userID int64) error {
_, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil)
+87 -56
View File
@@ -17,35 +17,37 @@ const (
// User represents a user in the system.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
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"`
CustomJS string `json:"custom_js"`
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"`
LastLoginAt *time.Time `json:"last_login_at"`
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"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
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"`
CustomJS string `json:"custom_js"`
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"`
LastLoginAt *time.Time `json:"last_login_at"`
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"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
func (u User) String() string {
@@ -63,33 +65,35 @@ 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"`
CustomJS *string `json:"custom_js"`
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"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
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"`
CustomJS *string `json:"custom_js"`
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"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Users represents a list of users.
@@ -156,8 +160,11 @@ type Feed struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
@@ -184,8 +191,11 @@ type FeedCreationRequest struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
@@ -198,8 +208,11 @@ type FeedModificationRequest struct {
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
@@ -325,6 +338,24 @@ type VersionResponse struct {
OS string `json:"os"`
}
// APIKey represents an application API key.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API keys.
type APIKeys []*APIKey
// APIKeyCreationRequest represents the request to create an API key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
func SetOptionalField[T any](value T) *T {
return &value
}
+1 -1
View File
@@ -19,7 +19,7 @@ services:
# healthcheck:
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
db:
image: postgres:15
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
+1 -1
View File
@@ -25,7 +25,7 @@ services:
- ADMIN_PASSWORD=test123
- BASE_URL=https://miniflux.example.org
db:
image: postgres:15
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
+1 -1
View File
@@ -37,7 +37,7 @@ services:
- "traefik.http.routers.miniflux.entrypoints=websecure"
- "traefik.http.routers.miniflux.tls.certresolver=myresolver"
db:
image: postgres:15
image: postgres:latest
container_name: postgres
environment:
- POSTGRES_USER=miniflux
+17 -18
View File
@@ -1,35 +1,34 @@
module miniflux.app/v2
// +heroku goVersion go1.23
// +heroku goVersion go1.24
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/andybalholm/brotli v1.1.1
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/go-webauthn/webauthn v0.12.3
github.com/go-webauthn/webauthn v0.13.4
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.28
github.com/prometheus/client_golang v1.22.0
github.com/tdewolff/minify/v2 v2.23.1
golang.org/x/crypto v0.37.0
golang.org/x/image v0.26.0
golang.org/x/net v0.39.0
golang.org/x/oauth2 v0.29.0
golang.org/x/term v0.31.0
github.com/tdewolff/minify/v2 v2.23.10
golang.org/x/crypto v0.40.0
golang.org/x/image v0.29.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/term v0.33.0
)
require (
github.com/go-webauthn/x v0.1.20 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/go-webauthn/x v0.1.23 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/go-tpm v0.9.5 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -37,13 +36,13 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.23 // indirect
github.com/tdewolff/parse/v2 v2.8.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
)
go 1.23.0
go 1.24.0
toolchain go1.24.1
+30 -32
View File
@@ -1,7 +1,7 @@
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -12,21 +12,21 @@ github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOum
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
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.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -37,8 +37,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -55,10 +53,10 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.23.1 h1:r6sKQrumHzskWZRdhiRa+pZhn7CdBMojACNP9fuKpXQ=
github.com/tdewolff/minify/v2 v2.23.1/go.mod h1:RkUGjklq6uIsBoOdzY3ll35HKKQ2aFqLQhnanBHhDyU=
github.com/tdewolff/parse/v2 v2.7.23 h1:sCW2PNTCM1yVldh5YK/8wrpRI9rSbloUZWjAydlN2IA=
github.com/tdewolff/parse/v2 v2.7.23/go.mod h1:I7TXO37t3aSG9SlPUBefAhgIF8nt7yYUwVGgETIoBcA=
github.com/tdewolff/minify/v2 v2.23.10 h1:puzRCH00Im+KDf+PxuuSmJykMTVd8Pp1HzTCxVutNmI=
github.com/tdewolff/minify/v2 v2.23.10/go.mod h1:VW3ISUd3gDOZuQ/jwZr4sCzsuX+Qvsx87FDMjk6Rvno=
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -72,10 +70,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -90,10 +88,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -112,8 +110,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -123,8 +121,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -134,8 +132,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
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=
+3
View File
@@ -76,6 +76,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
sr.HandleFunc("/api-keys", handler.createAPIKey).Methods(http.MethodPost)
sr.HandleFunc("/api-keys", handler.getAPIKeys).Methods(http.MethodGet)
sr.HandleFunc("/api-keys/{apiKeyID}", handler.deleteAPIKey).Methods(http.MethodDelete)
}
func (h *handler) versionHandler(w http.ResponseWriter, r *http.Request) {
+111 -1
View File
@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"math/rand/v2"
"os"
"strings"
"testing"
@@ -729,6 +729,116 @@ func TestRegularUsersCannotUpdateOtherUsers(t *testing.T) {
}
}
func TestAPIKeysEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
apiKeys, err := regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys, got %d`, len(apiKeys))
}
// Create an API key for the user.
apiKey, err := regularUserClient.CreateAPIKey("Test API Key")
if err != nil {
t.Fatal(err)
}
if apiKey.ID == 0 {
t.Fatalf(`Invalid API key ID, got "%v"`, apiKey.ID)
}
if apiKey.UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKey.UserID, regularTestUser.ID)
}
if apiKey.Token == "" {
t.Fatalf(`Invalid API key token, got "%v"`, apiKey.Token)
}
if apiKey.Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKey.Description)
}
// Create a duplicate API key with the same description.
if _, err := regularUserClient.CreateAPIKey("Test API Key"); err == nil {
t.Fatal(`Creating a duplicate API key with the same description should raise an error`)
}
// Fetch the API keys again.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 1 {
t.Fatalf(`Expected 1 API key, got %d`, len(apiKeys))
}
if apiKeys[0].ID != apiKey.ID {
t.Fatalf(`Invalid API key ID, got "%v" instead of "%v"`, apiKeys[0].ID, apiKey.ID)
}
if apiKeys[0].UserID != regularTestUser.ID {
t.Fatalf(`Invalid user ID for API key, got "%v" instead of "%v"`, apiKeys[0].UserID, regularTestUser.ID)
}
if apiKeys[0].Token != apiKey.Token {
t.Fatalf(`Invalid API key token, got "%v" instead of "%v"`, apiKeys[0].Token, apiKey.Token)
}
if apiKeys[0].Description != "Test API Key" {
t.Fatalf(`Invalid API key description, got "%v" instead of "Test API Key"`, apiKeys[0].Description)
}
// Create a new client using the API key.
apiKeyClient := miniflux.NewClient(testConfig.testBaseURL, apiKey.Token)
// Fetch the user using the API key client.
user, err := apiKeyClient.Me()
if err != nil {
t.Fatal(err)
}
// Verify the user matches the regular test user.
if user.ID != regularTestUser.ID {
t.Fatalf(`Expected user ID %d, got %d`, regularTestUser.ID, user.ID)
}
// Delete the API key.
if err := regularUserClient.DeleteAPIKey(apiKey.ID); err != nil {
t.Fatal(err)
}
// Verify the API key is deleted.
apiKeys, err = regularUserClient.APIKeys()
if err != nil {
t.Fatal(err)
}
if len(apiKeys) != 0 {
t.Fatalf(`Expected no API keys after deletion, got %d`, len(apiKeys))
}
// Try to delete the API key again, it should return an error.
err = regularUserClient.DeleteAPIKey(apiKey.ID)
if err == nil {
t.Fatal(`Deleting a non-existent API key should raise an error`)
}
if !errors.Is(err, miniflux.ErrNotFound) {
t.Fatalf(`Expected "not found" error, got %v`, err)
}
// Try to create an API key with an empty description.
if _, err := regularUserClient.CreateAPIKey(""); err == nil {
t.Fatal(`Creating an API key with an empty description should raise an error`)
}
}
func TestMarkUserAsReadEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package api // import "miniflux.app/v2/internal/api"
import (
json_parser "encoding/json"
"errors"
"net/http"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
func (h *handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
var apiKeyCreationRequest model.APIKeyCreationRequest
if err := json_parser.NewDecoder(r.Body).Decode(&apiKeyCreationRequest); err != nil {
json.BadRequest(w, r, err)
return
}
if validationErr := validator.ValidateAPIKeyCreation(h.store, userID, &apiKeyCreationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
}
apiKey, err := h.store.CreateAPIKey(userID, apiKeyCreationRequest.Description)
if err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, apiKey)
}
func (h *handler) getAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeys, err := h.store.APIKeys(userID)
if err != nil {
json.ServerError(w, r, err)
return
}
json.OK(w, r, apiKeys)
}
func (h *handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
apiKeyID := request.RouteInt64Param(r, "apiKeyID")
if err := h.store.DeleteAPIKey(userID, apiKeyID); err != nil {
if errors.Is(err, storage.ErrAPIKeyNotFound) {
json.NotFound(w, r)
return
}
json.ServerError(w, r, err)
return
}
json.NoContent(w, r)
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
json_parser "encoding/json"
"net/http"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/model"
@@ -33,7 +34,7 @@ func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
return
}
enclosure.ProxifyEnclosureURL(h.router)
enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, enclosure)
}
+2 -2
View File
@@ -10,6 +10,7 @@ import (
"strconv"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
@@ -34,8 +35,7 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
json.OK(w, r, entry)
}
+3
View File
@@ -30,9 +30,11 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
}
var rssbridgeURL string
var rssbridgeToken string
intg, err := h.store.Integration(request.UserID(r))
if err == nil && intg != nil && intg.RSSBridgeEnabled {
rssbridgeURL = intg.RSSBridgeURL
rssbridgeToken = intg.RSSBridgeToken
}
requestBuilder := fetcher.NewRequestBuilder()
@@ -50,6 +52,7 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
subscriptionDiscoveryRequest.URL,
rssbridgeURL,
rssbridgeToken,
)
if localizedError != nil {
-14
View File
@@ -7,8 +7,6 @@ import (
json_parser "encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
@@ -84,18 +82,6 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
}
}
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
if userModificationRequest.BlockFilterEntryRules != nil {
*userModificationRequest.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.BlockFilterEntryRules, "")
// Clean carriage returns for Windows environments
*userModificationRequest.BlockFilterEntryRules = strings.ReplaceAll(*userModificationRequest.BlockFilterEntryRules, "\r\n", "\n")
}
if userModificationRequest.KeepFilterEntryRules != nil {
*userModificationRequest.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.KeepFilterEntryRules, "")
// Clean carriage returns for Windows environments
*userModificationRequest.KeepFilterEntryRules = strings.ReplaceAll(*userModificationRequest.KeepFilterEntryRules, "\r\n", "\n")
}
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error())
return
+15 -5
View File
@@ -13,7 +13,7 @@ import (
"time"
"miniflux.app/v2/internal/config"
httpd "miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/http/server"
"miniflux.app/v2/internal/metric"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/systemd"
@@ -33,9 +33,9 @@ func startDaemon(store *storage.Storage) {
runScheduler(store, pool)
}
var httpServer *http.Server
var httpServers []*http.Server
if config.Opts.HasHTTPService() {
httpServer = httpd.StartWebServer(store, pool)
httpServers = server.StartWebServer(store, pool)
}
if config.Opts.HasMetricsCollector() {
@@ -78,8 +78,18 @@ func startDaemon(store *storage.Storage) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if httpServer != nil {
httpServer.Shutdown(ctx)
if len(httpServers) > 0 {
slog.Debug("Shutting down HTTP servers...")
for _, server := range httpServers {
if server != nil {
if err := server.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", slog.Any("error", err), slog.String("addr", server.Addr))
}
}
}
slog.Debug("All HTTP servers shut down.")
} else {
slog.Debug("No HTTP servers to shut down.")
}
slog.Debug("Process gracefully stopped")
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func doHealthCheck(healthCheckEndpoint string) {
if healthCheckEndpoint == "auto" {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
healthCheckEndpoint = "http://" + config.Opts.ListenAddr()[0] + config.Opts.BasePath() + "/healthcheck"
}
slog.Debug("Executing health check request", slog.String("endpoint", healthCheckEndpoint))
+1 -1
View File
@@ -4,4 +4,4 @@
package config // import "miniflux.app/v2/internal/config"
// Opts holds parsed configuration options.
var Opts *Options
var Opts *options
+42 -227
View File
@@ -6,6 +6,7 @@ package config // import "miniflux.app/v2/internal/config"
import (
"bytes"
"os"
"reflect"
"testing"
)
@@ -184,35 +185,6 @@ func TestLogFormatWithInvalidValue(t *testing.T) {
}
}
func TestDebugModeOn(t *testing.T) {
os.Clearenv()
os.Setenv("DEBUG", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "debug" {
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
}
func TestDebugModeOff(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if opts.LogLevel() != "info" {
t.Fatalf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
}
func TestCustomBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org")
@@ -457,18 +429,18 @@ func TestListenAddr(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "foobar"
expected := []string{"foobar"}
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
if !reflect.DeepEqual(result, expected) {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %v instead of %v`, result, expected)
}
}
func TestListenAddrWithPortDefined(t *testing.T) {
os.Clearenv()
os.Setenv("PORT", "3000")
os.Setenv("LISTEN_ADDR", "foobar")
os.Setenv("LISTEN_ADDR", "foobar") // This should be overridden by PORT
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
@@ -476,11 +448,11 @@ func TestListenAddrWithPortDefined(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := ":3000"
expected := []string{":3000"}
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
if !reflect.DeepEqual(result, expected) {
t.Fatalf(`Unexpected LISTEN_ADDR value when PORT is set, got %v instead of %v`, result, expected)
}
}
@@ -493,11 +465,11 @@ func TestDefaultListenAddrValue(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultListenAddr
expected := []string{defaultListenAddr}
result := opts.ListenAddr()
if result != expected {
t.Fatalf(`Unexpected LISTEN_ADDR value, got %q instead of %q`, result, expected)
if !reflect.DeepEqual(result, expected) {
t.Fatalf(`Unexpected default LISTEN_ADDR value, got %v instead of %v`, result, expected)
}
}
@@ -1466,41 +1438,6 @@ func TestCreateAdmin(t *testing.T) {
}
}
func TestPocketConsumerKeyFromEnvVariable(t *testing.T) {
os.Clearenv()
os.Setenv("POCKET_CONSUMER_KEY", "something")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "something"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
}
func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "default"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Fatalf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
}
}
func TestMediaProxyMode(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
@@ -1687,147 +1624,6 @@ func TestMediaProxyPrivateKey(t *testing.T) {
}
}
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGES", "all")
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 PROXY_IMAGES 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 PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
expectedProxyOption := "all"
result := opts.MediaProxyMode()
if result != expectedProxyOption {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
}
}
func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_IMAGE_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_IMAGE_URL value, got %q instead of %q`, result, 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.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
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")
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 PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
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 := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
func TestHTTPSOff(t *testing.T) {
os.Clearenv()
@@ -1966,9 +1762,7 @@ func TestParseConfigFile(t *testing.T) {
content := []byte(`
# This is a comment
DEBUG = yes
POCKET_CONSUMER_KEY= >#1234
LOG_LEVEL = debug
Invalid text
`)
@@ -1991,13 +1785,7 @@ Invalid text
}
if opts.LogLevel() != "debug" {
t.Errorf(`Unexpected debug mode value, got %q`, opts.LogLevel())
}
expected := ">#1234"
result := opts.PocketConsumerKey("default")
if result != expected {
t.Errorf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
t.Errorf(`Unexpected log level value, got %q`, opts.LogLevel())
}
if err := tmpfile.Close(); err != nil {
@@ -2169,12 +1957,33 @@ func TestYouTubeApiKey(t *testing.T) {
}
}
func TestDefaultYouTubeEmbedUrl(t *testing.T) {
os.Clearenv()
opts, err := NewParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "https://www.youtube-nocookie.com/embed/"
result := opts.YouTubeEmbedUrlOverride()
if result != expected {
t.Fatalf(`Unexpected default value, got %v instead of %v`, result, expected)
}
expected = "www.youtube-nocookie.com"
result = opts.YouTubeEmbedDomain()
if result != expected {
t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
}
}
func TestYouTubeEmbedUrlOverride(t *testing.T) {
os.Clearenv()
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
opts, err := NewParser().ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
@@ -2185,6 +1994,12 @@ func TestYouTubeEmbedUrlOverride(t *testing.T) {
if result != expected {
t.Fatalf(`Unexpected YOUTUBE_EMBED_URL_OVERRIDE value, got %v instead of %v`, result, expected)
}
expected = "invidious.custom"
result = opts.YouTubeEmbedDomain()
if result != expected {
t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
}
}
func TestParseConfigDumpOutput(t *testing.T) {
+108 -125
View File
@@ -5,8 +5,9 @@ package config // import "miniflux.app/v2/internal/config"
import (
"fmt"
"maps"
"net/url"
"sort"
"slices"
"strings"
"time"
@@ -23,8 +24,6 @@ const (
defaultHSTS = true
defaultHTTPService = true
defaultSchedulerService = true
defaultDebug = false
defaultTiming = false
defaultBaseURL = "http://localhost"
defaultRootURL = "http://localhost"
defaultBasePath = ""
@@ -75,7 +74,6 @@ const (
defaultOauth2OidcProviderName = "OpenID Connect"
defaultOAuth2Provider = ""
defaultDisableLocalAuth = false
defaultPocketConsumerKey = ""
defaultHTTPClientTimeout = 20
defaultHTTPClientMaxBodySize = 15
defaultHTTPClientProxy = ""
@@ -96,14 +94,14 @@ const (
var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
// Option contains a key to value map of a single option. It may be used to output debug strings.
type Option struct {
// option contains a key to value map of a single option. It may be used to output debug strings.
type option struct {
Key string
Value interface{}
Value any
}
// Options contains configuration options.
type Options struct {
// options contains configuration options.
type options struct {
HTTPS bool
logFile string
logDateTime bool
@@ -112,7 +110,6 @@ type Options struct {
hsts bool
httpService bool
schedulerService bool
serverTimingHeader bool
baseURL string
rootURL string
basePath string
@@ -121,7 +118,7 @@ type Options struct {
databaseMinConns int
databaseConnectionLifetime int
runMigrations bool
listenAddr string
listenAddr []string
certFile string
certDomain string
certKeyFile string
@@ -155,6 +152,7 @@ type Options struct {
filterEntryMaxAgeDays int
youTubeApiKey string
youTubeEmbedUrlOverride string
youTubeEmbedDomain string
oauth2UserCreationAllowed bool
oauth2ClientID string
oauth2ClientSecret string
@@ -163,7 +161,6 @@ type Options struct {
oidcProviderName string
oauth2Provider string
disableLocalAuth bool
pocketConsumerKey string
httpClientTimeout int
httpClientMaxBodySize int64
httpClientProxyURL *url.URL
@@ -186,8 +183,8 @@ type Options struct {
}
// NewOptions returns Options with default values.
func NewOptions() *Options {
return &Options{
func NewOptions() *options {
return &options{
HTTPS: defaultHTTPS,
logFile: defaultLogFile,
logDateTime: defaultLogDateTime,
@@ -196,7 +193,6 @@ func NewOptions() *Options {
hsts: defaultHSTS,
httpService: defaultHTTPService,
schedulerService: defaultSchedulerService,
serverTimingHeader: defaultTiming,
baseURL: defaultBaseURL,
rootURL: defaultRootURL,
basePath: defaultBasePath,
@@ -205,7 +201,7 @@ func NewOptions() *Options {
databaseMinConns: defaultDatabaseMinConns,
databaseConnectionLifetime: defaultDatabaseConnectionLifetime,
runMigrations: defaultRunMigrations,
listenAddr: defaultListenAddr,
listenAddr: []string{defaultListenAddr},
certFile: defaultCertFile,
certDomain: defaultCertDomain,
certKeyFile: defaultKeyFile,
@@ -245,7 +241,6 @@ func NewOptions() *Options {
oidcProviderName: defaultOauth2OidcProviderName,
oauth2Provider: defaultOAuth2Provider,
disableLocalAuth: defaultDisableLocalAuth,
pocketConsumerKey: defaultPocketConsumerKey,
httpClientTimeout: defaultHTTPClientTimeout,
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
httpClientProxyURL: nil,
@@ -268,422 +263,417 @@ func NewOptions() *Options {
}
}
func (o *Options) LogFile() string {
func (o *options) LogFile() string {
return o.logFile
}
// LogDateTime returns true if the date/time should be displayed in log messages.
func (o *Options) LogDateTime() bool {
func (o *options) LogDateTime() bool {
return o.logDateTime
}
// LogFormat returns the log format.
func (o *Options) LogFormat() string {
func (o *options) LogFormat() string {
return o.logFormat
}
// LogLevel returns the log level.
func (o *Options) LogLevel() string {
func (o *options) LogLevel() string {
return o.logLevel
}
// SetLogLevel sets the log level.
func (o *Options) SetLogLevel(level string) {
func (o *options) SetLogLevel(level string) {
o.logLevel = level
}
// HasMaintenanceMode returns true if maintenance mode is enabled.
func (o *Options) HasMaintenanceMode() bool {
func (o *options) HasMaintenanceMode() bool {
return o.maintenanceMode
}
// MaintenanceMessage returns maintenance message.
func (o *Options) MaintenanceMessage() string {
func (o *options) MaintenanceMessage() string {
return o.maintenanceMessage
}
// HasServerTimingHeader returns true if server-timing headers enabled.
func (o *Options) HasServerTimingHeader() bool {
return o.serverTimingHeader
}
// BaseURL returns the application base URL with path.
func (o *Options) BaseURL() string {
func (o *options) BaseURL() string {
return o.baseURL
}
// RootURL returns the base URL without path.
func (o *Options) RootURL() string {
func (o *options) RootURL() string {
return o.rootURL
}
// BasePath returns the application base path according to the base URL.
func (o *Options) BasePath() string {
func (o *options) BasePath() string {
return o.basePath
}
// IsDefaultDatabaseURL returns true if the default database URL is used.
func (o *Options) IsDefaultDatabaseURL() bool {
func (o *options) IsDefaultDatabaseURL() bool {
return o.databaseURL == defaultDatabaseURL
}
// DatabaseURL returns the database URL.
func (o *Options) DatabaseURL() string {
func (o *options) DatabaseURL() string {
return o.databaseURL
}
// DatabaseMaxConns returns the maximum number of database connections.
func (o *Options) DatabaseMaxConns() int {
func (o *options) DatabaseMaxConns() int {
return o.databaseMaxConns
}
// DatabaseMinConns returns the minimum number of database connections.
func (o *Options) DatabaseMinConns() int {
func (o *options) DatabaseMinConns() int {
return o.databaseMinConns
}
// DatabaseConnectionLifetime returns the maximum amount of time a connection may be reused.
func (o *Options) DatabaseConnectionLifetime() time.Duration {
func (o *options) DatabaseConnectionLifetime() time.Duration {
return time.Duration(o.databaseConnectionLifetime) * time.Minute
}
// ListenAddr returns the listen address for the HTTP server.
func (o *Options) ListenAddr() string {
func (o *options) ListenAddr() []string {
return o.listenAddr
}
// CertFile returns the SSL certificate filename if any.
func (o *Options) CertFile() string {
func (o *options) CertFile() string {
return o.certFile
}
// CertKeyFile returns the private key filename for custom SSL certificate.
func (o *Options) CertKeyFile() string {
func (o *options) CertKeyFile() string {
return o.certKeyFile
}
// CertDomain returns the domain to use for Let's Encrypt certificate.
func (o *Options) CertDomain() string {
func (o *options) CertDomain() string {
return o.certDomain
}
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
func (o *Options) CleanupFrequencyHours() int {
func (o *options) CleanupFrequencyHours() int {
return o.cleanupFrequencyHours
}
// CleanupArchiveReadDays returns the number of days after which marking read items as removed.
func (o *Options) CleanupArchiveReadDays() int {
func (o *options) CleanupArchiveReadDays() int {
return o.cleanupArchiveReadDays
}
// CleanupArchiveUnreadDays returns the number of days after which marking unread items as removed.
func (o *Options) CleanupArchiveUnreadDays() int {
func (o *options) CleanupArchiveUnreadDays() int {
return o.cleanupArchiveUnreadDays
}
// CleanupArchiveBatchSize returns the number of entries to archive for each interval.
func (o *Options) CleanupArchiveBatchSize() int {
func (o *options) CleanupArchiveBatchSize() int {
return o.cleanupArchiveBatchSize
}
// CleanupRemoveSessionsDays returns the number of days after which to remove sessions.
func (o *Options) CleanupRemoveSessionsDays() int {
func (o *options) CleanupRemoveSessionsDays() int {
return o.cleanupRemoveSessionsDays
}
// WorkerPoolSize returns the number of background worker.
func (o *Options) WorkerPoolSize() int {
func (o *options) WorkerPoolSize() int {
return o.workerPoolSize
}
// PollingFrequency returns the interval to refresh feeds in the background.
func (o *Options) PollingFrequency() int {
func (o *options) PollingFrequency() int {
return o.pollingFrequency
}
// ForceRefreshInterval returns the force refresh interval
func (o *Options) ForceRefreshInterval() int {
func (o *options) ForceRefreshInterval() int {
return o.forceRefreshInterval
}
// BatchSize returns the number of feeds to send for background processing.
func (o *Options) BatchSize() int {
func (o *options) BatchSize() int {
return o.batchSize
}
// PollingScheduler returns the scheduler used for polling feeds.
func (o *Options) PollingScheduler() string {
func (o *options) PollingScheduler() string {
return o.pollingScheduler
}
// SchedulerEntryFrequencyMaxInterval returns the maximum interval in minutes for the entry frequency scheduler.
func (o *Options) SchedulerEntryFrequencyMaxInterval() int {
func (o *options) SchedulerEntryFrequencyMaxInterval() int {
return o.schedulerEntryFrequencyMaxInterval
}
// SchedulerEntryFrequencyMinInterval returns the minimum interval in minutes for the entry frequency scheduler.
func (o *Options) SchedulerEntryFrequencyMinInterval() int {
func (o *options) SchedulerEntryFrequencyMinInterval() int {
return o.schedulerEntryFrequencyMinInterval
}
// SchedulerEntryFrequencyFactor returns the factor for the entry frequency scheduler.
func (o *Options) SchedulerEntryFrequencyFactor() int {
func (o *options) SchedulerEntryFrequencyFactor() int {
return o.schedulerEntryFrequencyFactor
}
func (o *Options) SchedulerRoundRobinMinInterval() int {
func (o *options) SchedulerRoundRobinMinInterval() int {
return o.schedulerRoundRobinMinInterval
}
func (o *Options) SchedulerRoundRobinMaxInterval() int {
func (o *options) SchedulerRoundRobinMaxInterval() int {
return o.schedulerRoundRobinMaxInterval
}
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
func (o *Options) PollingParsingErrorLimit() int {
func (o *options) PollingParsingErrorLimit() int {
return o.pollingParsingErrorLimit
}
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
func (o *Options) IsOAuth2UserCreationAllowed() bool {
func (o *options) IsOAuth2UserCreationAllowed() bool {
return o.oauth2UserCreationAllowed
}
// OAuth2ClientID returns the OAuth2 Client ID.
func (o *Options) OAuth2ClientID() string {
func (o *options) OAuth2ClientID() string {
return o.oauth2ClientID
}
// OAuth2ClientSecret returns the OAuth2 client secret.
func (o *Options) OAuth2ClientSecret() string {
func (o *options) OAuth2ClientSecret() string {
return o.oauth2ClientSecret
}
// OAuth2RedirectURL returns the OAuth2 redirect URL.
func (o *Options) OAuth2RedirectURL() string {
func (o *options) OAuth2RedirectURL() string {
return o.oauth2RedirectURL
}
// OIDCDiscoveryEndpoint returns the OAuth2 OIDC discovery endpoint.
func (o *Options) OIDCDiscoveryEndpoint() string {
func (o *options) OIDCDiscoveryEndpoint() string {
return o.oidcDiscoveryEndpoint
}
// OIDCProviderName returns the OAuth2 OIDC provider's display name
func (o *Options) OIDCProviderName() string {
func (o *options) OIDCProviderName() string {
return o.oidcProviderName
}
// OAuth2Provider returns the name of the OAuth2 provider configured.
func (o *Options) OAuth2Provider() string {
func (o *options) OAuth2Provider() string {
return o.oauth2Provider
}
// DisableLocalAUth returns true if the local user database should not be used to authenticate users
func (o *Options) DisableLocalAuth() bool {
func (o *options) DisableLocalAuth() bool {
return o.disableLocalAuth
}
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (o *Options) HasHSTS() bool {
func (o *options) HasHSTS() bool {
return o.hsts
}
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
func (o *Options) RunMigrations() bool {
func (o *options) RunMigrations() bool {
return o.runMigrations
}
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
func (o *Options) CreateAdmin() bool {
func (o *options) CreateAdmin() bool {
return o.createAdmin
}
// AdminUsername returns the admin username if defined.
func (o *Options) AdminUsername() string {
func (o *options) AdminUsername() string {
return o.adminUsername
}
// AdminPassword returns the admin password if defined.
func (o *Options) AdminPassword() string {
func (o *options) AdminPassword() string {
return o.adminPassword
}
// FetchYouTubeWatchTime returns true if the YouTube video duration
// should be fetched and used as a reading time.
func (o *Options) FetchYouTubeWatchTime() bool {
func (o *options) FetchYouTubeWatchTime() bool {
return o.fetchYouTubeWatchTime
}
// YouTubeApiKey returns the YouTube API key if defined.
func (o *Options) YouTubeApiKey() string {
func (o *options) YouTubeApiKey() string {
return o.youTubeApiKey
}
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
func (o *Options) YouTubeEmbedUrlOverride() string {
// YouTubeEmbedUrlOverride returns the YouTube embed URL override if defined.
func (o *options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// YouTubeEmbedDomain returns the domain used for YouTube embeds.
func (o *options) YouTubeEmbedDomain() string {
if o.youTubeEmbedDomain != "" {
return o.youTubeEmbedDomain
}
return "www.youtube-nocookie.com"
}
// FetchNebulaWatchTime returns true if the Nebula video duration
// should be fetched and used as a reading time.
func (o *Options) FetchNebulaWatchTime() bool {
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 {
func (o *options) FetchOdyseeWatchTime() bool {
return o.fetchOdyseeWatchTime
}
// FetchBilibiliWatchTime returns true if the Bilibili video duration
// should be fetched and used as a reading time.
func (o *Options) FetchBilibiliWatchTime() bool {
func (o *options) FetchBilibiliWatchTime() bool {
return o.fetchBilibiliWatchTime
}
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) MediaProxyMode() string {
func (o *options) MediaProxyMode() string {
return o.mediaProxyMode
}
// MediaProxyResourceTypes returns a slice of resource types to proxy.
func (o *Options) MediaProxyResourceTypes() []string {
func (o *options) MediaProxyResourceTypes() []string {
return o.mediaProxyResourceTypes
}
// MediaCustomProxyURL returns the custom proxy URL for medias.
func (o *Options) MediaCustomProxyURL() string {
func (o *options) MediaCustomProxyURL() string {
return o.mediaProxyCustomURL
}
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) MediaProxyHTTPClientTimeout() int {
func (o *options) MediaProxyHTTPClientTimeout() int {
return o.mediaProxyHTTPClientTimeout
}
// MediaProxyPrivateKey returns the private key used by the media proxy.
func (o *Options) MediaProxyPrivateKey() []byte {
func (o *options) MediaProxyPrivateKey() []byte {
return o.mediaProxyPrivateKey
}
// HasHTTPService returns true if the HTTP service is enabled.
func (o *Options) HasHTTPService() bool {
func (o *options) HasHTTPService() bool {
return o.httpService
}
// HasSchedulerService returns true if the scheduler service is enabled.
func (o *Options) HasSchedulerService() bool {
func (o *options) HasSchedulerService() bool {
return o.schedulerService
}
// PocketConsumerKey returns the Pocket Consumer Key if configured.
func (o *Options) PocketConsumerKey(defaultValue string) string {
if o.pocketConsumerKey != "" {
return o.pocketConsumerKey
}
return defaultValue
}
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
func (o *Options) HTTPClientTimeout() int {
func (o *options) HTTPClientTimeout() int {
return o.httpClientTimeout
}
// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
func (o *Options) HTTPClientMaxBodySize() int64 {
func (o *options) HTTPClientMaxBodySize() int64 {
return o.httpClientMaxBodySize
}
// HTTPClientProxyURL returns the client HTTP proxy URL if configured.
func (o *Options) HTTPClientProxyURL() *url.URL {
func (o *options) HTTPClientProxyURL() *url.URL {
return o.httpClientProxyURL
}
// HasHTTPClientProxyURLConfigured returns true if the client HTTP proxy URL if configured.
func (o *Options) HasHTTPClientProxyURLConfigured() bool {
func (o *options) HasHTTPClientProxyURLConfigured() bool {
return o.httpClientProxyURL != nil
}
// HTTPClientProxies returns the list of proxies.
func (o *Options) HTTPClientProxies() []string {
func (o *options) HTTPClientProxies() []string {
return o.httpClientProxies
}
// HTTPClientProxiesString returns true if the list of rotating proxies are configured.
func (o *Options) HasHTTPClientProxiesConfigured() bool {
func (o *options) HasHTTPClientProxiesConfigured() bool {
return len(o.httpClientProxies) > 0
}
// HTTPServerTimeout returns the time limit in seconds before the HTTP server cancel the request.
func (o *Options) HTTPServerTimeout() int {
func (o *options) HTTPServerTimeout() int {
return o.httpServerTimeout
}
// AuthProxyHeader returns an HTTP header name that contains username for
// authentication using auth proxy.
func (o *Options) AuthProxyHeader() string {
func (o *options) AuthProxyHeader() string {
return o.authProxyHeader
}
// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
// users authenticated using auth proxy.
func (o *Options) IsAuthProxyUserCreationAllowed() bool {
func (o *options) IsAuthProxyUserCreationAllowed() bool {
return o.authProxyUserCreation
}
// HasMetricsCollector returns true if metrics collection is enabled.
func (o *Options) HasMetricsCollector() bool {
func (o *options) HasMetricsCollector() bool {
return o.metricsCollector
}
// MetricsRefreshInterval returns the refresh interval in seconds.
func (o *Options) MetricsRefreshInterval() int {
func (o *options) MetricsRefreshInterval() int {
return o.metricsRefreshInterval
}
// MetricsAllowedNetworks returns the list of networks allowed to connect to the metrics endpoint.
func (o *Options) MetricsAllowedNetworks() []string {
func (o *options) MetricsAllowedNetworks() []string {
return o.metricsAllowedNetworks
}
func (o *Options) MetricsUsername() string {
func (o *options) MetricsUsername() string {
return o.metricsUsername
}
func (o *Options) MetricsPassword() string {
func (o *options) MetricsPassword() string {
return o.metricsPassword
}
// HTTPClientUserAgent returns the global User-Agent header for miniflux.
func (o *Options) HTTPClientUserAgent() string {
func (o *options) HTTPClientUserAgent() string {
return o.httpClientUserAgent
}
// HasWatchdog returns true if the systemd watchdog is enabled.
func (o *Options) HasWatchdog() bool {
func (o *options) HasWatchdog() bool {
return o.watchdog
}
// InvidiousInstance returns the invidious instance used by miniflux
func (o *Options) InvidiousInstance() string {
func (o *options) InvidiousInstance() string {
return o.invidiousInstance
}
// WebAuthn returns true if WebAuthn logins are supported
func (o *Options) WebAuthn() bool {
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 {
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 {
func (o *options) SortedOptions(redactSecret bool) []*option {
var clientProxyURLRedacted string
if o.httpClientProxyURL != nil {
if redactSecret {
@@ -749,7 +739,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"HTTP_SERVICE": o.httpService,
"INVIDIOUS_INSTANCE": o.invidiousInstance,
"KEY_FILE": o.certKeyFile,
"LISTEN_ADDR": o.listenAddr,
"LISTEN_ADDR": strings.Join(o.listenAddr, ","),
"LOG_FILE": o.logFile,
"LOG_DATE_TIME": o.logDateTime,
"LOG_FORMAT": o.logFormat,
@@ -769,7 +759,6 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
"POLLING_FREQUENCY": o.pollingFrequency,
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
@@ -787,7 +776,6 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": o.schedulerRoundRobinMinInterval,
"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": o.schedulerRoundRobinMaxInterval,
"SCHEDULER_SERVICE": o.schedulerService,
"SERVER_TIMING_HEADER": o.serverTimingHeader,
"WATCHDOG": o.watchdog,
"WORKER_POOL_SIZE": o.workerPoolSize,
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
@@ -795,20 +783,15 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"WEBAUTHN": o.webAuthn,
}
keys := make([]string, 0, len(keyValues))
for key := range keyValues {
keys = append(keys, key)
}
sort.Strings(keys)
var sortedOptions []*Option
for _, key := range keys {
sortedOptions = append(sortedOptions, &Option{Key: key, Value: keyValues[key]})
sortedKeys := slices.Sorted(maps.Keys(keyValues))
var sortedOptions = make([]*option, 0, len(sortedKeys))
for _, key := range sortedKeys {
sortedOptions = append(sortedOptions, &option{Key: key, Value: keyValues[key]})
}
return sortedOptions
}
func (o *Options) String() string {
func (o *options) String() string {
var builder strings.Builder
for _, option := range o.SortedOptions(false) {
+32 -64
View File
@@ -10,27 +10,26 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
)
// Parser handles configuration parsing.
type Parser struct {
opts *Options
// parser handles configuration parsing.
type parser struct {
opts *options
}
// NewParser returns a new Parser.
func NewParser() *Parser {
return &Parser{
func NewParser() *parser {
return &parser{
opts: NewOptions(),
}
}
// ParseEnvironmentVariables loads configuration values from environment variables.
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
func (p *parser) ParseEnvironmentVariables() (*options, error) {
err := p.parseLines(os.Environ())
if err != nil {
return nil, err
@@ -39,7 +38,7 @@ func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
}
// ParseFile loads configuration values from a local file.
func (p *Parser) ParseFile(filename string) (*Options, error) {
func (p *parser) ParseFile(filename string) (*options, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
@@ -53,7 +52,7 @@ func (p *Parser) ParseFile(filename string) (*Options, error) {
return p.opts, nil
}
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
func (p *parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -64,13 +63,15 @@ func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
return lines
}
func (p *Parser) parseLines(lines []string) (err error) {
func (p *parser) parseLines(lines []string) (err error) {
var port string
for _, line := range lines {
fields := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
for lineNum, line := range lines {
key, value, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "LOG_FILE":
@@ -87,14 +88,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
if parsedValue == "json" || parsedValue == "text" {
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"
}
case "SERVER_TIMING_HEADER":
p.opts.serverTimingHeader = parseBool(value, defaultTiming)
case "BASE_URL":
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
if err != nil {
@@ -103,7 +96,7 @@ func (p *Parser) parseLines(lines []string) (err error) {
case "PORT":
port = value
case "LISTEN_ADDR":
p.opts.listenAddr = parseString(value, defaultListenAddr)
p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
case "DATABASE_URL":
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
case "DATABASE_URL_FILE":
@@ -164,37 +157,12 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
case "PROXY_IMAGES":
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":
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":
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":
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":
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":
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)
if _, err := rand.Read(randomKey); err != nil {
return fmt.Errorf("config: unable to generate random key: %w", err)
}
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
if _, err := rand.Read(randomKey); err != nil {
@@ -213,10 +181,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.adminPassword = parseString(value, defaultAdminPassword)
case "ADMIN_PASSWORD_FILE":
p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
case "POCKET_CONSUMER_KEY":
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
case "POCKET_CONSUMER_KEY_FILE":
p.opts.pocketConsumerKey = readSecretFile(value, defaultPocketConsumerKey)
case "OAUTH2_USER_CREATION":
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
case "OAUTH2_CLIENT_ID":
@@ -296,8 +260,15 @@ func (p *Parser) parseLines(lines []string) (err error) {
}
if port != "" {
p.opts.listenAddr = ":" + port
p.opts.listenAddr = []string{":" + port}
}
youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
if err != nil {
return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
}
p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
return nil
}
@@ -306,9 +277,7 @@ func parseBaseURL(value string) (string, string, string, error) {
return defaultBaseURL, defaultRootURL, "", nil
}
if value[len(value)-1:] == "/" {
value = value[:len(value)-1]
}
value = strings.TrimSuffix(value, "/")
parsedURL, err := url.Parse(value)
if err != nil {
@@ -364,15 +333,14 @@ func parseStringList(value string, fallback []string) []string {
}
var strList []string
strMap := make(map[string]bool)
present := make(map[string]bool)
items := strings.Split(value, ",")
for _, item := range items {
itemValue := strings.TrimSpace(item)
if _, found := strMap[itemValue]; !found {
strMap[itemValue] = true
strList = append(strList, itemValue)
for item := range strings.SplitSeq(value, ",") {
if itemValue := strings.TrimSpace(item); itemValue != "" {
if !present[itemValue] {
present[itemValue] = true
strList = append(strList, itemValue)
}
}
}
+106
View File
@@ -4,6 +4,7 @@
package config // import "miniflux.app/v2/internal/config"
import (
"reflect"
"testing"
)
@@ -58,3 +59,108 @@ func TestParseIntValue(t *testing.T) {
t.Errorf(`Defined variables should returns the specified value`)
}
}
func TestParseListenAddr(t *testing.T) {
defaultExpected := []string{defaultListenAddr}
tests := []struct {
name string
listenAddr string
port string
expected []string
lines []string // Used for direct lines parsing instead of individual env vars
isLineOriented bool // Flag to indicate if we use lines
}{
{
name: "Single LISTEN_ADDR",
listenAddr: "127.0.0.1:8080",
expected: []string{"127.0.0.1:8080"},
},
{
name: "Multiple LISTEN_ADDR comma-separated",
listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
expected: []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
},
{
name: "Multiple LISTEN_ADDR with spaces around commas",
listenAddr: "127.0.0.1:8080 , :8081",
expected: []string{"127.0.0.1:8080", ":8081"},
},
{
name: "Empty LISTEN_ADDR",
listenAddr: "",
expected: defaultExpected,
},
{
name: "PORT overrides LISTEN_ADDR",
listenAddr: "127.0.0.1:8000",
port: "8082",
expected: []string{":8082"},
},
{
name: "PORT overrides empty LISTEN_ADDR",
listenAddr: "",
port: "8083",
expected: []string{":8083"},
},
{
name: "LISTEN_ADDR with empty segment (comma)",
listenAddr: "127.0.0.1:8080,,:8081",
expected: []string{"127.0.0.1:8080", ":8081"},
},
{
name: "PORT override with lines parsing",
isLineOriented: true,
lines: []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
expected: []string{":8082"},
},
{
name: "LISTEN_ADDR only with lines parsing (comma)",
isLineOriented: true,
lines: []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
expected: []string{"10.0.0.1:9090", "10.0.0.2:9091"},
},
{
name: "Empty LISTEN_ADDR with lines parsing (default)",
isLineOriented: true,
lines: []string{"LISTEN_ADDR="},
expected: defaultExpected,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
var err error
if tt.isLineOriented {
err = parser.parseLines(tt.lines)
} else {
// Simulate os.Environ() behaviour for individual var testing
var envLines []string
if tt.listenAddr != "" {
envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
}
if tt.port != "" {
envLines = append(envLines, "PORT="+tt.port)
}
// Add a dummy var if both are empty to avoid empty lines slice if not intended
if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
// This case specifically tests empty LISTEN_ADDR resulting in default
// So, we pass LISTEN_ADDR=
envLines = append(envLines, "LISTEN_ADDR=")
}
err = parser.parseLines(envLines)
}
if err != nil {
t.Fatalf("parseLines() error = %v", err)
}
opts := parser.opts
if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
}
})
}
}
+10 -15
View File
@@ -8,38 +8,33 @@ import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"hash/fnv"
"golang.org/x/crypto/bcrypt"
)
// HashFromBytes returns a SHA-256 checksum of the input.
// HashFromBytes returns a non-cryptographic checksum of the input.
func HashFromBytes(value []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(value))
h := fnv.New128a()
h.Write(value)
return hex.EncodeToString(h.Sum(nil))
}
// Hash returns a SHA-256 checksum of a string.
func Hash(value string) string {
return HashFromBytes([]byte(value))
// SHA256 returns a SHA-256 checksum of a string.
func SHA256(value string) string {
h := sha256.Sum256([]byte(value))
return hex.EncodeToString(h[:])
}
// GenerateRandomBytes returns random bytes.
func GenerateRandomBytes(size int) []byte {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
panic(err)
}
rand.Read(b)
return b
}
// GenerateRandomString returns a random string.
func GenerateRandomString(size int) string {
return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
}
// GenerateRandomStringHex returns a random hexadecimal string.
func GenerateRandomStringHex(size int) string {
return hex.EncodeToString(GenerateRandomBytes(size))
+2 -4
View File
@@ -14,11 +14,9 @@ func Migrate(db *sql.DB) error {
var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
driver := getDriverStr()
slog.Info("Running database migrations",
slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion),
slog.String("driver", driver),
)
for version := currentVersion; version < schemaVersion; version++ {
@@ -29,12 +27,12 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if err := migrations[version](tx, driver); err != nil {
if err := migrations[version](tx); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
if _, err := tx.Exec(`DELETE FROM schema_version`); err != nil {
if _, err := tx.Exec(`TRUNCATE schema_version`); err != nil {
tx.Rollback()
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
}
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,5 +1,3 @@
//go:build !sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
@@ -25,7 +23,3 @@ func NewConnectionPool(dsn string, minConnections, maxConnections int, connectio
return db, nil
}
func getDriverStr() string {
return "postgresql"
}
-26
View File
@@ -1,26 +0,0 @@
//go:build sqlite
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package database // import "miniflux.app/v2/internal/database"
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
return db, nil
}
func getDriverStr() string {
return "sqlite3"
}
+184 -355
View File
@@ -9,7 +9,6 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/config"
@@ -35,178 +34,12 @@ type handler struct {
router *mux.Router
}
const (
// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
StreamPrefix = "user/-/state/com.google/"
// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
UserStreamPrefix = "user/%d/state/com.google/"
// LabelPrefix is the prefix for a label stream
LabelPrefix = "user/-/label/"
// UserLabelPrefix is the user specific prefix prefix for a label stream
UserLabelPrefix = "user/%d/label/"
// FeedPrefix is the prefix for a feed stream
FeedPrefix = "feed/"
// Read is the suffix for read stream
Read = "read"
// Starred is the suffix for starred stream
Starred = "starred"
// ReadingList is the suffix for reading list stream
ReadingList = "reading-list"
// KeptUnread is the suffix for kept unread stream
KeptUnread = "kept-unread"
// Broadcast is the suffix for broadcast stream
Broadcast = "broadcast"
// BroadcastFriends is the suffix for broadcast friends stream
BroadcastFriends = "broadcast-friends"
// Like is the suffix for like stream
Like = "like"
// EntryIDLong is the long entry id representation
EntryIDLong = "tag:google.com,2005:reader/item/%016x"
var (
errEmptyFeedTitle = errors.New("googlereader: empty feed title")
errFeedNotFound = errors.New("googlereader: feed not found")
errCategoryNotFound = errors.New("googlereader: category not found")
)
const (
// ParamItemIDs - name of the parameter with the item ids
ParamItemIDs = "i"
// ParamStreamID - name of the parameter containing the stream to be included
ParamStreamID = "s"
// ParamStreamExcludes - name of the parameter containing streams to be excluded
ParamStreamExcludes = "xt"
// ParamStreamFilters - name of the parameter containing streams to be included
ParamStreamFilters = "it"
// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
ParamStreamMaxItems = "n"
// ParamStreamOrder - name of the parameter containing the sort criteria
ParamStreamOrder = "r"
// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
ParamStreamStartTime = "ot"
// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
ParamStreamStopTime = "nt"
// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
ParamTagsRemove = "r"
// ParamTagsAdd - name of the parameter containing tags (streams) to be added
ParamTagsAdd = "a"
// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
ParamSubscribeAction = "ac"
// ParamTitle - name of the parameter for the title of the subscription
ParamTitle = "t"
// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
ParamQuickAdd = "quickadd"
// ParamDestination - name of the parameter for the new name of a tag
ParamDestination = "dest"
// ParamContinuation - name of the parameter for callers to pass to receive the next page of results
ParamContinuation = "c"
)
// StreamType represents the possible stream types
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
// RequestModifiers are the parsed request parameters.
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
var streamStr []string
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
var exclusions []string
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
var filters []string
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
// Serve handles Google Reader API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store, router}
@@ -228,102 +61,22 @@ func Serve(router *mux.Router, store *storage.Storage) {
sr.HandleFunc("/subscription/quickadd", handler.quickAddHandler).Methods(http.MethodPost).Name("QuickAdd")
sr.HandleFunc("/stream/items/ids", handler.streamItemIDsHandler).Methods(http.MethodGet).Name("StreamItemIDs")
sr.HandleFunc("/stream/items/contents", handler.streamItemContentsHandler).Methods(http.MethodPost).Name("StreamItemsContents")
sr.HandleFunc("/mark-all-as-read", handler.markAllAsReadHandler).Methods(http.MethodPost).Name("MarkAllAsRead")
sr.PathPrefix("/").HandlerFunc(handler.serveHandler).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
}
func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, ParamContinuation, 0)
result.StartTime = request.QueryInt64Param(r, ParamStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, ParamStreamStopTime, int64(0))
return result, nil
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
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 {
case Read:
return Stream{ReadStream, ""}, nil
case Starred:
return Stream{StarredStream, ""}, nil
case ReadingList:
return Stream{ReadingListStream, ""}, nil
case KeptUnread:
return Stream{KeptUnreadStream, ""}, nil
case Broadcast:
return Stream{BroadcastStream, ""}, nil
case BroadcastFriends:
return Stream{BroadcastFriendsStream, ""}, nil
case Like:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
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
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0)
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {
tags := make(map[StreamType]bool)
for _, s := range addTags {
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = true
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = false
case StarredStream:
@@ -338,17 +91,17 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = false
case KeptUnreadStream:
if _, ok := tags[ReadStream]; ok {
return nil, fmt.Errorf("googlereader: %s ad %s should not be supplied simultaneously", KeptUnread, Read)
return nil, fmt.Errorf("googlereader: %s and %s should not be supplied simultaneously", keptUnreadStreamSuffix, readStreamSuffix)
}
tags[ReadStream] = true
case StarredStream:
if _, ok := tags[StarredStream]; ok {
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", Starred)
return nil, fmt.Errorf("googlereader: %s should not be supplied for add and remove simultaneously", starredStreamSuffix)
}
tags[StarredStream] = false
case BroadcastStream, LikeStream:
@@ -361,28 +114,6 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
return tags, nil
}
func getItemIDs(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
var itemID int64
_, err := fmt.Sscanf(item, EntryIDLong, &itemID)
if err != nil {
itemID, err = strconv.ParseInt(item, 16, 64)
if err != nil {
return nil, fmt.Errorf("googlereader: could not parse item: %v", item)
}
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
func checkOutputFormat(r *http.Request) error {
var output string
if r.Method == http.MethodPost {
@@ -469,7 +200,7 @@ func (h *handler) clientLoginHandler(w http.ResponseWriter, r *http.Request) {
slog.String("username", username),
)
result := login{SID: token, LSID: token, Auth: token}
result := loginResponse{SID: token, LSID: token, Auth: token}
if output == "json" {
json.OK(w, r, result)
return
@@ -538,12 +269,12 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID)
addTags, err := getStreams(r.PostForm[paramTagsAdd], userID)
if err != nil {
json.ServerError(w, r, err)
return
}
removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID)
removeTags, err := getStreams(r.PostForm[paramTagsRemove], userID)
if err != nil {
json.ServerError(w, r, err)
return
@@ -559,9 +290,9 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
itemIDs, err := getItemIDs(r)
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@@ -656,7 +387,7 @@ func (h *handler) editTagHandler(w http.ResponseWriter, r *http.Request) {
}
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
@@ -676,7 +407,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
return
}
feedURL := r.Form.Get(ParamQuickAdd)
feedURL := r.Form.Get(paramQuickAdd)
if !validator.IsValidURL(feedURL) {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid URL: %s", feedURL))
return
@@ -687,11 +418,13 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
requestBuilder.WithProxyRotator(proxyrotator.ProxyRotatorInstance)
var rssBridgeURL string
var rssBridgeToken string
if intg, err := h.store.Integration(userID); err == nil && intg != nil && intg.RSSBridgeEnabled {
rssBridgeURL = intg.RSSBridgeURL
rssBridgeToken = intg.RSSBridgeToken
}
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL)
subscriptions, localizedError := mfs.NewSubscriptionFinder(requestBuilder).FindSubscriptions(feedURL, rssBridgeURL, rssBridgeToken)
if localizedError != nil {
json.ServerError(w, r, localizedError.Error())
return
@@ -723,7 +456,7 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
json.OK(w, r, quickAddResponse{
NumResults: 1,
Query: newFeed.FeedURL,
StreamID: fmt.Sprintf(FeedPrefix+"%d", newFeed.ID),
StreamID: fmt.Sprintf(feedPrefix+"%d", newFeed.ID),
StreamName: newFeed.Title,
})
}
@@ -796,14 +529,25 @@ func unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {
return nil
}
func rename(stream Stream, title string, store *storage.Storage, userID int64) error {
func rename(feedStream Stream, title string, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Renaming feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.String("new_title", title),
)
if title == "" {
return errors.New("empty title")
return errEmptyFeedTitle
}
feed, err := getFeed(stream, store, userID)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
if feed == nil {
return errFeedNotFound
}
feedModification := model.FeedModificationRequest{
Title: &title,
}
@@ -811,15 +555,29 @@ func rename(stream Stream, title string, store *storage.Storage, userID int64) e
return store.UpdateFeed(feed)
}
func move(stream Stream, destination Stream, store *storage.Storage, userID int64) error {
feed, err := getFeed(stream, store, userID)
func move(feedStream Stream, labelStream Stream, store *storage.Storage, userID int64) error {
slog.Debug("[GoogleReader] Moving feed",
slog.Int64("user_id", userID),
slog.Any("feed_stream", feedStream),
slog.Any("label_stream", labelStream),
)
feed, err := getFeed(feedStream, store, userID)
if err != nil {
return err
}
category, err := getOrCreateCategory(destination, store, userID)
if feed == nil {
return errFeedNotFound
}
category, err := getOrCreateCategory(labelStream, store, userID)
if err != nil {
return err
}
if category == nil {
return errCategoryNotFound
}
feedModification := model.FeedModificationRequest{
CategoryID: &category.ID,
}
@@ -851,20 +609,20 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
return
}
streamIds, err := getStreams(r.Form[ParamStreamID], userID)
streamIds, err := getStreams(r.Form[paramStreamID], userID)
if err != nil || len(streamIds) == 0 {
json.BadRequest(w, r, errors.New("googlereader: no valid stream IDs provided"))
return
}
newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID)
newLabel, err := getStream(r.Form.Get(paramTagsAdd), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamTagsAdd))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramTagsAdd))
return
}
title := r.Form.Get(ParamTitle)
action := r.Form.Get(ParamSubscribeAction)
title := r.Form.Get(paramTitle)
action := r.Form.Get(paramSubscribeAction)
switch action {
case "subscribe":
@@ -882,32 +640,41 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
case "edit":
if title != "" {
if err := rename(streamIds[0], title, h.store, userID); err != nil {
json.ServerError(w, r, err)
if errors.Is(err, errFeedNotFound) || errors.Is(err, errEmptyFeedTitle) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
if r.Form.Has(ParamTagsAdd) {
if r.Form.Has(paramTagsAdd) {
if newLabel.Type != LabelStream {
json.BadRequest(w, r, errors.New("destination must be a label"))
return
}
if err := move(streamIds[0], newLabel, h.store, userID); err != nil {
json.ServerError(w, r, err)
if errors.Is(err, errFeedNotFound) || errors.Is(err, errCategoryNotFound) {
json.BadRequest(w, r, err)
} else {
json.ServerError(w, r, err)
}
return
}
}
default:
json.ServerError(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
json.BadRequest(w, r, fmt.Errorf("googlereader: unrecognized action %s", action))
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
userName := request.UserName(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /stream/items/contents",
@@ -927,25 +694,20 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
json.ServerError(w, r, err)
return
}
var user *model.User
if user, err = h.store.UserByID(userID); err != nil {
json.ServerError(w, r, err)
return
}
requestModifiers, err := getStreamFilterModifiers(r)
requestModifiers, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
}
userReadingList := fmt.Sprintf(UserStreamPrefix, userID) + ReadingList
userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
userReadingList := fmt.Sprintf(userStreamPrefix, userID) + readingListStreamSuffix
userRead := fmt.Sprintf(userStreamPrefix, userID) + readStreamSuffix
userStarred := fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix
itemIDs, err := getItemIDs(r)
itemIDs, err := parseItemIDsFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
json.BadRequest(w, r, err)
return
}
@@ -969,28 +731,17 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
return
}
if len(entries) == 0 {
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
return
}
result := streamContentItems{
result := streamContentItemsResponse{
Direction: "ltr",
ID: fmt.Sprintf("feed/%d", entries[0].FeedID),
Title: entries[0].Feed.Title,
Alternate: []contentHREFType{
{
HREF: entries[0].Feed.SiteURL,
Type: "text/html",
},
},
Updated: time.Now().Unix(),
ID: "user/-/state/com.google/reading-list",
Title: "Reading List",
Updated: time.Now().Unix(),
Self: []contentHREF{
{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
},
},
Author: user.Username,
Author: userName,
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
@@ -1001,7 +752,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories := make([]string, 0)
categories = append(categories, userReadingList)
if entry.Feed.Category.Title != "" {
categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+entry.Feed.Category.Title)
categories = append(categories, fmt.Sprintf(userLabelPrefix, userID)+entry.Feed.Category.Title)
}
if entry.Status == model.EntryStatusRead {
categories = append(categories, userRead)
@@ -1012,11 +763,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
contentItems[i] = contentItem{
ID: fmt.Sprintf(EntryIDLong, entry.ID),
ID: convertEntryIDToLongFormItemID(entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
@@ -1072,9 +822,9 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
streams, err := getStreams(r.Form[ParamStreamID], userID)
streams, err := getStreams(r.Form[paramStreamID], userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
return
}
@@ -1093,7 +843,7 @@ func (h *handler) disableTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
@@ -1112,15 +862,15 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
source, err := getStream(r.Form.Get(ParamStreamID), userID)
source, err := getStream(r.Form.Get(paramStreamID), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamStreamID))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramStreamID))
return
}
destination, err := getStream(r.Form.Get(ParamDestination), userID)
destination, err := getStream(r.Form.Get(paramDestination), userID)
if err != nil {
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", ParamDestination))
json.BadRequest(w, r, fmt.Errorf("googlereader: invalid data in %s", paramDestination))
return
}
@@ -1160,7 +910,7 @@ func (h *handler) renameTagHandler(w http.ResponseWriter, r *http.Request) {
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
@@ -1184,13 +934,13 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err)
return
}
result.Tags = make([]subscriptionCategory, 0)
result.Tags = append(result.Tags, subscriptionCategory{
ID: fmt.Sprintf(UserStreamPrefix, userID) + Starred,
result.Tags = make([]subscriptionCategoryResponse, 0)
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userStreamPrefix, userID) + starredStreamSuffix,
})
for _, category := range categories {
result.Tags = append(result.Tags, subscriptionCategory{
ID: fmt.Sprintf(UserLabelPrefix, userID) + category.Title,
result.Tags = append(result.Tags, subscriptionCategoryResponse{
ID: fmt.Sprintf(userLabelPrefix, userID) + category.Title,
Label: category.Title,
Type: "folder",
})
@@ -1220,13 +970,13 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
return
}
result.Subscriptions = make([]subscription, 0)
result.Subscriptions = make([]subscriptionResponse, 0)
for _, feed := range feeds {
result.Subscriptions = append(result.Subscriptions, subscription{
ID: fmt.Sprintf(FeedPrefix+"%d", feed.ID),
result.Subscriptions = append(result.Subscriptions, subscriptionResponse{
ID: fmt.Sprintf(feedPrefix+"%d", feed.ID),
Title: feed.Title,
URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
Categories: []subscriptionCategoryResponse{{fmt.Sprintf(userLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL,
IconURL: h.feedIconURL(feed),
})
@@ -1265,7 +1015,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err)
return
}
userInfo := userInfo{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
userInfo := userInfoResponse{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
json.OK(w, r, userInfo)
}
@@ -1285,7 +1035,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
return
}
rm, err := getStreamFilterModifiers(r)
rm, err := parseStreamFilterFromRequest(r)
if err != nil {
json.ServerError(w, r, err)
return
@@ -1341,7 +1091,7 @@ func (h *handler) handleReadingListStreamHandler(w http.ResponseWriter, r *http.
slog.String("handler", "handleReadingListStreamHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Any("filter_type", s.Type),
slog.Int("filter_type", int(s.Type)),
)
}
}
@@ -1512,3 +1262,82 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
json.OK(w, r, streamIDResponse{itemRefs, continuation})
}
func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
clientIP := request.ClientIP(r)
slog.Debug("[GoogleReader] Handle /mark-all-as-read",
slog.String("handler", "markAllAsReadHandler"),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
if err := r.ParseForm(); err != nil {
json.BadRequest(w, r, err)
return
}
stream, err := getStream(r.Form.Get(paramStreamID), userID)
if err != nil {
json.BadRequest(w, r, err)
return
}
var before time.Time
if timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != "" {
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
if timestampParsedValue > 0 {
// It's unclear if the timestamp is in seconds or microseconds, so we try both using a naive approach.
if len(timestampParamValue) >= 16 {
before = time.UnixMicro(timestampParsedValue)
} else {
before = time.Unix(timestampParsedValue, 0)
}
}
}
if before.IsZero() {
before = time.Now()
}
switch stream.Type {
case FeedStream:
feedID, err := strconv.ParseInt(stream.ID, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
return
}
err = h.store.MarkFeedAsRead(userID, feedID, before)
if err != nil {
json.ServerError(w, r, err)
return
}
case LabelStream:
category, err := h.store.CategoryByTitle(userID, stream.ID)
if err != nil {
json.ServerError(w, r, err)
return
}
if category == nil {
json.NotFound(w, r)
return
}
if err := h.store.MarkCategoryAsRead(userID, category.ID, before); err != nil {
json.ServerError(w, r, err)
return
}
case ReadingListStream:
if err = h.store.MarkAllAsReadBeforeDate(userID, before); err != nil {
json.ServerError(w, r, err)
return
}
}
sendOkayResponse(w)
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"strconv"
"strings"
)
const (
ItemIDPrefix = "tag:google.com,2005:reader/item/"
ItemIDFormat = "tag:google.com,2005:reader/item/%016x"
)
func convertEntryIDToLongFormItemID(entryID int64) string {
// The entry ID is a 64-bit integer, so we need to format it as a 16-character hexadecimal string.
return fmt.Sprintf(ItemIDFormat, entryID)
}
// Expected format: "tag:google.com,2005:reader/item/00000000148b9369" (hexadecimal string with prefix and padding)
// NetNewsWire uses this format: "tag:google.com,2005:reader/item/2f2" (hexadecimal string with prefix and no padding)
// Reeder uses this format: "000000000000048c" (hexadecimal string without prefix and padding)
// Liferea uses this format: "12345" (decimal string)
// It returns the parsed ID as a int64 and an error if parsing fails.
func parseItemID(itemIDValue string) (int64, error) {
var itemID int64
if strings.HasPrefix(itemIDValue, ItemIDPrefix) {
n, err := fmt.Sscanf(itemIDValue, ItemIDFormat, &itemID)
if err != nil {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: %w", itemIDValue, err)
}
if n != 1 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: expected 1 value, got %d", itemIDValue, n)
}
if itemID == 0 {
return 0, fmt.Errorf("failed to parse hexadecimal item ID %s: item ID is zero", itemIDValue)
}
return itemID, nil
}
if len(itemIDValue) == 16 {
if n, err := fmt.Sscanf(itemIDValue, "%016x", &itemID); err == nil && n == 1 {
return itemID, nil
}
}
itemID, err := strconv.ParseInt(itemIDValue, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse decimal item ID %s: %w", itemIDValue, err)
}
return itemID, nil
}
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
items := r.Form[paramItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
itemIDs := make([]int64, len(items))
for i, item := range items {
itemID, err := parseItemID(item)
if err != nil {
return nil, fmt.Errorf("googlereader: failed to parse item ID %s: %w", item, err)
}
itemIDs[i] = itemID
}
return itemIDs, nil
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"net/http"
"net/url"
"reflect"
"testing"
)
func TestConvertEntryIDToLongFormItemID(t *testing.T) {
entryID := int64(344691561)
expected := "tag:google.com,2005:reader/item/00000000148b9369"
result := convertEntryIDToLongFormItemID(entryID)
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestParseItemIDsFromRequest(t *testing.T) {
formValues := url.Values{}
formValues.Add("i", "12345")
formValues.Add("i", "tag:google.com,2005:reader/item/00000000148b9369")
formValues.Add("i", "tag:google.com,2005:reader/item/2f2")
formValues.Add("i", "000000000000046f")
formValues.Add("i", "tag:google.com,2005:reader/item/272")
request := &http.Request{
Form: formValues,
}
result, err := parseItemIDsFromRequest(request)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var expected = []int64{12345, 344691561, 754, 1135, 626}
if !reflect.DeepEqual(result, expected) {
t.Errorf("expected %v, got %v", expected, result)
}
// Test with no item IDs
formValues = url.Values{}
request = &http.Request{
Form: formValues,
}
_, err = parseItemIDsFromRequest(request)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestParseItemID(t *testing.T) {
// Test with long form ID and hex ID
result, err := parseItemID("tag:google.com,2005:reader/item/0000000000000001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := int64(1)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with hexadecimal long form ID
result, err = parseItemID("0000000000000468")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(1128)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with short form ID
result, err = parseItemID("12345")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected = int64(12345)
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
// Test with invalid long form ID
_, err = parseItemID("tag:google.com,2005:reader/item/000000000000000g")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with invalid short form ID
_, err = parseItemID("invalid_id")
if err == nil {
t.Fatalf("expected error, got nil")
}
// Test with empty ID
_, err = parseItemID("")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
+13 -20
View File
@@ -51,7 +51,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
@@ -62,7 +62,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
} else {
@@ -74,7 +74,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
fields := strings.Fields(authorization)
@@ -84,7 +84,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
if fields[0] != "GoogleLogin" {
@@ -93,7 +93,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
auths := strings.Split(fields[1], "=")
@@ -103,7 +103,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
if auths[0] != "auth" {
@@ -112,7 +112,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
token = auths[1]
@@ -126,7 +126,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.String("token", token),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
var integration *model.Integration
@@ -139,7 +139,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
@@ -149,7 +149,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
if user, err = m.store.UserByID(integration.UserID); err != nil {
@@ -159,7 +159,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("user_agent", r.UserAgent()),
slog.Any("error", err),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
@@ -169,22 +169,15 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
)
Unauthorized(w, r)
sendUnauthorizedResponse(w)
return
}
slog.Info("[GoogleReader] User authenticated successfully",
slog.Bool("authentication_successful", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.Int64("user_id", user.ID),
slog.String("username", user.Username),
)
m.store.SetLastLogin(integration.UserID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserNameContextKey, user.Username)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// paramItemIDs - name of the parameter with the item ids
paramItemIDs = "i"
// paramStreamID - name of the parameter containing the stream to be included
paramStreamID = "s"
// paramStreamExcludes - name of the parameter containing streams to be excluded
paramStreamExcludes = "xt"
// paramStreamFilters - name of the parameter containing streams to be included
paramStreamFilters = "it"
// paramStreamMaxItems - name of the parameter containing number of items per page/max items returned
paramStreamMaxItems = "n"
// paramStreamOrder - name of the parameter containing the sort criteria
paramStreamOrder = "r"
// paramStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
paramStreamStartTime = "ot"
// paramStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
paramStreamStopTime = "nt"
// paramTagsRemove - name of the parameter containing tags (streams) to be removed
paramTagsRemove = "r"
// paramTagsAdd - name of the parameter containing tags (streams) to be added
paramTagsAdd = "a"
// paramSubscribeAction - name of the parameter indicating the action to take for subscription/edit
paramSubscribeAction = "ac"
// paramTitle - name of the parameter for the title of the subscription
paramTitle = "t"
// paramQuickAdd - name of the parameter for a URL being quick subscribed to
paramQuickAdd = "quickadd"
// paramDestination - name of the parameter for the new name of a tag
paramDestination = "dest"
// paramContinuation - name of the parameter for callers to pass to receive the next page of results
paramContinuation = "c"
// paramTimestamp - name of the parameter for unix timestamp
paramTimestamp = "ts"
)
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
const (
// streamPrefix is the prefix for streams (read/starred/reading list and so on)
streamPrefix = "user/-/state/com.google/"
// userStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
userStreamPrefix = "user/%d/state/com.google/"
// labelPrefix is the prefix for a label stream
labelPrefix = "user/-/label/"
// userLabelPrefix is the user specific prefix prefix for a label stream
userLabelPrefix = "user/%d/label/"
// feedPrefix is the prefix for a feed stream
feedPrefix = "feed/"
// readStreamSuffix is the suffix for read stream
readStreamSuffix = "read"
// starredStreamSuffix is the suffix for starred stream
starredStreamSuffix = "starred"
// readingListStreamSuffix is the suffix for reading list stream
readingListStreamSuffix = "reading-list"
// keptUnreadStreamSuffix is the suffix for kept unread stream
keptUnreadStreamSuffix = "kept-unread"
// broadcastStreamSuffix is the suffix for broadcast stream
broadcastStreamSuffix = "broadcast"
// broadcastFriendsStreamSuffix is the suffix for broadcast friends stream
broadcastFriendsStreamSuffix = "broadcast-friends"
// likeStreamSuffix is the suffix for like stream
likeStreamSuffix = "like"
)
+91
View File
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"strings"
"miniflux.app/v2/internal/http/request"
)
type RequestModifiers struct {
ExcludeTargets []Stream
FilterTargets []Stream
Streams []Stream
Count int
Offset int
SortDirection string
StartTime int64
StopTime int64
ContinuationToken string
UserID int64
}
func (r RequestModifiers) String() string {
var results []string
results = append(results, fmt.Sprintf("UserID: %d", r.UserID))
var streamStr []string
for _, s := range r.Streams {
streamStr = append(streamStr, s.String())
}
results = append(results, fmt.Sprintf("Streams: [%s]", strings.Join(streamStr, ", ")))
var exclusions []string
for _, s := range r.ExcludeTargets {
exclusions = append(exclusions, s.String())
}
results = append(results, fmt.Sprintf("Exclusions: [%s]", strings.Join(exclusions, ", ")))
var filters []string
for _, s := range r.FilterTargets {
filters = append(filters, s.String())
}
results = append(results, fmt.Sprintf("Filters: [%s]", strings.Join(filters, ", ")))
results = append(results, fmt.Sprintf("Count: %d", r.Count))
results = append(results, fmt.Sprintf("Offset: %d", r.Offset))
results = append(results, fmt.Sprintf("Sort Direction: %s", r.SortDirection))
results = append(results, fmt.Sprintf("Continuation Token: %s", r.ContinuationToken))
results = append(results, fmt.Sprintf("Start Time: %d", r.StartTime))
results = append(results, fmt.Sprintf("Stop Time: %d", r.StopTime))
return strings.Join(results, "; ")
}
func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
userID := request.UserID(r)
result := RequestModifiers{
SortDirection: "desc",
UserID: userID,
}
streamOrder := request.QueryStringParam(r, paramStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, paramStreamFilters), userID)
if err != nil {
return RequestModifiers{}, err
}
result.Count = request.QueryIntParam(r, paramStreamMaxItems, 0)
result.Offset = request.QueryIntParam(r, paramContinuation, 0)
result.StartTime = request.QueryInt64Param(r, paramStreamStartTime, int64(0))
result.StopTime = request.QueryInt64Param(r, paramStreamStopTime, int64(0))
return result, nil
}
+33 -41
View File
@@ -6,34 +6,36 @@ package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"net/http"
"miniflux.app/v2/internal/http/response"
)
type login struct {
type loginResponse struct {
SID string `json:"SID,omitempty"`
LSID string `json:"LSID,omitempty"`
Auth string `json:"Auth,omitempty"`
}
func (l login) String() string {
func (l loginResponse) String() string {
return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
}
type userInfo struct {
type userInfoResponse struct {
UserID string `json:"userId"`
UserName string `json:"userName"`
UserProfileID string `json:"userProfileId"`
UserEmail string `json:"userEmail"`
}
type subscription struct {
ID string `json:"id"`
Title string `json:"title"`
Categories []subscriptionCategory `json:"categories"`
URL string `json:"url"`
HTMLURL string `json:"htmlUrl"`
IconURL string `json:"iconUrl"`
type subscriptionResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Categories []subscriptionCategoryResponse `json:"categories"`
URL string `json:"url"`
HTMLURL string `json:"htmlUrl"`
IconURL string `json:"iconUrl"`
}
type subscriptionsResponse struct {
Subscriptions []subscriptionResponse `json:"subscriptions"`
}
type quickAddResponse struct {
@@ -43,14 +45,11 @@ type quickAddResponse struct {
StreamName string `json:"streamName,omitempty"`
}
type subscriptionCategory struct {
type subscriptionCategoryResponse struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Type string `json:"type,omitempty"`
}
type subscriptionsResponse struct {
Subscriptions []subscription `json:"subscriptions"`
}
type itemRef struct {
ID string `json:"id"`
@@ -64,18 +63,17 @@ type streamIDResponse struct {
}
type tagsResponse struct {
Tags []subscriptionCategory `json:"tags"`
Tags []subscriptionCategoryResponse `json:"tags"`
}
type streamContentItems struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Alternate []contentHREFType `json:"alternate"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
type streamContentItemsResponse struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
Self []contentHREF `json:"self"`
Updated int64 `json:"updated"`
Items []contentItem `json:"items"`
Author string `json:"author"`
}
type contentItem struct {
@@ -119,21 +117,15 @@ type contentItemOrigin struct {
HTMLUrl string `json:"htmlUrl"`
}
// Unauthorized sends a not authorized error to the client.
func Unauthorized(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusUnauthorized)
builder.WithHeader("Content-Type", "text/plain")
builder.WithHeader("X-Reader-Google-Bad-Token", "true")
builder.WithBody("Unauthorized")
builder.Write()
func sendUnauthorizedResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Reader-Google-Bad-Token", "true")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
}
// OK sends a ok response to the client.
func OK(w http.ResponseWriter, r *http.Request) {
builder := response.New(w, r)
builder.WithStatus(http.StatusOK)
builder.WithHeader("Content-Type", "text/plain")
builder.WithBody("OK")
builder.Write()
func sendOkayResponse(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
+119
View File
@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googlereader // import "miniflux.app/v2/internal/googlereader"
import (
"fmt"
"strings"
)
type StreamType int
const (
// NoStream - no stream type
NoStream StreamType = iota
// ReadStream - read stream type
ReadStream
// StarredStream - starred stream type
StarredStream
// ReadingListStream - reading list stream type
ReadingListStream
// KeptUnreadStream - kept unread stream type
KeptUnreadStream
// BroadcastStream - broadcast stream type
BroadcastStream
// BroadcastFriendsStream - broadcast friends stream type
BroadcastFriendsStream
// LabelStream - label stream type
LabelStream
// FeedStream - feed stream type
FeedStream
// LikeStream - like stream type
LikeStream
)
// Stream defines a stream type and its ID.
type Stream struct {
Type StreamType
ID string
}
func (s Stream) String() string {
return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
}
func (st StreamType) String() string {
switch st {
case NoStream:
return "NoStream"
case ReadStream:
return "ReadStream"
case StarredStream:
return "StarredStream"
case ReadingListStream:
return "ReadingListStream"
case KeptUnreadStream:
return "KeptUnreadStream"
case BroadcastStream:
return "BroadcastStream"
case BroadcastFriendsStream:
return "BroadcastFriendsStream"
case LabelStream:
return "LabelStream"
case FeedStream:
return "FeedStream"
case LikeStream:
return "LikeStream"
default:
return st.String()
}
}
func getStream(streamID string, userID int64) (Stream, error) {
switch {
case strings.HasPrefix(streamID, feedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, feedPrefix)}, nil
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 {
case readStreamSuffix:
return Stream{ReadStream, ""}, nil
case starredStreamSuffix:
return Stream{StarredStream, ""}, nil
case readingListStreamSuffix:
return Stream{ReadingListStream, ""}, nil
case keptUnreadStreamSuffix:
return Stream{KeptUnreadStream, ""}, nil
case broadcastStreamSuffix:
return Stream{BroadcastStream, ""}, nil
case broadcastFriendsStreamSuffix:
return Stream{BroadcastFriendsStream, ""}, nil
case likeStreamSuffix:
return Stream{LikeStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
}
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
case streamID == "":
return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
}
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0, len(streamIDs))
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
return []Stream{}, err
}
streams = append(streams, stream)
}
return streams, nil
}
+16 -4
View File
@@ -18,20 +18,26 @@ const (
// New creates a new cookie.
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
cookie := &http.Cookie{
Name: name,
Value: value,
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
}
// OAuth doesn't work when cookies are in strict mode.
if config.Opts.OAuth2Provider() != "" {
cookie.SameSite = http.SameSiteLaxMode
}
return cookie
}
// Expired returns an expired cookie.
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
cookie := &http.Cookie{
Name: name,
Value: "",
Path: basePath(path),
@@ -39,8 +45,14 @@ func Expired(name string, isHTTPS bool, path string) *http.Cookie {
HttpOnly: true,
MaxAge: -1,
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
}
// OAuth doesn't work when cookies are in strict mode.
if config.Opts.OAuth2Provider() != "" {
cookie.SameSite = http.SameSiteLaxMode
}
return cookie
}
func basePath(path string) string {
+10 -6
View File
@@ -16,6 +16,7 @@ type ContextKey int
// List of context keys.
const (
UserIDContextKey ContextKey = iota
UserNameContextKey
UserTimezoneContextKey
IsAdminUserContextKey
IsAuthenticatedContextKey
@@ -28,7 +29,6 @@ const (
OAuth2CodeVerifierContextKey
FlashMessageContextKey
FlashErrorMessageContextKey
PocketRequestTokenContextKey
LastForceRefreshContextKey
ClientIPContextKey
GoogleReaderToken
@@ -64,6 +64,15 @@ func UserID(r *http.Request) int64 {
return getContextInt64Value(r, UserIDContextKey)
}
// UserName returns the username of the logged user.
func UserName(r *http.Request) string {
value := getContextStringValue(r, UserNameContextKey)
if value == "" {
value = "unknown"
}
return value
}
// UserTimezone returns the timezone used by the logged user.
func UserTimezone(r *http.Request) string {
value := getContextStringValue(r, UserTimezoneContextKey)
@@ -125,11 +134,6 @@ func FlashErrorMessage(r *http.Request) string {
return getContextStringValue(r, FlashErrorMessageContextKey)
}
// PocketRequestToken returns the Pocket Request Token if any.
func PocketRequestToken(r *http.Request) string {
return getContextStringValue(r, PocketRequestTokenContextKey)
}
// LastForceRefresh returns the last force refresh timestamp.
func LastForceRefresh(r *http.Request) int64 {
jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
-22
View File
@@ -390,28 +390,6 @@ func TestFlashErrorMessage(t *testing.T) {
}
}
func TestPocketRequestToken(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
result := PocketRequestToken(r)
expected := ""
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
ctx := r.Context()
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, "request token")
r = r.WithContext(ctx)
result = PocketRequestToken(r)
expected = "request token"
if result != expected {
t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
}
}
func TestClientIP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.org", nil)
+5 -4
View File
@@ -4,6 +4,7 @@
package html // import "miniflux.app/v2/internal/http/response/html"
import (
"html"
"log/slog"
"net/http"
@@ -38,9 +39,9 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
builder := response.New(w, r)
builder.WithStatus(http.StatusInternalServerError)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
@@ -62,9 +63,9 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
builder := response.New(w, r)
builder.WithStatus(http.StatusBadRequest)
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Content-Type", "text/plain; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
builder.WithBody(err)
builder.WithBody(html.EscapeString(err.Error()))
builder.Write()
}
+6 -6
View File
@@ -58,7 +58,7 @@ func TestServerErrorResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ServerError(w, r, errors.New("Some error"))
ServerError(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
@@ -69,13 +69,13 @@ func TestServerErrorResponse(t *testing.T) {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error`
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
@@ -91,7 +91,7 @@ func TestBadRequestResponse(t *testing.T) {
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
BadRequest(w, r, errors.New("Some error"))
BadRequest(w, r, errors.New("Some error with injected HTML <script>alert('XSS')</script>"))
})
handler.ServeHTTP(w, r)
@@ -102,13 +102,13 @@ func TestBadRequestResponse(t *testing.T) {
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
}
expectedBody := `Some error`
expectedBody := `Some error with injected HTML &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;`
actualBody := w.Body.String()
if actualBody != expectedBody {
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
}
expectedContentType := "text/html; charset=utf-8"
expectedContentType := "text/plain; charset=utf-8"
actualContentType := resp.Header.Get("Content-Type")
if actualContentType != expectedContentType {
t.Fatalf(`Unexpected content type, got %q instead of %q`, actualContentType, expectedContentType)
+144 -98
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
package server // import "miniflux.app/v2/internal/http/server"
import (
"crypto/tls"
@@ -30,36 +30,84 @@ import (
"golang.org/x/crypto/acme/autocert"
)
func StartWebServer(store *storage.Storage, pool *worker.Pool) *http.Server {
func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
listenAddresses := config.Opts.ListenAddr()
var httpServers []*http.Server
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
certDomain := config.Opts.CertDomain()
listenAddr := config.Opts.ListenAddr()
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
Handler: setupHandler(store, pool),
var sharedAutocertTLSConfig *tls.Config
if certDomain != "" {
slog.Debug("Configuring autocert manager and shared TLS config", slog.String("domain", certDomain))
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
sharedAutocertTLSConfig = &tls.Config{}
sharedAutocertTLSConfig.GetCertificate = certManager.GetCertificate
sharedAutocertTLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
challengeServer := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
slog.Info("Starting ACME HTTP challenge server for autocert", slog.String("address", challengeServer.Addr))
go func() {
if err := challengeServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
}
}()
config.Opts.HTTPS = true
httpServers = append(httpServers, challengeServer)
}
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
startSystemdSocketServer(server)
case strings.HasPrefix(listenAddr, "/"):
startUnixSocketServer(server, listenAddr)
case certDomain != "":
config.Opts.HTTPS = true
startAutoCertTLSServer(server, certDomain, store)
case certFile != "" && keyFile != "":
config.Opts.HTTPS = true
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
default:
server.Addr = listenAddr
startHTTPServer(server)
for i, listenAddr := range listenAddresses {
server := &http.Server{
ReadTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
WriteTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
IdleTimeout: time.Duration(config.Opts.HTTPServerTimeout()) * time.Second,
Handler: setupHandler(store, pool),
}
if !strings.HasPrefix(listenAddr, "/") && os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
server.Addr = listenAddr
}
shouldAddServer := true
switch {
case os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()):
if i == 0 {
slog.Info("Starting server using systemd socket for the first listen address", slog.String("address_info", listenAddr))
startSystemdSocketServer(server)
} else {
slog.Warn("Systemd socket activation: Only the first listen address is used by systemd. Other addresses ignored.", slog.String("skipped_address", listenAddr))
shouldAddServer = false
}
case strings.HasPrefix(listenAddr, "/"): // Unix socket
startUnixSocketServer(server, listenAddr)
case certDomain != "" && (listenAddr == ":https" || (i == 0 && strings.Contains(listenAddr, ":"))):
server.Addr = listenAddr
startAutoCertTLSServer(server, sharedAutocertTLSConfig)
case certFile != "" && keyFile != "":
server.Addr = listenAddr
startTLSServer(server, certFile, keyFile)
config.Opts.HTTPS = true
default:
server.Addr = listenAddr
startHTTPServer(server)
}
if shouldAddServer {
httpServers = append(httpServers, server)
}
}
return server
return httpServers
}
func startSystemdSocketServer(server *http.Server) {
@@ -72,83 +120,66 @@ func startSystemdSocketServer(server *http.Server) {
slog.Info(`Starting server using systemd socket`)
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit(`Systemd socket server failed to start: %v`, err)
}
}()
}
func startUnixSocketServer(server *http.Server, socketFile string) {
os.Remove(socketFile)
if err := os.Remove(socketFile); err != nil && !os.IsNotExist(err) {
printErrorAndExit("Unable to remove existing Unix socket %s: %v", socketFile, err)
}
listener, err := net.Listen("unix", socketFile)
if err != nil {
printErrorAndExit(`Server failed to listen on Unix socket %s: %v`, socketFile, err)
}
go func(sock string) {
listener, err := net.Listen("unix", sock)
if err != nil {
printErrorAndExit(`Server failed to start: %v`, err)
}
defer listener.Close()
if err := os.Chmod(socketFile, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission for %s: %v`, socketFile, err)
}
if err := os.Chmod(sock, 0666); err != nil {
printErrorAndExit(`Unable to change socket permission: %v`, err)
}
go func() {
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
slog.Info("Starting server using a Unix socket", slog.String("socket", sock))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
if certFile != "" && keyFile != "" {
slog.Info("Starting TLS server using a Unix socket",
slog.String("socket", socketFile),
slog.String("cert_file", certFile),
slog.String("key_file", keyFile),
)
// Ensure HTTPS is marked as true if any listener uses TLS
config.Opts.HTTPS = true
if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
}
} else {
slog.Info("Starting server using a Unix socket", slog.String("socket", socketFile))
if err := server.Serve(listener); err != http.ErrServerClosed {
printErrorAndExit("Unix socket server failed to start on %s: %v", socketFile, err)
}
}
}(socketFile)
}()
}
func tlsConfig() *tls.Config {
// See https://blog.cloudflare.com/exposing-go-on-the-internet/
// And https://wiki.mozilla.org/Security/Server_Side_TLS
return &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.CurveP256,
tls.X25519,
},
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{}
}
}
func startAutoCertTLSServer(server *http.Server, certDomain string, store *storage.Storage) {
server.Addr = ":https"
certManager := autocert.Manager{
Cache: storage.NewCertificateCache(store),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
server.TLSConfig = tlsConfig()
server.TLSConfig.GetCertificate = certManager.GetCertificate
server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
go func() {
slog.Info("Starting TLS server using automatic certificate management",
slog.String("listen_address", server.Addr),
slog.String("domain", certDomain),
)
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("Autocert server failed to start on %s: %v", server.Addr, err)
}
}()
}
func startTLSServer(server *http.Server, certFile, keyFile string) {
server.TLSConfig = tlsConfig()
go func() {
slog.Info("Starting TLS server using a certificate",
slog.String("listen_address", server.Addr),
@@ -156,7 +187,7 @@ func startTLSServer(server *http.Server, certFile, keyFile string) {
slog.String("key_file", keyFile),
)
if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("TLS server failed to start on %s: %v", server.Addr, err)
}
}()
}
@@ -167,49 +198,64 @@ func startHTTPServer(server *http.Server) {
slog.String("listen_address", server.Addr),
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
printErrorAndExit(`Server failed to start: %v`, err)
printErrorAndExit("HTTP server failed to start on %s: %v", server.Addr, err)
}
}()
}
func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
livenessProbe := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
readinessProbe := func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, fmt.Sprintf("Database Connection Error: %q", err), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
router := mux.NewRouter()
// These routes do not take the base path into consideration and are always available at the root of the server.
router.HandleFunc("/liveness", livenessProbe).Name("liveness")
router.HandleFunc("/healthz", livenessProbe).Name("healthz")
router.HandleFunc("/readiness", readinessProbe).Name("readiness")
router.HandleFunc("/readyz", readinessProbe).Name("readyz")
var subrouter *mux.Router
if config.Opts.BasePath() != "" {
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()
} else {
subrouter = router.NewRoute().Subrouter()
}
if config.Opts.HasMaintenanceMode() {
router.Use(func(next http.Handler) http.Handler {
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(config.Opts.MaintenanceMessage()))
})
})
}
router.Use(middleware)
subrouter.Use(middleware)
fever.Serve(router, store)
googlereader.Serve(router, store)
api.Serve(router, store, pool)
ui.Serve(router, store, pool)
fever.Serve(subrouter, store)
googlereader.Serve(subrouter, store)
api.Serve(subrouter, store, pool)
ui.Serve(subrouter, store, pool)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, "Database Connection Error", http.StatusInternalServerError)
return
}
subrouter.HandleFunc("/healthcheck", readinessProbe).Name("healthcheck")
w.Write([]byte("OK"))
}).Name("healthcheck")
router.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
subrouter.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version.Version))
}).Name("version")
if config.Opts.HasMetricsCollector() {
router.Handle("/metrics", promhttp.Handler()).Name("metrics")
router.Use(func(next http.Handler) http.Handler {
subrouter.Handle("/metrics", promhttp.Handler()).Name("metrics")
subrouter.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := mux.CurrentRoute(r)
+1 -1
View File
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package httpd // import "miniflux.app/v2/internal/http/server"
package server // import "miniflux.app/v2/internal/http/server"
import (
"context"
+19 -20
View File
@@ -6,13 +6,13 @@ package integration // import "miniflux.app/v2/internal/integration"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/discord"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/karakeep"
"miniflux.app/v2/internal/integration/linkace"
"miniflux.app/v2/internal/integration/linkding"
"miniflux.app/v2/internal/integration/linkwarden"
@@ -22,7 +22,6 @@ import (
"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/pushover"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
@@ -196,24 +195,6 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.PocketEnabled {
slog.Debug("Sending entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := pocket.NewClient(config.Opts.PocketConsumerKey(userIntegrations.PocketConsumerKey), userIntegrations.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Pocket",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.LinkAceEnabled {
slog.Debug("Sending entry to LinkAce",
slog.Int64("user_id", userIntegrations.UserID),
@@ -428,6 +409,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.KarakeepEnabled {
slog.Debug("Sending entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := karakeep.NewClient(userIntegrations.KarakeepAPIKey, userIntegrations.KarakeepURL)
if err := client.SaveURL(entry.URL); err != nil {
slog.Error("Unable to send entry to Karakeep",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
+81
View File
@@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package karakeep // import "miniflux.app/v2/internal/integration/karakeep"
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type errorResponse struct {
Code string `json:"code"`
Error string `json:"error"`
}
type saveURLPayload struct {
Type string `json:"type"`
URL string `json:"url"`
}
type Client struct {
wrapped *http.Client
apiEndpoint string
apiToken string
}
func NewClient(apiToken string, apiEndpoint string) *Client {
return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
}
func (c *Client) SaveURL(entryURL string) error {
requestBody, err := json.Marshal(&saveURLPayload{
Type: "link",
URL: entryURL,
})
if err != nil {
return fmt.Errorf("karakeep: unable to encode request body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, c.apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("karakeep: unable to create request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
resp, err := c.wrapped.Do(req)
if err != nil {
return fmt.Errorf("karakeep: unable to send request: %v", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("karakeep: failed to parse response: %s", err)
}
if resp.Header.Get("Content-Type") != "application/json" {
return fmt.Errorf("karakeep: unexpected content type response: %s", resp.Header.Get("Content-Type"))
}
if resp.StatusCode != http.StatusCreated {
var errResponse errorResponse
if err := json.Unmarshal(responseBody, &errResponse); err != nil {
return fmt.Errorf("karakeep: unable to parse error response: status=%d body=%s", resp.StatusCode, string(responseBody))
}
return fmt.Errorf("karakeep: failed to save URL: status=%d errorcode=%s %s", resp.StatusCode, errResponse.Code, errResponse.Error)
}
return nil
}
+4 -16
View File
@@ -35,12 +35,9 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf(`linkwarden: invalid API endpoint: %v`, err)
}
requestBody, err := json.Marshal(&linkwardenBookmark{
Url: entryURL,
Name: "",
Description: "",
Tags: []string{},
Collection: map[string]interface{}{},
requestBody, err := json.Marshal(map[string]string{
"url": entryURL,
"name": entryTitle,
})
if err != nil {
@@ -54,8 +51,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.AddCookie(&http.Cookie{Name: "__Secure-next-auth.session-token", Value: c.apiKey})
request.AddCookie(&http.Cookie{Name: "next-auth.session-token", Value: c.apiKey})
request.Header.Set("Authorization", "Bearer "+c.apiKey)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
@@ -70,11 +66,3 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
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"`
}
-132
View File
@@ -1,132 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"miniflux.app/v2/internal/version"
)
// Connector manages the authorization flow with Pocket to get a personal access token.
type Connector struct {
consumerKey string
}
// NewConnector returns a new Pocket Connector.
func NewConnector(consumerKey string) *Connector {
return &Connector{consumerKey}
}
// RequestToken fetches a new request token from Pocket API.
func (c *Connector) RequestToken(redirectURL string) (string, error) {
apiEndpoint := "https://getpocket.com/v3/oauth/request"
requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
if err != nil {
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var result createTokenResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
if result.Code == "" {
return "", errors.New("pocket: request token is empty")
}
return result.Code, nil
}
// AccessToken fetches a new access token once the end-user authorized the application.
func (c *Connector) AccessToken(requestToken string) (string, error) {
apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
if err != nil {
return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return "", fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Accept", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
}
var result authorizeReponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", fmt.Errorf("pocket: unable to decode response: %v", err)
}
if result.AccessToken == "" {
return "", errors.New("pocket: access token is empty")
}
return result.AccessToken, nil
}
// AuthorizationURL returns the authorization URL for the end-user.
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
return fmt.Sprintf(
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
requestToken,
redirectURL,
)
}
type createTokenRequest struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
type createTokenResponse struct {
Code string `json:"code"`
}
type authorizeRequest struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
type authorizeReponse struct {
AccessToken string `json:"access_token"`
Username string `json:"username"`
}
-70
View File
@@ -1,70 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package pocket // import "miniflux.app/v2/internal/integration/pocket"
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
consumerKey string
accessToken string
}
func NewClient(consumerKey, accessToken string) *Client {
return &Client{consumerKey, accessToken}
}
func (c *Client) AddURL(entryURL, entryTitle string) error {
if c.consumerKey == "" || c.accessToken == "" {
return fmt.Errorf("pocket: missing consumer key or access token")
}
apiEndpoint := "https://getpocket.com/v3/add"
requestBody, err := json.Marshal(&createItemRequest{
AccessToken: c.accessToken,
ConsumerKey: c.consumerKey,
Title: entryTitle,
URL: entryURL,
})
if err != nil {
return fmt.Errorf("pocket: unable to encode request body: %v", err)
}
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("pocket: unable to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
httpClient := &http.Client{Timeout: defaultClientTimeout}
response, err := httpClient.Do(request)
if err != nil {
return fmt.Errorf("pocket: unable to send request: %v", err)
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
}
return nil
}
type createItemRequest struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}
+13 -1
View File
@@ -24,13 +24,16 @@ type BridgeMeta struct {
Name string `json:"name"`
}
func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge, error) {
endpointURL, err := url.Parse(rssBridgeURL)
if err != nil {
return nil, fmt.Errorf("RSS-Bridge: unable to parse bridge URL: %w", err)
}
values := endpointURL.Query()
if rssBridgeToken != "" {
values.Add("token", rssBridgeToken)
}
values.Add("action", "findfeed")
values.Add("format", "atom")
values.Add("url", websiteURL)
@@ -78,6 +81,15 @@ func DetectBridges(rssBridgeURL, websiteURL string) ([]*Bridge, error) {
slog.String("url", bridge.URL),
)
}
if rssBridgeToken != "" {
bridge.URL = bridge.URL + "&token=" + rssBridgeToken
slog.Debug("Appended token to RSS bridge URL",
slog.String("name", bridge.BridgeMeta.Name),
slog.String("url", bridge.URL),
)
}
}
return bridgeResponse, nil
+3 -16
View File
@@ -9,7 +9,7 @@ import (
"fmt"
)
type translationDict map[string]interface{}
type translationDict map[string]any
type catalog map[string]translationDict
var defaultCatalog = make(catalog, len(AvailableLanguages))
@@ -17,7 +17,7 @@ var defaultCatalog = make(catalog, len(AvailableLanguages))
//go:embed translations/*.json
var translationFiles embed.FS
func GetTranslationDict(language string) (translationDict, error) {
func getTranslationDict(language string) (translationDict, error) {
if _, ok := defaultCatalog[language]; !ok {
var err error
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
@@ -27,21 +27,8 @@ func GetTranslationDict(language string) (translationDict, error) {
return defaultCatalog[language], nil
}
// LoadCatalogMessages loads and parses all translations encoded in JSON.
func LoadCatalogMessages() error {
var err error
for language := range AvailableLanguages {
defaultCatalog[language], err = loadTranslationFile(language)
if err != nil {
return err
}
}
return nil
}
func loadTranslationFile(language string) (translationDict, error) {
translationFileData, err := translationFiles.ReadFile(fmt.Sprintf("translations/%s.json", language))
translationFileData, err := translationFiles.ReadFile("translations/" + language + ".json")
if err != nil {
return nil, err
}
+5 -2
View File
@@ -33,8 +33,11 @@ func TestParser(t *testing.T) {
}
func TestLoadCatalog(t *testing.T) {
if err := LoadCatalogMessages(); err != nil {
t.Fatal(err)
for language := range AvailableLanguages {
_, err := loadTranslationFile(language)
if err != nil {
t.Fatal(err)
}
}
}
+279
View File
@@ -0,0 +1,279 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import (
"errors"
"testing"
)
func TestNewLocalizedErrorWrapper(t *testing.T) {
originalErr := errors.New("original error message")
translationKey := "error.test_key"
args := []any{"arg1", 42}
wrapper := NewLocalizedErrorWrapper(originalErr, translationKey, args...)
if wrapper.originalErr != originalErr {
t.Errorf("Expected original error to be %v, got %v", originalErr, wrapper.originalErr)
}
if wrapper.translationKey != translationKey {
t.Errorf("Expected translation key to be %q, got %q", translationKey, wrapper.translationKey)
}
if len(wrapper.translationArgs) != 2 {
t.Errorf("Expected 2 translation args, got %d", len(wrapper.translationArgs))
}
if wrapper.translationArgs[0] != "arg1" || wrapper.translationArgs[1] != 42 {
t.Errorf("Expected translation args [arg1, 42], got %v", wrapper.translationArgs)
}
}
func TestLocalizedErrorWrapper_Error(t *testing.T) {
originalErr := errors.New("original error message")
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key")
result := wrapper.Error()
if result != originalErr {
t.Errorf("Expected Error() to return original error %v, got %v", originalErr, result)
}
}
func TestLocalizedErrorWrapper_Translate(t *testing.T) {
// Set up test catalog
defaultCatalog = catalog{
"en_US": translationDict{
"error.test_key": "Error: %s (code: %d)",
},
"fr_FR": translationDict{
"error.test_key": "Erreur : %s (code : %d)",
},
}
originalErr := errors.New("original error")
wrapper := NewLocalizedErrorWrapper(originalErr, "error.test_key", "test message", 404)
// Test English translation
result := wrapper.Translate("en_US")
expected := "Error: test message (code: 404)"
if result != expected {
t.Errorf("Expected English translation %q, got %q", expected, result)
}
// Test French translation
result = wrapper.Translate("fr_FR")
expected = "Erreur : test message (code : 404)"
if result != expected {
t.Errorf("Expected French translation %q, got %q", expected, result)
}
// Test with missing language (should use key as fallback with args applied)
result = wrapper.Translate("invalid_lang")
expected = "error.test_key%!(EXTRA string=test message, int=404)"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
}
func TestLocalizedErrorWrapper_TranslateWithEmptyKey(t *testing.T) {
originalErr := errors.New("original error message")
wrapper := NewLocalizedErrorWrapper(originalErr, "")
result := wrapper.Translate("en_US")
expected := "original error message"
if result != expected {
t.Errorf("Expected original error message %q, got %q", expected, result)
}
}
func TestLocalizedErrorWrapper_TranslateWithNoArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.simple": "Simple error message",
},
}
originalErr := errors.New("original error")
wrapper := NewLocalizedErrorWrapper(originalErr, "error.simple")
result := wrapper.Translate("en_US")
expected := "Simple error message"
if result != expected {
t.Errorf("Expected translation %q, got %q", expected, result)
}
}
func TestNewLocalizedError(t *testing.T) {
translationKey := "error.validation"
args := []any{"field1", "invalid"}
localizedErr := NewLocalizedError(translationKey, args...)
if localizedErr.translationKey != translationKey {
t.Errorf("Expected translation key to be %q, got %q", translationKey, localizedErr.translationKey)
}
if len(localizedErr.translationArgs) != 2 {
t.Errorf("Expected 2 translation args, got %d", len(localizedErr.translationArgs))
}
if localizedErr.translationArgs[0] != "field1" || localizedErr.translationArgs[1] != "invalid" {
t.Errorf("Expected translation args [field1, invalid], got %v", localizedErr.translationArgs)
}
}
func TestLocalizedError_String(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.validation": "Validation failed for %s: %s",
},
}
localizedErr := NewLocalizedError("error.validation", "username", "too short")
result := localizedErr.String()
expected := "Validation failed for username: too short"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
}
func TestLocalizedError_StringWithMissingTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{},
}
localizedErr := NewLocalizedError("error.missing", "arg1")
result := localizedErr.String()
expected := "error.missing%!(EXTRA string=arg1)"
if result != expected {
t.Errorf("Expected String() result %q, got %q", expected, result)
}
}
func TestLocalizedError_Error(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.database": "Database connection failed: %s",
},
}
localizedErr := NewLocalizedError("error.database", "timeout")
result := localizedErr.Error()
if result == nil {
t.Error("Expected Error() to return a non-nil error")
}
expected := "Database connection failed: timeout"
if result.Error() != expected {
t.Errorf("Expected Error() message %q, got %q", expected, result.Error())
}
}
func TestLocalizedError_Translate(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.permission": "Permission denied for %s",
},
"es_ES": translationDict{
"error.permission": "Permiso denegado para %s",
},
}
localizedErr := NewLocalizedError("error.permission", "admin panel")
// Test English translation
result := localizedErr.Translate("en_US")
expected := "Permission denied for admin panel"
if result != expected {
t.Errorf("Expected English translation %q, got %q", expected, result)
}
// Test Spanish translation
result = localizedErr.Translate("es_ES")
expected = "Permiso denegado para admin panel"
if result != expected {
t.Errorf("Expected Spanish translation %q, got %q", expected, result)
}
// Test with missing language
result = localizedErr.Translate("invalid_lang")
expected = "error.permission%!(EXTRA string=admin panel)"
if result != expected {
t.Errorf("Expected fallback translation %q, got %q", expected, result)
}
}
func TestLocalizedError_TranslateWithNoArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.generic": "An error occurred",
},
"de_DE": translationDict{
"error.generic": "Ein Fehler ist aufgetreten",
},
}
localizedErr := NewLocalizedError("error.generic")
// Test English
result := localizedErr.Translate("en_US")
expected := "An error occurred"
if result != expected {
t.Errorf("Expected English translation %q, got %q", expected, result)
}
// Test German
result = localizedErr.Translate("de_DE")
expected = "Ein Fehler ist aufgetreten"
if result != expected {
t.Errorf("Expected German translation %q, got %q", expected, result)
}
}
func TestLocalizedError_TranslateWithComplexArgs(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"error.complex": "Error %d: %s occurred at %s with severity %s",
},
}
localizedErr := NewLocalizedError("error.complex", 500, "Internal Server Error", "2024-01-01", "high")
result := localizedErr.Translate("en_US")
expected := "Error 500: Internal Server Error occurred at 2024-01-01 with severity high"
if result != expected {
t.Errorf("Expected complex translation %q, got %q", expected, result)
}
}
func TestLocalizedErrorWrapper_WithNilError(t *testing.T) {
// This tests edge case behavior - what happens with nil error
wrapper := NewLocalizedErrorWrapper(nil, "error.test")
// Error() should return nil
result := wrapper.Error()
if result != nil {
t.Errorf("Expected Error() to return nil, got %v", result)
}
}
func TestLocalizedError_EmptyKey(t *testing.T) {
localizedErr := NewLocalizedError("")
result := localizedErr.String()
expected := ""
if result != expected {
t.Errorf("Expected empty string for empty key, got %q", result)
}
result = localizedErr.Translate("en_US")
if result != expected {
t.Errorf("Expected empty string for empty key translation, got %q", result)
}
}
+27 -71
View File
@@ -5,16 +5,9 @@ package locale // import "miniflux.app/v2/internal/locale"
// 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]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 {
func getPluralForm(lang string, n int) int {
switch lang {
case "ar_AR":
switch {
case n == 0:
return 0
@@ -26,90 +19,53 @@ var pluralForms = map[string]func(n int) int{
return 3
case n%100 >= 11:
return 4
default:
return 5
}
return 5
},
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
"cs_CZ": func(n int) int {
case "cs_CZ":
switch {
case n == 1:
return 0
case n >= 2 && n <= 4:
return 1
default:
return 2
}
return 2
},
// nplurals=2; plural=(n > 1);
"fr_FR": func(n int) int {
if n > 1 {
return 1
}
case "id_ID", "ja_JP":
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 {
case "pl_PL":
switch {
case n == 1:
return 0
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
default:
return 2
}
return 2
},
// nplurals=2; plural=(n > 1);
"pt_BR": func(n int) int {
if n > 1 {
return 1
}
return 0
},
// nplurals=3; plural=(n==1 ? 0 : n==0 || (n%100 > 0 && n%100 < 20) ? 1 : 2);
"ro_RO": func(n int) int {
case "ro_RO":
switch {
case n == 1:
return 0
case n == 0 || (n%100 > 0 && n%100 < 20):
return 1
default:
return 2
}
return 2
},
"ru_RU": pluralFormRuSrUa,
// nplurals=2; plural=(n > 1);
"tr_TR": func(n int) int {
case "ru_RU", "uk_UA", "sr_RS":
switch {
case n%10 == 1 && n%100 != 11:
return 0
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
default:
return 2
}
case "zh_CN", "zh_TW", "nan_Latn_pehoeji":
return 0
default: // includes fr_FR, pr_BR, tr_TR
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
},
"nan_Latn_pehoeji": 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 {
switch {
case n%10 == 1 && n%100 != 11:
return 0
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
return 1
}
return 2
}
+156 -48
View File
@@ -7,90 +7,198 @@ import "testing"
func TestPluralRules(t *testing.T) {
scenarios := map[string]map[int]int{
// Default rule (covers fr_FR, pt_BR, tr_TR, and other unlisted languages)
"default": {
1: 0,
2: 1,
5: 1,
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Arabic (ar_AR) - 6 forms
"ar_AR": {
0: 0,
1: 1,
2: 2,
5: 3,
11: 4,
200: 5,
0: 0, // n == 0
1: 1, // n == 1
2: 2, // n == 2
3: 3, // n%100 >= 3 && n%100 <= 10
5: 3, // n%100 >= 3 && n%100 <= 10
10: 3, // n%100 >= 3 && n%100 <= 10
11: 4, // n%100 >= 11
15: 4, // n%100 >= 11
99: 4, // n%100 >= 11
100: 5, // default case (n%100 == 0, doesn't match any condition)
101: 5, // default case (n%100 == 1, but n != 1)
200: 5, // default case
},
// Czech (cs_CZ) - 3 forms
"cs_CZ": {
1: 0,
2: 1,
5: 2,
1: 0, // n == 1
2: 1, // n >= 2 && n <= 4
3: 1, // n >= 2 && n <= 4
4: 1, // n >= 2 && n <= 4
5: 2, // default case
},
// French (fr_FR) - uses default rule
"fr_FR": {
1: 0,
2: 1,
5: 1,
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Indonesian (id_ID) - always form 0
"id_ID": {
1: 0,
5: 0,
0: 0,
1: 0,
5: 0,
100: 0,
},
// Japanese (ja_JP) - always form 0
"ja_JP": {
1: 0,
2: 0,
5: 0,
0: 0,
1: 0,
2: 0,
5: 0,
100: 0,
},
// Polish (pl_PL) - 3 forms
"pl_PL": {
1: 0,
2: 1,
5: 2,
1: 0, // n == 1
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
10: 2, // default case (n%100 < 10, but n%10 not in 2-4)
11: 2, // default case (n%100 >= 10 and < 20)
12: 2, // default case (n%100 >= 10 and < 20)
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
24: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
},
// Portuguese Brazilian (pt_BR) - uses default rule
"pt_BR": {
1: 0,
2: 1,
5: 1,
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Romanian (ro_RO) - 3 forms
"ro_RO": {
1: 0,
2: 1,
5: 1,
0: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
1: 0, // n == 1
2: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
5: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
19: 1, // n == 0 || (n%100 > 0 && n%100 < 20)
20: 2, // default case
21: 2, // default case
100: 2, // default case (n%100 == 0, so condition fails)
101: 1, // n%100 == 1, so n%100 > 0 && n%100 < 20
},
// Russian (ru_RU) - 3 forms
"ru_RU": {
1: 0,
2: 1,
5: 2,
1: 0, // n%10 == 1 && n%100 != 11
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
3: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
4: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
11: 2, // n%10 == 1 but n%100 == 11, so default case
12: 2, // default case
21: 0, // n%10 == 1 && n%100 != 11
22: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 >= 20)
},
// Serbian (sr_RS) - same as Russian
"sr_RS": {
1: 0,
2: 1,
5: 2,
1: 0, // n%10 == 1 && n%100 != 11
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
11: 2, // n%10 == 1 but n%100 == 11, so default case
21: 0, // n%10 == 1 && n%100 != 11
},
// Turkish (tr_TR) - uses default rule
"tr_TR": {
1: 0,
2: 1,
5: 1,
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
5: 1, // n > 1
},
// Ukrainian (uk_UA) - same as Russian
"uk_UA": {
1: 0,
2: 1,
5: 2,
1: 0, // n%10 == 1 && n%100 != 11
2: 1, // n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20)
5: 2, // default case
11: 2, // n%10 == 1 but n%100 == 11, so default case
21: 0, // n%10 == 1 && n%100 != 11
},
// Chinese Simplified (zh_CN) - always form 0
"zh_CN": {
1: 0,
5: 0,
0: 0,
1: 0,
5: 0,
100: 0,
},
// Chinese Traditional (zh_TW) - always form 0
"zh_TW": {
1: 0,
5: 0,
0: 0,
1: 0,
5: 0,
100: 0,
},
// Min Nan (nan_Latn_pehoeji) - always form 0
"nan_Latn_pehoeji": {
1: 0,
5: 0,
0: 0,
1: 0,
5: 0,
100: 0,
},
// Additional languages from AvailableLanguages that use default rule
"de_DE": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"el_EL": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"en_US": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"es_ES": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"fi_FI": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"hi_IN": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"it_IT": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
"nl_NL": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
// Test a language not in the switch (should use default rule)
"unknown_language": {
0: 0, // n <= 1
1: 0, // n <= 1
2: 1, // n > 1
},
}
for rule, values := range scenarios {
for input, expected := range values {
result := pluralForms[rule](input)
result := getPluralForm(rule, input)
if result != expected {
t.Errorf(`Unexpected result for %q rule, got %d instead of %d for %d as input`, rule, result, expected, input)
}
+15 -23
View File
@@ -10,8 +10,13 @@ type Printer struct {
language string
}
// NewPrinter creates a new Printer instance for the given language.
func NewPrinter(language string) *Printer {
return &Printer{language}
}
func (p *Printer) Print(key string) string {
if dict, err := GetTranslationDict(p.language); err == nil {
if dict, err := getTranslationDict(p.language); err == nil {
if str, ok := dict[key]; ok {
if translation, ok := str.(string); ok {
return translation
@@ -22,15 +27,12 @@ func (p *Printer) Print(key string) string {
}
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...interface{}) string {
func (p *Printer) Printf(key string, args ...any) string {
translation := key
if dict, err := GetTranslationDict(p.language); err == nil {
str, found := dict[key]
if found {
var valid bool
translation, valid = str.(string)
if !valid {
if dict, err := getTranslationDict(p.language); err == nil {
if str, ok := dict[key]; ok {
if translation, ok = str.(string); !ok {
translation = key
}
}
@@ -41,7 +43,7 @@ func (p *Printer) Printf(key string, args ...interface{}) string {
// Plural returns the translation of the given key by using the language plural form.
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
dict, err := GetTranslationDict(p.language)
dict, err := getTranslationDict(p.language)
if err != nil {
return key
}
@@ -50,22 +52,17 @@ func (p *Printer) Plural(key string, n int, args ...interface{}) string {
var plurals []string
switch v := choices.(type) {
case []interface{}:
case []string:
plurals = v
case []any:
for _, v := range v {
plurals = append(plurals, fmt.Sprint(v))
}
case []string:
plurals = v
default:
return key
}
pluralForm, found := pluralForms[p.language]
if !found {
pluralForm = pluralForms["default"]
}
index := pluralForm(n)
index := getPluralForm(p.language, n)
if len(plurals) > index {
return fmt.Sprintf(plurals[index], args...)
}
@@ -73,8 +70,3 @@ func (p *Printer) Plural(key string, n int, args ...interface{}) string {
return key
}
// NewPrinter creates a new Printer.
func NewPrinter(language string) *Printer {
return &Printer{language}
}
+260 -10
View File
@@ -5,7 +5,7 @@ package locale // import "miniflux.app/v2/internal/locale"
import "testing"
func TestTranslateWithMissingLanguage(t *testing.T) {
func TestPrintfWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Printf("missing.key")
@@ -14,7 +14,7 @@ func TestTranslateWithMissingLanguage(t *testing.T) {
}
}
func TestTranslateWithMissingKey(t *testing.T) {
func TestPrintfWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"k": "v",
@@ -27,7 +27,7 @@ func TestTranslateWithMissingKey(t *testing.T) {
}
}
func TestTranslateWithExistingKey(t *testing.T) {
func TestPrintfWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
@@ -40,7 +40,7 @@ func TestTranslateWithExistingKey(t *testing.T) {
}
}
func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"key": "Test: %s",
@@ -56,7 +56,7 @@ func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
}
}
func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
@@ -72,7 +72,7 @@ func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
}
}
func TestTranslateWithInvalidValue(t *testing.T) {
func TestPrintfWithInvalidValue(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
@@ -88,7 +88,134 @@ func TestTranslateWithInvalidValue(t *testing.T) {
}
}
func TestTranslatePluralWithDefaultRule(t *testing.T) {
func TestPrintWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Print("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"existing.key": "value",
},
}
translation := NewPrinter("en_US").Print("missing.key")
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
},
}
translation := NewPrinter("en_US").Print("auth.username")
if translation != "Login" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestPrintWithDifferentLanguages(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"greeting": "Hello",
},
"fr_FR": translationDict{
"greeting": "Bonjour",
},
"es_ES": translationDict{
"greeting": "Hola",
},
}
tests := []struct {
language string
expected string
}{
{"en_US", "Hello"},
{"fr_FR", "Bonjour"},
{"es_ES", "Hola"},
}
for _, test := range tests {
translation := NewPrinter(test.language).Print("greeting")
if translation != test.expected {
t.Errorf(`Wrong translation for %s, got %q instead of %q`, test.language, translation, test.expected)
}
}
}
func TestPrintWithInvalidTranslationType(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"valid.key": "valid string",
"invalid.key": 12345, // not a string
},
}
printer := NewPrinter("en_US")
// Valid string should work
translation := printer.Print("valid.key")
if translation != "valid string" {
t.Errorf(`Wrong translation for valid key, got %q`, translation)
}
// Invalid type should return the key itself
translation = printer.Print("invalid.key")
if translation != "invalid.key" {
t.Errorf(`Wrong translation for invalid key, got %q`, translation)
}
}
func TestPrintWithNilTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"nil.key": nil,
},
}
translation := NewPrinter("en_US").Print("nil.key")
if translation != "nil.key" {
t.Errorf(`Wrong translation for nil value, got %q`, translation)
}
}
func TestPrintWithEmptyKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"": "empty key translation",
},
}
translation := NewPrinter("en_US").Print("")
if translation != "empty key translation" {
t.Errorf(`Wrong translation for empty key, got %q`, translation)
}
}
func TestPrintWithEmptyTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"empty.value": "",
},
}
translation := NewPrinter("en_US").Print("empty.value")
if translation != "" {
t.Errorf(`Wrong translation for empty value, got %q`, translation)
}
}
func TestPluralWithDefaultRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
@@ -112,7 +239,7 @@ func TestTranslatePluralWithDefaultRule(t *testing.T) {
}
}
func TestTranslatePluralWithRussianRule(t *testing.T) {
func TestPluralWithRussianRule(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"time_elapsed.years": []string{"%d year", "%d years"},
@@ -143,7 +270,7 @@ func TestTranslatePluralWithRussianRule(t *testing.T) {
}
}
func TestTranslatePluralWithMissingTranslation(t *testing.T) {
func TestPluralWithMissingTranslation(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
@@ -157,7 +284,7 @@ func TestTranslatePluralWithMissingTranslation(t *testing.T) {
}
}
func TestTranslatePluralWithInvalidValues(t *testing.T) {
func TestPluralWithInvalidValues(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
@@ -172,3 +299,126 @@ func TestTranslatePluralWithInvalidValues(t *testing.T) {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid_language").Plural("test.key", 2)
expected := "test.key"
if translation != expected {
t.Errorf(`Wrong translation, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithAnySliceType(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"test.key": []any{"%d item", "%d items"},
},
}
printer := NewPrinter("en_US")
translation := printer.Plural("test.key", 1, 1)
expected := "1 item"
if translation != expected {
t.Errorf(`Wrong translation for singular, got %q instead of %q`, translation, expected)
}
translation = printer.Plural("test.key", 2, 2)
expected = "2 items"
if translation != expected {
t.Errorf(`Wrong translation for plural, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithMixedAnySliceTypes(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"mixed.key": []any{"single: %s", "multiple: %s", "many: %s"},
},
}
printer := NewPrinter("en_US")
// Test first element (should convert first any element to string)
translation := printer.Plural("mixed.key", 0, "test") // n=0 uses index 0
expected := "single: test"
if translation != expected {
t.Errorf(`Wrong translation for index 0, got %q instead of %q`, translation, expected)
}
// Test second element (should use plural form)
translation = printer.Plural("mixed.key", 2, "items") // plural form for default language
expected = "multiple: items"
if translation != expected {
t.Errorf(`Wrong translation for index 1, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithIndexOutOfBounds(t *testing.T) {
defaultCatalog = catalog{
"test_lang": translationDict{
"limited.key": []string{"only one form"},
},
}
// Force a scenario where getPluralForm might return an index >= len(plurals)
// We'll create a scenario with Czech language rules
defaultCatalog["cs_CZ"] = translationDict{
"limited.key": []string{"one form only"}, // Only one form, but Czech has 3 plural forms
}
printer := NewPrinter("cs_CZ")
// n=5 should return index 2 for Czech, but we only have 1 form (index 0)
translation := printer.Plural("limited.key", 5)
expected := "limited.key"
if translation != expected {
t.Errorf(`Wrong translation for out of bounds index, got %q instead of %q`, translation, expected)
}
}
func TestPluralWithVariousLanguageRules(t *testing.T) {
defaultCatalog = catalog{
"ar_AR": translationDict{
"items": []string{"no items", "one item", "two items", "few items", "many items", "other items"},
},
"pl_PL": translationDict{
"files": []string{"one file", "few files", "many files"},
},
"ja_JP": translationDict{
"photos": []string{"photos"},
},
}
tests := []struct {
language string
key string
n int
expected string
}{
// Arabic tests
{"ar_AR", "items", 0, "no items"},
{"ar_AR", "items", 1, "one item"},
{"ar_AR", "items", 2, "two items"},
{"ar_AR", "items", 5, "few items"}, // n%100 >= 3 && n%100 <= 10
{"ar_AR", "items", 15, "many items"}, // n%100 >= 11
// Polish tests
{"pl_PL", "files", 1, "one file"},
{"pl_PL", "files", 3, "few files"}, // n%10 >= 2 && n%10 <= 4
{"pl_PL", "files", 5, "many files"}, // default case
// Japanese tests (always uses same form)
{"ja_JP", "photos", 1, "photos"},
{"ja_JP", "photos", 10, "photos"},
}
for _, test := range tests {
printer := NewPrinter(test.language)
translation := printer.Plural(test.key, test.n)
if translation != test.expected {
t.Errorf(`Wrong translation for %s with n=%d, got %q instead of %q`,
test.language, test.n, translation, test.expected)
}
}
}
+34 -26
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
"alert.no_unread_entry": "Es existiert kein ungelesener Artikel.",
"alert.no_user": "Sie sind der einzige Benutzer.",
"alert.pocket_linked": "Ihr Pocket-Konto ist jetzt verknüpft!",
"alert.prefs_saved": "Einstellungen gespeichert!",
"alert.too_many_feeds_refresh": [
"Sie haben zu viele Aktualisierungen ausgelöst. Bitte warten Sie %d Minute, bevor Sie es erneut versuchen.",
@@ -77,6 +76,10 @@
"entry.status.toast.read": "Als gelesen markiert",
"entry.status.toast.unread": "Als ungelesen markiert",
"entry.tags.label": "Stichworte:",
"entry.tags.more_tags_label": [
"Zeige %d weiteres Schlagwort",
"Zeige %d weitere Schlagwörter"
],
"entry.unshare.label": "Nicht teilen",
"error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
"error.bad_credentials": "Benutzername oder Passwort ungültig.",
@@ -114,9 +117,11 @@
"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_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_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.invalid_categories_sorting_order": "Ungültige Kategorie-Sortierreihenfolge.",
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
"error.invalid_display_mode": "Progressive-Web-App- (PWA-)Anzeigemodus",
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
"error.invalid_entry_order": "Ungültige Sortierreihenfolge.",
"error.invalid_feed_proxy_url": "Ungültige Proxy-URL.",
"error.invalid_feed_url": "Ungültiger Feed-URL.",
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
@@ -127,8 +132,6 @@
"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.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
"error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
"error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
"error.proxy_url_not_empty": "Die Proxy-URL darf nicht leer sein.",
"error.settings_block_rule_fieldname_invalid": "Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
"error.settings_block_rule_invalid_regex": "Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Regeln",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
"form.feed.label.blocklist_rules": "Blockierregeln",
"form.feed.label.block_filter_entry_rules": "Eintrags-Sperrregeln",
"form.feed.label.blocklist_rules": "Regex-basierte Sperrfilter",
"form.feed.label.category": "Kategorie",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.crawler": "Originalinhalt herunterladen",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Den auf Anwendungsebene konfigurierten Proxy verwenden",
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
"form.feed.label.keep_filter_entry_rules": "Eintrags-Erlaubnisregeln",
"form.feed.label.keeplist_rules": "Regex-basierte Behalte-Filter",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
@@ -197,7 +202,7 @@
"form.feed.label.pushover_max_priority": "Höchste Pushoverpriorität",
"form.feed.label.pushover_min_priority": "Niedrigste Pushoverpriorität",
"form.feed.label.pushover_priority": "Pushover-Nachrichtenpriorität",
"form.feed.label.rewrite_rules": "Umschreiberegeln",
"form.feed.label.rewrite_rules": "Inhalts-Umschreibregeln",
"form.feed.label.scraper_rules": "Extraktionsregeln",
"form.feed.label.site_url": "URL der Webseite",
"form.feed.label.title": "Titel",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
"form.integration.instapaper_password": "Instapaper-Passwort",
"form.integration.instapaper_username": "Instapaper-Benutzername",
"form.integration.karakeep_activate": "Einträge in Karakeep speichern",
"form.integration.karakeep_api_key": "Karakeep-API-Schlüssel",
"form.integration.karakeep_url": "Karakeep-API-Endpunkt",
"form.integration.linkace_activate": "Einträge in LinkAce speichern",
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Linkding-Tags",
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
"form.integration.linkwarden_endpoint": "Linkwarden-API-Endpunkt",
"form.integration.linkwarden_endpoint": "Linkwarden-Base-URL",
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
"form.integration.pinboard_tags": "Pinboard-Tags",
"form.integration.pinboard_token": "Pinboard-API-Token",
"form.integration.pocket_access_token": "Pocket-Zugangstoken",
"form.integration.pocket_activate": "Einträge in Pocket speichern",
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
"form.integration.pocket_consumer_key": "Pocket-Verbraucher-Schlüssel",
"form.integration.pushover_activate": "Einträge an Pushover senden",
"form.integration.pushover_device": "Pushovergerät (optional)",
"form.integration.pushover_prefix": "Pushover-URL-Präfix (optional)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
"form.integration.rssbridge_token": "RSS-Bridge-Authentifizierungs-Token",
"form.integration.rssbridge_url": "RSS-Bridge-Server-URL",
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
"form.integration.shaarli_api_secret": "Shaarli-API-Geheimnis",
@@ -325,6 +330,7 @@
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Artikel immer mit Öffnen der Links lesen",
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
"form.prefs.label.cjk_reading_speed": "Lesegeschwindigkeit für Chinesisch, Koreanisch und Japanisch (Zeichen pro Minute)",
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"form.prefs.label.open_external_links_in_new_tab": "Externe Links in einem neuen Tab öffnen (fügt target=\"_blank\" zu Links hinzu)",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Zeitzone",
@@ -398,13 +405,14 @@
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
"menu.starred": "Lesezeichen",
"menu.title": "Menu",
"menu.title": "Menü",
"menu.unread": "Ungelesen",
"menu.users": "Benutzer",
"page.about.author": "Autor:",
"page.about.build_date": "Datum der Kompilierung:",
"page.about.credits": "Urheberrechte",
"page.about.db_usage": "Datenbankgröße:",
"page.about.git_commit": "Git-Commit:",
"page.about.global_config_options": "Globale Konfigurationsoptionen",
"page.about.go_version": "Go-Version:",
"page.about.license": "Lizenz:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Zuletzt verwendeten",
"page.api_keys.table.token": "Zeichen",
"page.api_keys.title": "API-Schlüssel",
"page.categories_count": [
"%d Kategorie",
"%d Kategorien"
],
"page.categories.entries": "Artikel",
"page.categories.feed_count": [
"Es gibt %d Abonnement.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Abonnements",
"page.categories.no_feed": "Kein Abonnement.",
"page.categories.title": "Kategorien",
"page.categories_count": [
"%d Kategorie",
"%d Kategorien"
],
"page.category_label": "Kategorie: %s",
"page.edit_category.title": "Kategorie bearbeiten: %s",
"page.edit_feed.etag_header": "ETag-Kopfzeile:",
@@ -500,8 +508,8 @@
"page.keyboard_shortcuts.title": "Tastenkürzel",
"page.keyboard_shortcuts.toggle_bookmark_status": "Lesezeichen hinzufügen/entfernen",
"page.keyboard_shortcuts.toggle_entry_attachments": "Artikelanhänge öffnen/schließen",
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, fokus als nächstes",
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, fokus vorherige",
"page.keyboard_shortcuts.toggle_read_status_next": "Gewählten Artikel als gelesen/ungelesen markieren, nächsten auswählen",
"page.keyboard_shortcuts.toggle_read_status_prev": "Gewählten Artikel als gelesen/ungelesen markieren, vorherigen auswählen",
"page.login.google_signin": "Anmeldung mit Google",
"page.login.oidc_signin": "Anmeldung mit %s",
"page.login.title": "Anmeldung",
@@ -522,7 +530,7 @@
"page.sessions.table.actions": "Aktionen",
"page.sessions.table.current_session": "Aktuelle Sitzung",
"page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP-Addresse",
"page.sessions.table.ip": "IP-Adresse",
"page.sessions.table.user_agent": "Benutzeragent",
"page.sessions.title": "Sitzungen",
"page.settings.link_google_account": "Google-Konto verknüpfen",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.shared_entries.title": "Geteilte Artikel",
"page.shared_entries_count": [
"%d geteilter Artikel",
"%d geteilte Artikel"
],
"page.shared_entries.title": "Geteilte Artikel",
"page.starred.title": "Lesezeichen",
"page.starred_entry_count": [
"%d Lesezeichen",
"%d Lesezeichen"
],
"page.starred.title": "Lesezeichen",
"page.total_entry_count": [
"%d Artikel insgesamt",
"%d Artikel insgesamt"
],
"page.unread.title": "Ungelesen",
"page.unread_entry_count": [
"%d ungelesener Artikel",
"%d ungelesene Artikel"
],
"page.unread.title": "Ungelesen",
"page.users.actions": "Aktionen",
"page.users.admin.no": "Nein",
"page.users.admin.yes": "Ja",
@@ -569,13 +577,13 @@
"page.users.title": "Benutzer",
"page.users.username": "Benutzername",
"page.webauthn_rename.title": "Passkey umbenennen",
"pagination.first": "First",
"pagination.last": "Last",
"pagination.first": "Erste",
"pagination.last": "Letzte",
"pagination.next": "Nächste",
"pagination.previous": "Vorherige",
"search.label": "Suche",
"search.placeholder": "Suche...",
"search.submit": "Search",
"search.submit": "Suchen",
"skip_to_content": "Zum Inhalt springen",
"time_elapsed.days": [
"vor %d Tag",
@@ -606,4 +614,4 @@
"time_elapsed.yesterday": "gestern",
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
"tooltip.logged_user": "Angemeldet als %s"
}
}
+195 -187
View File
@@ -13,7 +13,7 @@
"action.update": "Ενημέρωση",
"alert.account_linked": "Ο εξωτερικός σας λογαριασμός είναι πλέον συνδεδεμένος!",
"alert.account_unlinked": "Ο εξωτερικός σας λογαριασμός είναι πλέον αποσυνδεδεμένος!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Όλες οι ροές ανανεώνονται στο παρασκήνιο. Μπορείτε να συνεχίσετε να χρησιμοποιείτε το Miniflux όσο εκτελείται αυτή η διαδικασία.",
"alert.feed_error": "Υπάρχει πρόβλημα με αυτήν τη ροή",
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
@@ -27,26 +27,25 @@
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_unread_entry": "Δεν υπάρχουν μη αναγνωσμένα άρθρα.",
"alert.no_user": "Είστε ο μόνος χρήστης.",
"alert.pocket_linked": "Ο λογαριασμός Pocket είναι τώρα συνδεδεμένος!",
"alert.prefs_saved": "Οι προτιμήσεις αποθηκεύτηκαν!",
"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."
"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτό πριν προσπαθήσετε ξανά.",
"Έχετε ενεργοποιήσει πάρα πολλές ανανεώσεις ροών. Παρακαλώ περιμένετε %d λεπτά πριν προσπαθήσετε ξανά."
],
"confirm.loading": "Σε εξέλιξη...",
"confirm.no": "όχι",
"confirm.question": "Είστε σίγουροι;",
"confirm.question.refresh": "Θέλετε να επιτελέσετε μια υποχρεωτική ανανέωση;",
"confirm.yes": "ναι",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "Αναζήτηση:",
"enclosure_media_controls.seek.title": "Αναζήτηση %s δευτερόλεπτα",
"enclosure_media_controls.speed": "Ταχύτητα:",
"enclosure_media_controls.speed.faster": "Γρηγορότερα",
"enclosure_media_controls.speed.faster.title": "Γρηγορότερα κατά %sx",
"enclosure_media_controls.speed.reset": "Επαναφορά",
"enclosure_media_controls.speed.reset.title": "Επαναφορά ταχύτητας σε 1x",
"enclosure_media_controls.speed.slower": "Πιο αργά",
"enclosure_media_controls.speed.slower.title": "Πιο αργά κατά %sx",
"entry.bookmark.toast.off": "Μη αγαπημένα",
"entry.bookmark.toast.on": "Αγαπημένα",
"entry.bookmark.toggle.off": "Αναίρεση αγαπημένου",
@@ -77,80 +76,84 @@
"entry.status.toast.read": "Επισήμανση ως αναγνωσμένο",
"entry.status.toast.unread": "Επισήμανση ως μη αναγνωσμένο",
"entry.tags.label": "Ετικέτες:",
"entry.tags.more_tags_label": [
"Εμφάνιση %d ακόμη ετικέτας",
"Εμφάνιση %d ακόμη ετικετών"
],
"entry.unshare.label": "Aναίρεση Διαμοιρασμού",
"error.api_key_already_exists": "Αυτό το κλειδί API υπάρχει ήδη.",
"error.bad_credentials": "Μη έγκυρο όνομα χρήστη ή κωδικό πρόσβασης.",
"error.category_already_exists": "Αυτή η κατηγορία υπάρχει ήδη.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.database_error": "Σφάλμα βάσης δεδομένων: %v.",
"error.different_passwords": "Οι κωδικοί πρόσβασης δεν είναι οι ίδιοι.",
"error.duplicate_fever_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Fever!",
"error.duplicate_googlereader_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Google Reader!",
"error.duplicate_linked_account": "Υπάρχει ήδη κάποιος που σχετίζεται με αυτόν τον πάροχο!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "Αυτή η ροή υπάρχει ήδη.",
"error.empty_file": "Αυτό το αρχείο είναι κενό.",
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
"error.feed_category_not_found": "Αυτή η κατηγορία δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "Δεν είναι δυνατή η ανίχνευση της μορφής ροής: %v.",
"error.feed_invalid_blocklist_rule": "Ο κανόνας λίστας μπλοκ δεν είναι έγκυρος.",
"error.feed_invalid_keeplist_rule": "Ο κανόνας keep list δεν είναι έγκυρος.",
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Αυτή η ροή δεν υπάρχει ή δεν ανήκει σε αυτόν τον χρήστη.",
"error.feed_title_not_empty": "Ο τίτλος ροής δεν μπορεί να είναι κενός.",
"error.feed_url_not_empty": "Η διεύθυνση URL ροής δεν μπορεί να είναι κενή.",
"error.fields_mandatory": "Όλα τα πεδία είναι υποχρεωτικά.",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"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_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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.http_bad_gateway": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_body_read": "Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.",
"error.http_client_error": "Σφάλμα πελάτη HTTP: %v.",
"error.http_empty_response": "Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;",
"error.http_empty_response_body": "Το σώμα απάντησης HTTP είναι κενό.",
"error.http_forbidden": "Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;",
"error.http_gateway_timeout": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος χρονικού ορίου πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_internal_server_error": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_not_authorized": "Η πρόσβαση σε αυτόν τον ιστότοπο δεν είναι εξουσιοδοτημένη. Μπορεί να είναι λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης.",
"error.http_resource_not_found": "Ο ζητούμενος πόρος δεν βρέθηκε. Επαληθεύστε τη διεύθυνση URL.",
"error.http_response_too_large": "Η απάντηση HTTP είναι πολύ μεγάλη. Μπορείτε να αυξήσετε το όριο μεγέθους απάντησης HTTP στις καθολικές ρυθμίσεις (απαιτεί επανεκκίνηση του διακομιστή).",
"error.http_service_unavailable": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω εσωτερικού σφάλματος διακομιστή. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.http_too_many_requests": "Το Miniflux δημιούργησε πάρα πολλά αιτήματα σε αυτόν τον ιστότοπο. Παρακαλώ δοκιμάστε ξανά αργότερα ή αλλάξτε τη διαμόρφωση της εφαρμογής.",
"error.http_unexpected_status_code": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω μη αναμενόμενου κωδικού κατάστασης HTTP: %d. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"error.invalid_categories_sorting_order": "Η κατηγορία δεν μπορεί να είναι κενή.",
"error.invalid_default_home_page": "Μη έγκυρη προεπιλεγμένη αρχική σελίδα!",
"error.invalid_display_mode": "Μη έγκυρη λειτουργία εμφάνισης εφαρμογών ιστού.",
"error.invalid_entry_direction": "Μη έγκυρη κατεύθυνση ταξινόμησης άρθρων.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Η σειρά των καταχωρήσεων είναι μη έγκυρη.",
"error.invalid_feed_proxy_url": "Μη έγκυρη διεύθυνση URL διακομιστή μεσολάβησης.",
"error.invalid_feed_url": "Μη έγκυρη διεύθυνση URL ροής.",
"error.invalid_gesture_nav": "Μη έγκυρη πλοήγηση με χειρονομίες.",
"error.invalid_language": "Μη έγκυρη γλώσσα.",
"error.invalid_site_url": "Μη έγκυρη διεύθυνση URL ιστότοπου.",
"error.invalid_theme": "Μη έγκυρο θέμα.",
"error.invalid_timezone": "Μη έγκυρη ζώνη ώρας.",
"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.network_operation": "Το Miniflux δεν μπορεί να φτάσει σε αυτόν τον ιστότοπο λόγω σφάλματος δικτύου: %v.",
"error.network_timeout": "Αυτός ο ιστότοπος είναι πολύ αργός και το αίτημα έληξε: %v",
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
"error.pocket_access_token": "Δεν είναι δυνατή η λήψη του access token από το Pocket!",
"error.pocket_request_token": "Δεν είναι δυνατή η λήψη του request token από το Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "Η διεύθυνση URL του διακομιστή μεσολάβησης δεν μπορεί να είναι κενή.",
"error.settings_block_rule_fieldname_invalid": "Μη έγκυρος κανόνας αποκλεισμού: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)",
"error.settings_block_rule_invalid_regex": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση",
"error.settings_block_rule_regex_required": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d δεν παρέχεται",
"error.settings_block_rule_separator_required": "Μη έγκυρος κανόνας αποκλεισμού: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='",
"error.settings_invalid_domain_list": "Μη έγκυρη λίστα τομέων. Παρακαλώ δώστε μια λίστα τομέων διαχωρισμένων με κενό.",
"error.settings_keep_rule_fieldname_invalid": "Μη έγκυρος κανόνας διατήρησης: ο κανόνας #%d λείπει ένα έγκυρο όνομα πεδίου (Επιλογές: %s)",
"error.settings_keep_rule_invalid_regex": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν είναι έγκυρη κανονική έκφραση",
"error.settings_keep_rule_regex_required": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d δεν παρέχεται",
"error.settings_keep_rule_separator_required": "Μη έγκυρος κανόνας διατήρησης: το μοτίβο του κανόνα #%d απαιτείται να διαχωρίζεται με ένα '='",
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
"error.site_url_not_empty": "Η διεύθυνση URL του ιστότοπου δεν μπορεί να είναι κενή.",
"error.subscription_not_found": "Δεν είναι δυνατή η εύρεση συνδρομής.",
"error.title_required": "Ο τίτλος είναι υποχρεωτικός.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "Σφάλμα TLS: %q. Μπορείτε να απενεργοποιήσετε την επαλήθευση TLS στις ρυθμίσεις ροής εάν το επιθυμείτε.",
"error.unable_to_create_api_key": "Δεν είναι δυνατή η δημιουργία αυτού του κλειδιού API.",
"error.unable_to_create_category": "Δεν είναι δυνατή η δημιουργία αυτής της κατηγορίας.",
"error.unable_to_create_user": "Δεν είναι δυνατή η δημιουργία αυτού του χρήστη.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "Δεν είναι δυνατή η ανίχνευση ροής με χρήση RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Δεν είναι δυνατή η ανάλυση αυτής της ροής: %v.",
"error.unable_to_update_category": "Δεν είναι δυνατή η ενημέρωση αυτής της κατηγορίας.",
"error.unable_to_update_feed": "Δεν είναι δυνατή η ενημέρωση αυτής της ροής.",
"error.unable_to_update_user": "Δεν είναι δυνατή η ενημέρωση αυτού του χρήστη.",
@@ -160,18 +163,19 @@
"form.api_key.label.description": "Ετικέτα κλειδιού API",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.category.label.title": "Τίτλος",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.general": "Γενικά",
"form.feed.fieldset.integration": "Υπηρεσίες τρίτων",
"form.feed.fieldset.network_settings": "Ρυθμίσεις δικτύου",
"form.feed.fieldset.rules": "Κανόνες",
"form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Κανόνες Αποκλεισμού",
"form.feed.label.apprise_service_urls": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
"form.feed.label.block_filter_entry_rules": "Κανόνες Αποκλεισμού Καταχωρήσεων",
"form.feed.label.blocklist_rules": "Φίλτρα Αποκλεισμού Βασισμένα σε Regex",
"form.feed.label.category": "Κατηγορία",
"form.feed.label.cookie": "Ορισμός Cookies",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "Απενεργοποίηση HTTP/2 για αποφυγή δακτυλικών αποτυπωμάτων",
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
"form.feed.label.feed_password": "Κωδικός Πρόσβασης ροής",
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
@@ -179,43 +183,44 @@
"form.feed.label.fetch_via_proxy": "Χρησιμοποιήστε τον διακομιστή μεσολάβησης που έχει ρυθμιστεί σε επίπεδο εφαρμογής",
"form.feed.label.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
"form.feed.label.keeplist_rules": "Κρατήστε Κανόνες",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Κανόνες Μετατροπής",
"form.feed.label.keep_filter_entry_rules": "Κανόνες Επιτρεπόμενων Καταχωρήσεων",
"form.feed.label.keeplist_rules": "Φίλτρα Διατήρησης Βασισμένα σε Regex",
"form.feed.label.no_media_player": "Χωρίς πρόγραμμα αναπαραγωγής πολυμέσων (ήχος/βίντεο)",
"form.feed.label.ntfy_activate": "Προώθηση καταχωρήσεων στο ntfy",
"form.feed.label.ntfy_default_priority": "Προεπιλεγμένη προτεραιότητα Ntfy",
"form.feed.label.ntfy_high_priority": "Υψηλή προτεραιότητα Ntfy",
"form.feed.label.ntfy_low_priority": "Χαμηλή προτεραιότητα Ntfy",
"form.feed.label.ntfy_max_priority": "Μέγιστη προτεραιότητα Ntfy",
"form.feed.label.ntfy_min_priority": "Ελάχιστη προτεραιότητα Ntfy",
"form.feed.label.ntfy_priority": "Προτεραιότητα Ntfy",
"form.feed.label.ntfy_topic": "Θέμα Ntfy (προαιρετικό)",
"form.feed.label.proxy_url": "Διεύθυνση URL διακομιστή μεσολάβησης",
"form.feed.label.pushover_activate": "Προώθηση καταχωρήσεων στο pushover.net",
"form.feed.label.pushover_default_priority": "Προεπιλεγμένη προτεραιότητα Pushover",
"form.feed.label.pushover_high_priority": "Υψηλή προτεραιότητα Pushover",
"form.feed.label.pushover_low_priority": "Χαμηλή προτεραιότητα Pushover",
"form.feed.label.pushover_max_priority": "Μέγιστη προτεραιότητα Pushover",
"form.feed.label.pushover_min_priority": "Ελάχιστη προτεραιότητα Pushover",
"form.feed.label.pushover_priority": "Προτεραιότητα μηνύματος Pushover",
"form.feed.label.rewrite_rules": "Κανόνες Επανασύνταξης Περιεχομένου",
"form.feed.label.scraper_rules": "Κανόνες Scraper",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.title": "Τίτλος",
"form.feed.label.urlrewrite_rules": "επανεγγραφή κανόνων για τη διεύθυνση URL.",
"form.feed.label.urlrewrite_rules": "κανόνες επανεγγραφής για τη διεύθυνση URL.",
"form.feed.label.user_agent": "Παράκαμψη Προεπιλεγμένου User Agent Χρήστη",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Παράκαμψη διεύθυνσης URL webhook",
"form.import.label.file": "Αρχείο OPML",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.import.label.url": "Διεύθυνση URL",
"form.integration.apprise_activate": "Προώθηση καταχωρήσεων στο Apprise",
"form.integration.apprise_services_url": "Λίστα διευθύνσεων URL υπηρεσιών Apprise διαχωρισμένων με κόμμα",
"form.integration.apprise_url": "Διεύθυνση URL API Apprise",
"form.integration.betula_activate": "Αποθήκευση καταχωρήσεων στο Betula",
"form.integration.betula_token": "Διακριτικό Betula",
"form.integration.betula_url": "Διεύθυνση URL διακομιστή Betula",
"form.integration.cubox_activate": "Αποθήκευση καταχωρήσεων στο Cubox",
"form.integration.cubox_api_link": "Σύνδεσμος API Cubox",
"form.integration.discord_activate": "Προώθηση καταχωρήσεων στο Discord",
"form.integration.discord_webhook_link": "Σύνδεσμος Webhook Discord",
"form.integration.espial_activate": "Αποθήκευση άρθρων στο Espial",
"form.integration.espial_api_key": "Κλειδί API Espial",
"form.integration.espial_endpoint": "Τελικό σημείο Espial API",
@@ -231,36 +236,39 @@
"form.integration.instapaper_activate": "Αποθήκευση άρθρων στο Instapaper",
"form.integration.instapaper_password": "Κωδικός Πρόσβασης Instapaper",
"form.integration.instapaper_username": "Όνομα Χρήστη Instapaper",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.karakeep_activate": "Αποθήκευση άρθρων στο Karakeep",
"form.integration.karakeep_api_key": "Κλειδί API Karakeep",
"form.integration.karakeep_url": "Τελικό σημείο Karakeep API",
"form.integration.linkace_activate": "Αποθήκευση καταχωρήσεων στο LinkAce",
"form.integration.linkace_api_key": "Κλειδί API LinkAce",
"form.integration.linkace_check_disabled": "Απενεργοποίηση ελέγχου συνδέσμου",
"form.integration.linkace_endpoint": "Τελικό σημείο API LinkAce",
"form.integration.linkace_is_private": "Σήμανση συνδέσμου ως ιδιωτικού",
"form.integration.linkace_tags": "Ετικέτες LinkAce",
"form.integration.linkding_activate": "Αποθήκευση άρθρων στο Linkding",
"form.integration.linkding_api_key": "Κλειδί API Linkding",
"form.integration.linkding_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.linkding_endpoint": "Τελικό σημείο Linkding API",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_tags": "Ετικέτες Linkding",
"form.integration.linkwarden_activate": "Αποθήκευση άρθρων στο Linkwarden",
"form.integration.linkwarden_api_key": "Κλειδί API Linkwarden",
"form.integration.linkwarden_endpoint": "Τελικό σημείο Linkwarden API",
"form.integration.linkwarden_endpoint": "URL βάσης Linkwarden",
"form.integration.matrix_bot_activate": "Μεταφορά νέων άρθρων στο Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_user": "Όνομα χρήστη για το Matrix",
"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.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.notion_activate": "Αποθήκευση καταχωρήσεων στο Notion",
"form.integration.notion_page_id": "Αναγνωριστικό σελίδας Notion",
"form.integration.notion_token": "Μυστικό διακριτικό Notion",
"form.integration.ntfy_activate": "Προώθηση καταχωρήσεων στο ntfy",
"form.integration.ntfy_api_token": "Διακριτικό API Ntfy (προαιρετικό)",
"form.integration.ntfy_icon_url": "Διεύθυνση URL εικονιδίου Ntfy (προαιρετικό)",
"form.integration.ntfy_internal_links": "Χρήση εσωτερικών συνδέσμων με κλικ (προαιρετικό)",
"form.integration.ntfy_password": "Κωδικός πρόσβασης Ntfy (προαιρετικό)",
"form.integration.ntfy_topic": "Θέμα Ntfy (προεπιλογή χρησιμοποιείται εάν δεν οριστεί στη ροή)",
"form.integration.ntfy_url": "Διεύθυνση URL Ntfy (προαιρετικό, προεπιλογή είναι ntfy.sh)",
"form.integration.ntfy_username": "Όνομα χρήστη Ntfy (προαιρετικό)",
"form.integration.nunux_keeper_activate": "Αποθήκευση άρθρων στο Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Κλειδί API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Τελικό σημείο Nunux Keeper API",
@@ -270,46 +278,43 @@
"form.integration.pinboard_activate": "Αποθήκευση άρθρων στο Pinboard",
"form.integration.pinboard_bookmark": "Σημείωση του σελιδοδείκτη ως μη αναγνωσμένου",
"form.integration.pinboard_tags": "Ετικέτες Pinboard",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_activate": "Αποθήκευση άρθρων στο Pocket",
"form.integration.pocket_connect_link": "Συνδέστε τον λογαριασμό Pocket σας",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.pinboard_token": "Διακριτικό API Pinboard",
"form.integration.pushover_activate": "Προώθηση καταχωρήσεων στο Pushover",
"form.integration.pushover_device": "Συσκευή Pushover (προαιρετικό)",
"form.integration.pushover_prefix": "Πρόθεμα διεύθυνσης URL Pushover (προαιρετικό)",
"form.integration.pushover_token": "Διακριτικό API εφαρμογής Pushover",
"form.integration.pushover_user": "Κλειδί χρήστη Pushover",
"form.integration.raindrop_activate": "Αποθήκευση καταχωρήσεων στο Raindrop",
"form.integration.raindrop_collection_id": "Αναγνωριστικό συλλογής",
"form.integration.raindrop_tags": "Ετικέτες (διαχωρισμένες με κόμμα)",
"form.integration.raindrop_token": "Διακριτικό (Δοκιμή)",
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_api_key": "Κλειδί API Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_labels": "Ετικέτες Readeck",
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"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.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.readwise_activate": "Αποθήκευση καταχωρήσεων στο Readwise Reader",
"form.integration.readwise_api_key": "Διακριτικό πρόσβασης Readwise Reader",
"form.integration.readwise_api_key_link": "Λήψη του διακριτικού πρόσβασης Readwise",
"form.integration.rssbridge_activate": "Έλεγχος RSS-Bridge κατά την προσθήκη συνδρομών",
"form.integration.rssbridge_token": "Διακριτικό ελέγχου ταυτότητας RSS-Bridge",
"form.integration.rssbridge_url": "Διεύθυνση URL διακομιστή RSS-Bridge",
"form.integration.shaarli_activate": "Αποθήκευση άρθρων στο Shaarli",
"form.integration.shaarli_api_secret": "Μυστικό API Shaarli",
"form.integration.shaarli_endpoint": "Διεύθυνση URL Shaarli",
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
"form.integration.shiori_password": "Κωδικός Πρόσβασης Shiori",
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",
"form.integration.slack_activate": "Push entries to Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.slack_activate": "Προώθηση καταχωρήσεων στο Slack",
"form.integration.slack_webhook_link": "Σύνδεσμος Webhook Slack",
"form.integration.telegram_bot_activate": "Προωθήστε νέα άρθρα στη συνομιλία Telegram",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_buttons": "Απενεργοποίηση κουμπιών",
"form.integration.telegram_bot_disable_notification": "Απενεργοποίηση ειδοποίησης",
"form.integration.telegram_bot_disable_web_page_preview": "Απενεργοποίηση προεπισκόπησης ιστοσελίδας",
"form.integration.telegram_bot_token": "Διακριτικό bot",
"form.integration.telegram_chat_id": "Αναγνωριστικό συνομιλίας",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_topic_id": "Αναγνωριστικό θέματος",
"form.integration.wallabag_activate": "Αποθήκευση άρθρων στο Wallabag",
"form.integration.wallabag_client_id": "Ταυτότητα πελάτη Wallabag",
"form.integration.wallabag_client_secret": "Wallabag Μυστικό Πελάτη",
@@ -317,14 +322,15 @@
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
"form.integration.wallabag_password": "Wallabag Κωδικός Πρόσβασης",
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.integration.webhook_activate": "Ενεργοποίηση Webhooks",
"form.integration.webhook_secret": "Μυστικό Webhooks",
"form.integration.webhook_url": "Προεπιλεγμένη διεύθυνση URL Webhook",
"form.prefs.fieldset.application_settings": "Ρυθμίσεις εφαρμογής",
"form.prefs.fieldset.authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας",
"form.prefs.fieldset.global_feed_settings": "Καθολικές ρυθμίσεις ροής",
"form.prefs.fieldset.reader_settings": "Ρυθμίσεις αναγνώστη",
"form.prefs.help.external_font_hosts": "Λίστα εξωτερικών κεντρικών υπολογιστών γραμματοσειρών διαχωρισμένων με κενό για να επιτρέπονται. Για παράδειγμα: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Ανάγνωση άρθρων ανοίγοντας εξωτερικούς συνδέσμους",
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
"form.prefs.label.cjk_reading_speed": "Ταχύτητα ανάγνωσης για κινέζικα, κορεάτικα και ιαπωνικά (χαρακτήρες ανά λεπτό)",
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
@@ -336,15 +342,16 @@
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
"form.prefs.label.entry_sorting": "Ταξινόμηση",
"form.prefs.label.entry_swipe": "Ενεργοποιήστε το σάρωση καταχώρισης στις οθόνες αφής",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Εξωτερικοί κεντρικοί υπολογιστές γραμματοσειρών",
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
"form.prefs.label.keyboard_shortcuts": "Ενεργοποίηση συντομεύσεων πληκτρολογίου",
"form.prefs.label.language": "Γλώσσα",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Σήμανση καταχωρήσεων ως αναγνωσμένων με μη αυτόματο τρόπο",
"form.prefs.label.mark_read_on_media_completion": "Σήμανση ως αναγνωσμένου μόνο όταν η αναπαραγωγή ήχου/βίντεο φτάσει το 90%% ολοκλήρωσης",
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_view_or_media_completion": "Σήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή. Για ήχο/βίντεο, σήμανση ως αναγνωσμένου στο 90%% ολοκλήρωσης",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"form.prefs.label.open_external_links_in_new_tab": "Άνοιγμα εξωτερικών συνδέσμων σε νέα καρτέλα (προσθέτει target=\"_blank\" στους συνδέσμους)",
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
"form.prefs.label.theme": "Θέμα",
"form.prefs.label.timezone": "Ζώνη Ώρας",
@@ -381,7 +388,7 @@
"menu.feeds": "Ροές",
"menu.flush_history": "Εκκαθάριση ιστορικού",
"menu.history": "Ιστορικό",
"menu.home_page": "Home page",
"menu.home_page": "Αρχική σελίδα",
"menu.import": "Εισαγωγή",
"menu.integrations": "Ενσωμάτωσεις",
"menu.logout": "Αποσύνδεση",
@@ -398,13 +405,14 @@
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
"menu.starred": "Αγαπημένα",
"menu.title": "Menu",
"menu.title": "Μενού",
"menu.unread": "Μη αναγνωσμένα",
"menu.users": "Χρήστες",
"page.about.author": "Συγγραφέας:",
"page.about.build_date": "Ημερομηνία Κατασκευής:",
"page.about.credits": "Συνεισφέροντες",
"page.about.db_usage": "Database size:",
"page.about.db_usage": "Μέγεθος βάσης δεδομένων:",
"page.about.git_commit": "Υποβολή Git:",
"page.about.global_config_options": "Γενικές ρυθμίσεις",
"page.about.go_version": "Έκδοση Go:",
"page.about.license": "Άδεια:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Τελευταία Χρήση",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Κλειδιά API",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories.entries": "Άρθρα",
"page.categories.feed_count": [
"Υπάρχει μία %d ροή.",
@@ -436,7 +440,11 @@
"page.categories.feeds": "Συνδρομές",
"page.categories.no_feed": "Καμία ροή.",
"page.categories.title": "Κατηγορίες",
"page.category_label": "Category: %s",
"page.categories_count": [
"%d κατηγορία",
"%d κατηγορίες"
],
"page.category_label": "Κατηγορία: %s",
"page.edit_category.title": "Επεξεργασία κατηγορίας: % s",
"page.edit_feed.etag_header": "Κεφαλίδα ETag:",
"page.edit_feed.last_check": "Τελευταίος έλεγχος:",
@@ -451,7 +459,7 @@
"%d σφάλματα"
],
"page.feeds.last_check": "Τελευταίος έλεγχος:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Επόμενος έλεγχος:",
"page.feeds.read_counter": "Αριθμός αναγνωσμένων καταχωρήσεων",
"page.feeds.title": "Ροές",
"page.history.title": "Ιστορικό",
@@ -499,7 +507,7 @@
"page.keyboard_shortcuts.subtitle.sections": "Πλοήγηση Τμημάτων",
"page.keyboard_shortcuts.title": "Συντομεύσεις Πληκτρολογίου",
"page.keyboard_shortcuts.toggle_bookmark_status": "Εναλλαγή σελιδοδείκτη",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "Εναλλαγή άνοιγμα/κλείσιμο συνημμένων καταχώρησης",
"page.keyboard_shortcuts.toggle_read_status_next": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στη συνέχεια",
"page.keyboard_shortcuts.toggle_read_status_prev": "Εναλλαγή ανάγνωσης / μη αναγνωσμένης, εστίαση στο προηγούμενο",
"page.login.google_signin": "Συνδεθείτε με τo Google",
@@ -507,7 +515,7 @@
"page.login.title": "Είσοδος",
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login.help": "Παρακαλώ εισαγάγετε το όνομα χρήστη σας εάν χρησιμοποιείτε κλειδί ασφαλείας. Αυτό δεν απαιτείται εάν χρησιμοποιείτε Passkey (ανακαλύψιμα διαπιστευτήρια).",
"page.new_api_key.title": "Νέο κλειδί API",
"page.new_category.title": "Νέα Κατηγορία",
"page.new_user.title": "Νέος Χρήστης",
@@ -515,8 +523,8 @@
"page.offline.refresh_page": "Προσπαθήστε να ανανεώσετε τη σελίδα",
"page.offline.title": "Λειτουργία Εκτός Σύνδεσης",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d αναγνωσμένη καταχώρηση",
"%d αναγνωσμένες καταχωρήσεις"
],
"page.search.title": "Αποτελέσματα Αναζήτησης",
"page.sessions.table.actions": "Eνέργειες",
@@ -530,36 +538,36 @@
"page.settings.title": "Ρυθμίσεις",
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου %s",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.actions": "Ενέργειες",
"page.settings.webauthn.added_on": "Προστέθηκε στις",
"page.settings.webauthn.delete": [
"Αφαιρέστε %d κωδικό πρόσβασης",
"Καταργήστε %d κωδικούς πρόσβασης"
"Κατάργηση %d κωδικού πρόσβασης",
"Κατάργηση %d κωδικών πρόσβασης"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.last_seen_on": "Τελευταία χρήση",
"page.settings.webauthn.passkey_name": "Όνομα κωδικού πρόσβασης",
"page.settings.webauthn.passkeys": "Κωδικοί πρόσβασης",
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries.title": "Κοινόχρηστες Καταχωρήσεις",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"page.shared_entries_count": [
"%d κοινόχρηστη καταχώρηση",
"%d κοινόχρηστες καταχωρήσεις"
],
"page.starred.title": "Αγαπημένo",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"page.starred_entry_count": [
"%d καταχώρηση με αστέρι",
"%d καταχωρήσεις με αστέρι"
],
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"page.total_entry_count": [
"%d καταχώρηση συνολικά",
"%d καταχωρήσεις συνολικά"
],
"page.unread.title": "Μη αναγνωσμένα",
"page.unread_entry_count": [
"%d μη αναγνωσμένη καταχώρηση",
"%d μη αναγνωσμένες καταχωρήσεις"
],
"page.users.actions": "Eνέργειες",
"page.users.admin.no": "Όχι",
"page.users.admin.yes": "Ναι.",
@@ -568,15 +576,15 @@
"page.users.never_logged": "Ποτέ",
"page.users.title": "Χρήστες",
"page.users.username": "Χρήστης",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"page.webauthn_rename.title": "Μετονομασία κωδικού πρόσβασης",
"pagination.first": "Πρώτο",
"pagination.last": "Τελευταίο",
"pagination.next": "Επόμενη",
"pagination.previous": "Προηγούμενη",
"search.label": "Αναζήτηση",
"search.placeholder": "Αναζήτηση...",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "Αναζήτηση",
"skip_to_content": "Μετάβαση στο περιεχόμενο",
"time_elapsed.days": [
"πριν %d ημέρα",
"πριν %d ημέρες"
+27 -19
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "There are no entries matching this tag.",
"alert.no_unread_entry": "There are no unread entries.",
"alert.no_user": "You are the only user.",
"alert.pocket_linked": "Your Pocket account is now linked!",
"alert.prefs_saved": "Preferences saved!",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
@@ -77,6 +76,10 @@
"entry.status.toast.read": "Marked as read",
"entry.status.toast.unread": "Marked as unread",
"entry.tags.label": "Tags:",
"entry.tags.more_tags_label": [
"Show %d more tag",
"Show %d more tags"
],
"entry.unshare.label": "Unshare",
"error.api_key_already_exists": "This API Key already exists.",
"error.bad_credentials": "Invalid username or password.",
@@ -114,9 +117,11 @@
"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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.invalid_categories_sorting_order": "Invalid categories sorting order.",
"error.invalid_default_home_page": "Invalid default homepage!",
"error.invalid_display_mode": "Invalid web app display mode.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_entry_order": "Invalid entry order.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_feed_url": "Invalid feed URL.",
"error.invalid_gesture_nav": "Invalid gesture navigation.",
@@ -127,8 +132,6 @@
"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.password_min_length": "The password must have at least 6 characters.",
"error.pocket_access_token": "Unable to fetch access token from Pocket!",
"error.pocket_request_token": "Unable to fetch request token from Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Rules",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Block Rules",
"form.feed.label.block_filter_entry_rules": "Entry Blocking Rules",
"form.feed.label.blocklist_rules": "Regex-Based Blocking Filters",
"form.feed.label.category": "Category",
"form.feed.label.cookie": "Set Cookies",
"form.feed.label.crawler": "Fetch original content",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Use the proxy configured at the application level",
"form.feed.label.hide_globally": "Hide entries in global unread list",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.keeplist_rules": "Keep Rules",
"form.feed.label.keep_filter_entry_rules": "Entry Allow Rules",
"form.feed.label.keeplist_rules": "Regex-Based Keep Filters",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
@@ -197,7 +202,7 @@
"form.feed.label.pushover_max_priority": "Max priority",
"form.feed.label.pushover_min_priority": "Minimal priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Rewrite Rules",
"form.feed.label.rewrite_rules": "Content Rewrite Rules",
"form.feed.label.scraper_rules": "Scraper Rules",
"form.feed.label.site_url": "Site URL",
"form.feed.label.title": "Title",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Save entries to Instapaper",
"form.integration.instapaper_password": "Instapaper Password",
"form.integration.instapaper_username": "Instapaper Username",
"form.integration.karakeep_activate": "Save entries to Karakeep",
"form.integration.karakeep_api_key": "Karakeep API key",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
"form.integration.matrix_bot_activate": "Push new entries to Matrix",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.matrix_bot_password": "Password for Matrix user",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "Mark bookmark as unread",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_activate": "Save entries to Pocket",
"form.integration.pocket_connect_link": "Connect your Pocket account",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -325,6 +330,7 @@
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "Categories sorting",
"form.prefs.label.cjk_reading_speed": "Reading speed for Chinese, Korean and Japanese (characters per minute)",
"form.prefs.label.custom_css": "Custom CSS",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Open external links in a new tab (adds target=\"_blank\" to links)",
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
"form.prefs.label.theme": "Theme",
"form.prefs.label.timezone": "Timezone",
@@ -405,6 +412,7 @@
"page.about.build_date": "Build Date:",
"page.about.credits": "Credits",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Global configuration options",
"page.about.go_version": "Go version:",
"page.about.license": "License:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Last Used",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API Keys",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories.entries": "Entries",
"page.categories.feed_count": [
"There is %d feed.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Feeds",
"page.categories.no_feed": "No feed.",
"page.categories.title": "Categories",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "Edit Category: %s",
"page.edit_feed.etag_header": "ETag header:",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.shared_entries.title": "Shared entries",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries.title": "Shared entries",
"page.starred.title": "Starred",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.starred.title": "Starred",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.unread.title": "Unread",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.unread.title": "Unread",
"page.users.actions": "Actions",
"page.users.admin.no": "No",
"page.users.admin.yes": "Yes",
@@ -606,4 +614,4 @@
"time_elapsed.yesterday": "yesterday",
"tooltip.keyboard_shortcuts": "Keyboard Shortcut: %s",
"tooltip.logged_user": "Logged in as %s"
}
}
+51 -43
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
"alert.no_unread_entry": "No hay artículos sin leer.",
"alert.no_user": "Eres el único usuario.",
"alert.pocket_linked": "¡Tu cuenta de Pocket ya está vinculada!",
"alert.prefs_saved": "¡Las preferencias se han guardado!",
"alert.too_many_feeds_refresh": [
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
@@ -77,6 +76,10 @@
"entry.status.toast.read": "Marcado como leído",
"entry.status.toast.unread": "Marcado como no leído",
"entry.tags.label": "Etiquetas:",
"entry.tags.more_tags_label": [
"Mostrar %d etiqueta más",
"Mostrar %d etiquetas más"
],
"entry.unshare.label": "No compartir",
"error.api_key_already_exists": "Esta clave API ya existe.",
"error.bad_credentials": "Usuario o contraseña no válido.",
@@ -114,10 +117,12 @@
"error.http_service_unavailable": "El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.http_too_many_requests": "Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.",
"error.http_unexpected_status_code": "El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
"error.invalid_categories_sorting_order": "Orden de clasificación de categorías no válido.",
"error.invalid_default_home_page": "¡Página de inicio por defecto no válida!",
"error.invalid_display_mode": "Modo de visualización de la aplicación web no válido.",
"error.invalid_entry_direction": "Dirección de artículo no válida.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Orden de artículo no válido.",
"error.invalid_feed_proxy_url": "URL de proxy inválida.",
"error.invalid_feed_url": "URL de feed no válida.",
"error.invalid_gesture_nav": "Navegación por gestos no válida.",
"error.invalid_language": "Idioma no válido.",
@@ -127,14 +132,12 @@
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
"error.pocket_access_token": "Incapaz de obtener un token de acceso de Pocket!",
"error.pocket_request_token": "Incapaz de obtener un token de solicitud de Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "La URL del proxy no puede estar vacía.",
"error.settings_block_rule_fieldname_invalid": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_invalid_domain_list": "Lista de dominios inválida. Por favor proporcione una lista de dominios separados por espacios.",
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Reglas",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
"form.feed.label.block_filter_entry_rules": "Reglas de Bloqueo de Entradas",
"form.feed.label.blocklist_rules": "Filtros de Bloqueo Basados en Regex",
"form.feed.label.category": "Categoría",
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.crawler": "Obtener rastreador original",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Usar el proxy configurado a nivel de la aplicación",
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
"form.feed.label.keep_filter_entry_rules": "Reglas de Permitir Entradas",
"form.feed.label.keeplist_rules": "Filtros de Mantener Basados en Regex",
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
@@ -189,21 +194,21 @@
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
"form.feed.label.ntfy_topic": "Tema Ntfy (opcional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Reglas de reescribir",
"form.feed.label.proxy_url": "URL del Proxy",
"form.feed.label.pushover_activate": "Enviar artículos a pushover.net",
"form.feed.label.pushover_default_priority": "Prioridad predeterminada de Pushover",
"form.feed.label.pushover_high_priority": "Prioridad alta de Pushover",
"form.feed.label.pushover_low_priority": "Prioridad baja de Pushover",
"form.feed.label.pushover_max_priority": "Prioridad máxima de Pushover",
"form.feed.label.pushover_min_priority": "Prioridad mínima de Pushover",
"form.feed.label.pushover_priority": "Prioridad del mensaje de Pushover",
"form.feed.label.rewrite_rules": "Reglas de Reescritura de Contenido",
"form.feed.label.scraper_rules": "Reglas de extracción de información",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.title": "Título",
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.user_agent": "Invalidar el agente de usuario predeterminado",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Invalidar la URL del webhook",
"form.import.label.file": "Archivo OPML",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Enviar artículos a Apprise",
@@ -212,8 +217,8 @@
"form.integration.betula_activate": "Guardar artículos en Betula",
"form.integration.betula_token": "Token de Betula",
"form.integration.betula_url": "URL del servidor Betula",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.cubox_activate": "Guardar artículos en Cubox",
"form.integration.cubox_api_link": "Enlace de la API de Cubox",
"form.integration.discord_activate": "Enviar artículos a Discord",
"form.integration.discord_webhook_link": "URL de la Webhook de Discord",
"form.integration.espial_activate": "Enviar artículos a Espial",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Enviar artículos a Instapaper",
"form.integration.instapaper_password": "Contraseña de Instapaper",
"form.integration.instapaper_username": "Nombre de usuario de Instapaper",
"form.integration.karakeep_activate": "Enviar artículos a Karakeep",
"form.integration.karakeep_api_key": "Clave de API de Karakeep",
"form.integration.karakeep_url": "Acceso API de Karakeep",
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
"form.integration.linkace_api_key": "Clave API de LinkAce",
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Etiquetas de Linkding",
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_api_key": "Clave de API de Linkwarden",
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL base de Linkwarden",
"form.integration.matrix_bot_activate": "Transferir nuevos artículos a Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
@@ -271,15 +279,11 @@
"form.integration.pinboard_bookmark": "Marcar marcador como no leído",
"form.integration.pinboard_tags": "Etiquetas de Pinboard",
"form.integration.pinboard_token": "Token de API de Pinboard",
"form.integration.pocket_access_token": "Token de acceso de Pocket",
"form.integration.pocket_activate": "Enviar artículos a Pocket",
"form.integration.pocket_connect_link": "Conectar a la cuenta de Pocket",
"form.integration.pocket_consumer_key": "Clave del consumidor de Pocket",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.pushover_activate": "Enviar artículos a Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (opcional)",
"form.integration.pushover_prefix": "Prefijo de URL de Pushover (opcional)",
"form.integration.pushover_token": "Token de API de la aplicación Pushover",
"form.integration.pushover_user": "Clave de usuario de Pushover",
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
"form.integration.raindrop_collection_id": "Colección ID",
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
@@ -324,7 +329,8 @@
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.help.external_font_hosts": "Lista separada por espacios de hosts de fuentes externas permitidos. Por ejemplo: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Leer artículos abriendo enlaces externos",
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
"form.prefs.label.cjk_reading_speed": "Velocidad de lectura en chino, coreano y japonés (caracteres por minuto)",
"form.prefs.label.custom_css": "CSS personalizado",
@@ -336,7 +342,7 @@
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
"form.prefs.label.entry_sorting": "Clasificación de artículos",
"form.prefs.label.entry_swipe": "Habilitar deslizamiento de entrada en pantallas táctiles",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Hosts de fuentes externas",
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
"form.prefs.label.keyboard_shortcuts": "Habilitar atajos de teclado",
"form.prefs.label.language": "Idioma",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir enlaces externos en una nueva pestaña (agrega target=\"_blank\" a los enlaces)",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Zona horaria",
@@ -404,7 +411,8 @@
"page.about.author": "Autor:",
"page.about.build_date": "Fecha de compilación:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Database size:",
"page.about.db_usage": "Tamaño de la base de datos:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Opciones de configuración global",
"page.about.go_version": "Go versión:",
"page.about.license": "Licencia:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Último utilizado",
"page.api_keys.table.token": "simbólico",
"page.api_keys.title": "Claves API",
"page.categories_count": [
"%d categoría",
"%d categorías"
],
"page.categories.entries": "Artículos",
"page.categories.feed_count": [
"Hay %d fuente.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Fuentes",
"page.categories.no_feed": "Sin fuente.",
"page.categories.title": "Categorías",
"page.categories_count": [
"%d categoría",
"%d categorías"
],
"page.category_label": "Categoría: %s",
"page.edit_category.title": "Editar categoría: %s",
"page.edit_feed.etag_header": "Cabecera de ETag:",
@@ -467,7 +475,7 @@
"page.integration.miniflux_api_username": "Nombre de usuario",
"page.integrations.title": "Integraciones",
"page.keyboard_shortcuts.close_modal": "Cerrar el cuadro de diálogo modal",
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
"page.keyboard_shortcuts.download_content": "Descargar el contenido original",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
"page.keyboard_shortcuts.go_to_categories": "Ir a las categorías",
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
@@ -507,7 +515,7 @@
"page.login.title": "Iniciar sesión",
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login.help": "Por favor, introduce tu nombre de usuario si usas una clave de seguridad. Esto no es necesario si usas una Passkey (credenciales detectables).",
"page.new_api_key.title": "Nueva clave API",
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario",
@@ -530,7 +538,7 @@
"page.settings.title": "Ajustes",
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
"page.settings.webauthn.actions": "Accioness",
"page.settings.webauthn.actions": "Acciones",
"page.settings.webauthn.added_on": "Añadido",
"page.settings.webauthn.delete": [
"Eliminar %d clave de acceso",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Claves de acceso",
"page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
"page.shared_entries.title": "Artículos compartidos",
"page.shared_entries_count": [
"%d artículo compartido",
"%d artículos compartidos"
],
"page.shared_entries.title": "Artículos compartidos",
"page.starred.title": "Marcadores",
"page.starred_entry_count": [
"%d artículo marcado",
"%d artículos marcados"
],
"page.starred.title": "Marcadores",
"page.total_entry_count": [
"%d artículo en total",
"%d artículos en total"
],
"page.unread.title": "No leídos",
"page.unread_entry_count": [
"%d artículo no leído",
"%d artículos no leídos"
],
"page.unread.title": "No leídos",
"page.users.actions": "Acciones",
"page.users.admin.no": "No",
"page.users.admin.yes": "Sí",
+55 -47
View File
@@ -13,7 +13,7 @@
"action.update": "Päivitä",
"alert.account_linked": "Ulkoinen tilisi on nyt linkitetty!",
"alert.account_unlinked": "Ulkoinen tilisi on nyt irrotettu!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Kaikki syötteet päivitetään taustalla. Voit jatkaa Minifluxin käyttöä tämän prosessin aikana.",
"alert.feed_error": "Tässä syötteessä on ongelma",
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
@@ -27,26 +27,25 @@
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
"alert.no_unread_entry": "Ei ole lukemattomia artikkeleita.",
"alert.no_user": "Olet ainoa käyttäjä.",
"alert.pocket_linked": "Pocket-tilisi on nyt linkitetty!",
"alert.prefs_saved": "Asetukset tallennettu!",
"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."
"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuutti ennen kuin yrität uudelleen.",
"Olet käynnistänyt liian monta syötteen päivitystä. Odota %d minuuttia ennen kuin yrität uudelleen."
],
"confirm.loading": "Käynnissä...",
"confirm.no": "ei",
"confirm.question": "Oletko varma?",
"confirm.question.refresh": "Haluatko pakottaa päivityksen?",
"confirm.yes": "kyllä",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "Siirry:",
"enclosure_media_controls.seek.title": "Siirry %s sekuntia",
"enclosure_media_controls.speed": "Nopeus:",
"enclosure_media_controls.speed.faster": "Nopeammin",
"enclosure_media_controls.speed.faster.title": "Nopeampi %sx",
"enclosure_media_controls.speed.reset": "Palauta",
"enclosure_media_controls.speed.reset.title": "Palauta nopeus 1x",
"enclosure_media_controls.speed.slower": "Hitaammin",
"enclosure_media_controls.speed.slower.title": "Hitaampi %sx",
"entry.bookmark.toast.off": "Tähdettömät",
"entry.bookmark.toast.on": "Tähdellä merkityt",
"entry.bookmark.toggle.off": "Poista suosikeista",
@@ -76,31 +75,35 @@
"entry.status.title": "Vaihda artikkelin tilaa",
"entry.status.toast.read": "Merkitty luetuksi",
"entry.status.toast.unread": "Merkitty lukemattomaksi",
"entry.tags.label": "Tags:",
"entry.tags.label": "Tunnisteet:",
"entry.tags.more_tags_label": [
"Näytä %d lisää tunnistetta",
"Näytä %d lisää tunnisteita"
],
"entry.unshare.label": "Poista jako",
"error.api_key_already_exists": "API-avain on jo olemassa.",
"error.bad_credentials": "Virheellinen käyttäjänimi tai salasana.",
"error.category_already_exists": "Kategoria on jo olemassa. ",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Tämä kategoria ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.database_error": "Tietokantavirhe: %v.",
"error.different_passwords": "Salasanat eivät ole samat.",
"error.duplicate_fever_username": "There is already someone else with the same Fever username!",
"error.duplicate_fever_username": "Joku muu käyttää jo samaa Fever-käyttäjänimeä!",
"error.duplicate_googlereader_username": "On jo joku muu, jolla on sama Google-syötteenlukijan käyttäjätunnus!",
"error.duplicate_linked_account": "There is already someone associated with this provider!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicate_linked_account": "Joku on jo yhdistetty tähän palveluntarjoajaan!",
"error.duplicated_feed": "Tämä syöte on jo olemassa.",
"error.empty_file": "Tiedosto on tyhjä.",
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
"error.feed_category_not_found": "Tätä kategoriaa ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_invalid_blocklist_rule": "The block list rule is invalid.",
"error.feed_invalid_keeplist_rule": "The keep list rule is invalid.",
"error.feed_format_not_detected": "Syötteen muotoa ei voitu tunnistaa: %v.",
"error.feed_invalid_blocklist_rule": "Estolistan sääntö on virheellinen.",
"error.feed_invalid_keeplist_rule": "Säilytettävien listan sääntö on virheellinen.",
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Tämä syöte ei ole olemassa tai se ei kuulu tälle käyttäjälle.",
"error.feed_title_not_empty": "Syötteen otsikko ei voi olla tyhjä.",
"error.feed_url_not_empty": "Syötteen URL-osoite ei voi olla tyhjä.",
"error.fields_mandatory": "Kaikki kentät ovat pakollisia.",
"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_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
@@ -114,9 +117,11 @@
"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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.invalid_categories_sorting_order": "Virheellinen kategorioiden lajittelujärjestys.",
"error.invalid_default_home_page": "Väärä oletusarvoinen kotisivu!",
"error.invalid_display_mode": "Virheellinen verkkosovelluksen näyttötila.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_entry_order": "Virheellinen artikkelin lajittelu.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_feed_url": "Virheellinen syötteen URL-osoite.",
"error.invalid_gesture_nav": "Virheellinen ele-navigointi.",
@@ -127,8 +132,6 @@
"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.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
"error.pocket_access_token": "Unable to fetch access token from Pocket!",
"error.pocket_request_token": "Unable to fetch request token from Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Rules",
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Block-säännöt",
"form.feed.label.block_filter_entry_rules": "Merkinnän estosäännöt",
"form.feed.label.blocklist_rules": "Regex-pohjaiset estosuodattimet",
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Aseta evästeet",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Käytä sovellustasolla määritettyä välityspalvelinta",
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
"form.feed.label.keeplist_rules": "Keep-säännöt",
"form.feed.label.keep_filter_entry_rules": "Merkinnän sallimissäännöt",
"form.feed.label.keeplist_rules": "Regex-pohjaiset säilytyssuodattimet",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
@@ -197,7 +202,7 @@
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Rewrite-säännöt",
"form.feed.label.rewrite_rules": "Sisällön uudelleenkirjoitussäännöt",
"form.feed.label.scraper_rules": "Scraper-säännöt",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.title": "Otsikko",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Tallenna artikkelit Instapaperiin",
"form.integration.instapaper_password": "Instapaper-salasana",
"form.integration.instapaper_username": "Instapaper-käyttäjätunnus",
"form.integration.karakeep_activate": "Tallenna artikkelit Karakeepiin",
"form.integration.karakeep_api_key": "Karakeep API-avain",
"form.integration.karakeep_url": "Karakeep API-päätepiste",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Tallenna artikkelit Linkkiin",
"form.integration.linkwarden_api_key": "Linkwarden API-avain",
"form.integration.linkwarden_endpoint": "Linkwarden API-päätepiste",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
"form.integration.matrix_bot_activate": "Siirrä uudet artikkelit Matrixiin",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "Merkitse kirjanmerkki lukemattomaksi",
"form.integration.pinboard_tags": "Pinboard-tagit",
"form.integration.pinboard_token": "Pinboard API-tunnus",
"form.integration.pocket_access_token": "Pocket-käyttöoikeustunnus",
"form.integration.pocket_activate": "Tallenna artikkelit Pocketiin",
"form.integration.pocket_connect_link": "Yhdistä Pocket-tilisi",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -325,6 +330,7 @@
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
"form.prefs.label.cjk_reading_speed": "Kiinan, Korean ja Japanin lukunopeus (merkkejä minuutissa)",
"form.prefs.label.custom_css": "Mukautettu CSS",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"form.prefs.label.open_external_links_in_new_tab": "Avaa ulkoiset linkit uuteen välilehteen (lisää target=\"_blank\" linkkeihin)",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
"form.prefs.label.theme": "Teema",
"form.prefs.label.timezone": "Aikavyöhyke",
@@ -381,7 +388,7 @@
"menu.feeds": "Syötteet",
"menu.flush_history": "Tyhjennä historia",
"menu.history": "Historia",
"menu.home_page": "Home page",
"menu.home_page": "Etusivu",
"menu.import": "Tuo",
"menu.integrations": "Integraatiot",
"menu.logout": "Kirjaudu ulos",
@@ -395,7 +402,7 @@
"menu.settings": "Asetukset",
"menu.shared_entries": "Jaetut artikkelit",
"menu.show_all_entries": "Näytä kaikki artikkelit",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.show_only_starred_entries": "Näytä vain suosikit",
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
"menu.starred": "Suosikit",
"menu.title": "Menu",
@@ -405,6 +412,7 @@
"page.about.build_date": "Valmistuspäivä:",
"page.about.credits": "Kiitokset",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Yleiset asetukset",
"page.about.go_version": "Go-versio:",
"page.about.license": "Lisenssi:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Viimeksi käytetty",
"page.api_keys.table.token": "Tunnus",
"page.api_keys.title": "API-avaimet",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories.entries": "Artikkelit",
"page.categories.feed_count": [
"On %d syöte.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Tilaukset",
"page.categories.no_feed": "Ei syötettä.",
"page.categories.title": "Kategoriat",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "Muokkaa kategoria: %s",
"page.edit_feed.etag_header": "ETag-otsikko:",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.shared_entries.title": "Jaetut artikkelit",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries.title": "Jaetut artikkelit",
"page.starred.title": "Suosikit",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.starred.title": "Suosikit",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.unread.title": "Lukemattomat",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.unread.title": "Lukemattomat",
"page.users.actions": "Toiminnot",
"page.users.admin.no": "Ei",
"page.users.admin.yes": "Kyllä",
@@ -569,14 +577,14 @@
"page.users.title": "Käyttäjät",
"page.users.username": "Käyttäjätunnus",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"pagination.first": "Ensimmäinen",
"pagination.last": "Viimeinen",
"pagination.next": "Seuraava",
"pagination.previous": "Edellinen",
"search.label": "Haku",
"search.placeholder": "Hae...",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "Hae",
"skip_to_content": "Siirry sisältöön",
"time_elapsed.days": [
"%d päivä sitten",
"%d päivää sitten"
+37 -29
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
"alert.no_unread_entry": "Il n'y a rien de nouveau à lire.",
"alert.no_user": "Vous êtes le seul utilisateur.",
"alert.pocket_linked": "Votre compte Pocket est maintenant connecté !",
"alert.prefs_saved": "Préférences sauvegardées !",
"alert.too_many_feeds_refresh": [
"Vous avez déclenché trop d'actualisations de flux. Veuillez attendre %d minute avant de réessayer.",
@@ -77,6 +76,10 @@
"entry.status.toast.read": "Marqué comme lu",
"entry.status.toast.unread": "Marqué comme non lu",
"entry.tags.label": "Libellés :",
"entry.tags.more_tags_label": [
"Afficher %d libellé supplémentaire",
"Afficher %d libellés supplémentaires"
],
"entry.unshare.label": "Enlever le partage",
"error.api_key_already_exists": "Cette clé d'API existe déjà.",
"error.bad_credentials": "Mauvais identifiant ou mot de passe.",
@@ -114,9 +117,11 @@
"error.http_service_unavailable": "Le site web n'est pas disponible pour le moment. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.http_too_many_requests": "Miniflux a généré trop de requêtes vers ce site web. Veuillez réessayer plus tard ou changez la configuration de l'application.",
"error.http_unexpected_status_code": "Le site web a répondu avec un code HTTP inattendu : %d. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
"error.invalid_categories_sorting_order": "L'ordre de tri des catégories n'est pas valide.",
"error.invalid_default_home_page": "Page d'accueil par défaut invalide !",
"error.invalid_display_mode": "Mode d'affichage de l'application web non valide.",
"error.invalid_entry_direction": "Ordre de trie non valide.",
"error.invalid_entry_order": "Ordre de tri non valide.",
"error.invalid_feed_proxy_url": "L'URL du proxy n'est pas valide.",
"error.invalid_feed_url": "URL de flux non valide.",
"error.invalid_gesture_nav": "Navigation gestuelle non valide.",
@@ -127,8 +132,6 @@
"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.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
"error.pocket_access_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"error.pocket_request_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"error.proxy_url_not_empty": "L'URL du proxy ne peut pas être vide.",
"error.settings_block_rule_fieldname_invalid": "Règle de blocage invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
"error.settings_block_rule_invalid_regex": "Règle de blocage invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Règles",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.apprise_service_urls": "Liste séparée par des virgules des URL du service Apprise",
"form.feed.label.blocklist_rules": "Règles de blocage",
"form.feed.label.block_filter_entry_rules": "Règles de blocage des entrées",
"form.feed.label.blocklist_rules": "Filtres de blocage basés sur des expressions régulières",
"form.feed.label.category": "Catégorie",
"form.feed.label.cookie": "Définir les cookies",
"form.feed.label.crawler": "Récupérer le contenu original",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Utiliser le proxy configuré au niveau de l'application",
"form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.keeplist_rules": "Règles d'autorisation",
"form.feed.label.keep_filter_entry_rules": "Règles d'autorisation des entrées",
"form.feed.label.keeplist_rules": "Filtres de conservation basés sur des expressions régulières",
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
"form.feed.label.ntfy_activate": "Activer les notifications",
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
@@ -197,7 +202,7 @@
"form.feed.label.pushover_max_priority": "Priorité maximale",
"form.feed.label.pushover_min_priority": "Priorité minimale",
"form.feed.label.pushover_priority": "Priorité des notifications Pushover",
"form.feed.label.rewrite_rules": "Règles de réécriture",
"form.feed.label.rewrite_rules": "Règles de réécriture du contenu",
"form.feed.label.scraper_rules": "Règles pour récupérer le contenu original",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.title": "Titre",
@@ -212,8 +217,8 @@
"form.integration.betula_activate": "Sauvegarder les entrées vers Betula",
"form.integration.betula_token": "Jeton de sécurité de l'API de Betula",
"form.integration.betula_url": "URL du serveur Betula",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.cubox_activate": "Sauvegarder les entrées vers Cubox",
"form.integration.cubox_api_link": "Lien API Cubox",
"form.integration.discord_activate": "Envoyer les articles vers Discord",
"form.integration.discord_webhook_link": "URL du Webhook Discord",
"form.integration.espial_activate": "Sauvegarder les articles vers Espial",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Sauvegarder les articles vers Instapaper",
"form.integration.instapaper_password": "Mot de passe Instapaper",
"form.integration.instapaper_username": "Nom d'utilisateur Instapaper",
"form.integration.karakeep_activate": "Sauvegarder les articles vers Karakeep",
"form.integration.karakeep_api_key": "Clé d'API de Karakeep",
"form.integration.karakeep_url": "URL de l'API de Karakeep",
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce",
"form.integration.linkace_api_key": "Clé d'API LinkAce",
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Libellés",
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
"form.integration.linkwarden_api_key": "Clé d'API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL de l'API de Linkwarden",
"form.integration.linkwarden_endpoint": "URL de base de Linkwarden",
"form.integration.matrix_bot_activate": "Envoyer les nouveaux articles vers Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "Marquer le lien comme non lu",
"form.integration.pinboard_tags": "Libellés de Pinboard",
"form.integration.pinboard_token": "Jeton de sécurité de l'API de Pinboard",
"form.integration.pocket_access_token": "Jeton d'accès de l'API de Pocket",
"form.integration.pocket_activate": "Sauvegarder les articles vers Pocket",
"form.integration.pocket_connect_link": "Connectez votre compte Pocket",
"form.integration.pocket_consumer_key": "Clé de l'API de Pocket",
"form.integration.pushover_activate": "Envoyer les articles vers Pushover",
"form.integration.pushover_device": "Nom de l'appareil Pushover (facultatif)",
"form.integration.pushover_prefix": "URL de préfixe Pushover (facultatif)",
@@ -287,13 +291,14 @@
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_labels": "Libellés Readeck",
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.rssbridge_activate": "Vérifier RSS-Bridge lors de l'ajout d'abonnements",
"form.integration.rssbridge_token": "Jeton d'authentification RSS-Bridge",
"form.integration.rssbridge_url": "URL du serveur RSS-Bridge",
"form.integration.shaarli_activate": "Sauvegarder les articles vers Shaarli",
"form.integration.shaarli_api_secret": "Clé d'API de Shaarli API",
"form.integration.shaarli_endpoint": "URL de l'API de Shaarli",
@@ -325,6 +330,7 @@
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
"form.prefs.label.always_open_external_links": "Lire les articles en ouvrant les liens externes",
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
"form.prefs.label.cjk_reading_speed": "Vitesse de lecture pour le chinois, le coréen et le japonais (caractères par minute)",
"form.prefs.label.custom_css": "Feuille de style personnalisée",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
"form.prefs.label.open_external_links_in_new_tab": "Ouvrir les liens externes dans un nouvel onglet (ajoute target=\"_blank\" aux liens)",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
"form.prefs.label.theme": "Thème",
"form.prefs.label.timezone": "Fuseau horaire",
@@ -404,7 +411,8 @@
"page.about.author": "Auteur :",
"page.about.build_date": "Date de la compilation :",
"page.about.credits": "Crédits",
"page.about.db_usage": "Database size:",
"page.about.db_usage": "Taille de la base de données :",
"page.about.git_commit": "Commit Git :",
"page.about.global_config_options": "Options de configuration globales",
"page.about.go_version": "Version de Go :",
"page.about.license": "Licence :",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Dernière utilisation",
"page.api_keys.table.token": "Jeton",
"page.api_keys.title": "Clés d'API",
"page.categories_count": [
"%d catégorie",
"%d catégories"
],
"page.categories.entries": "Articles",
"page.categories.feed_count": [
"Il y a %d abonnement.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Abonnements",
"page.categories.no_feed": "Aucun abonnement.",
"page.categories.title": "Catégories",
"page.categories_count": [
"%d catégorie",
"%d catégories"
],
"page.category_label": "Catégorie : %s",
"page.edit_category.title": "Modification de la catégorie : %s",
"page.edit_feed.etag_header": "En-tête ETag :",
@@ -499,7 +507,7 @@
"page.keyboard_shortcuts.subtitle.sections": "Navigation entre les sections",
"page.keyboard_shortcuts.title": "Raccourcis clavier",
"page.keyboard_shortcuts.toggle_bookmark_status": "Ajouter/Enlever favoris",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "Ouvrir/Fermer les pièces jointes de l'entrée",
"page.keyboard_shortcuts.toggle_read_status_next": "Basculer entre lu/non lu, et changer le focus sur l'élément suivant",
"page.keyboard_shortcuts.toggle_read_status_prev": "Basculer entre lu/non lu, et changer le focus sur l'élément précédent",
"page.login.google_signin": "Se connecter avec Google",
@@ -515,8 +523,8 @@
"page.offline.refresh_page": "Essayez de rafraîchir la page",
"page.offline.title": "Mode Hors-Ligne",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d entrée lue",
"%d entrées lues"
],
"page.search.title": "Résultats de la recherche",
"page.sessions.table.actions": "Actions",
@@ -539,27 +547,27 @@
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.passkey_name": "Nom de la clé daccès",
"page.settings.webauthn.passkeys": "Clés daccès",
"page.settings.webauthn.register": "Enregister une nouvelle clé daccès",
"page.settings.webauthn.register": "Enregistrer une nouvelle clé daccès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès",
"page.shared_entries.title": "Articles partagés",
"page.shared_entries_count": [
"%d article partagé",
"%d articles partagés"
],
"page.shared_entries.title": "Articles partagés",
"page.starred.title": "Favoris",
"page.starred_entry_count": [
"%d favori",
"%d favoris"
],
"page.starred.title": "Favoris",
"page.total_entry_count": [
"%d article au total",
"%d articles au total"
],
"page.unread.title": "Non lus",
"page.unread_entry_count": [
"%d article non lu",
"%d articles non lus"
],
"page.unread.title": "Non lus",
"page.users.actions": "Actions",
"page.users.admin.no": "Non",
"page.users.admin.yes": "Oui",
@@ -568,7 +576,7 @@
"page.users.never_logged": "Jamais",
"page.users.title": "Utilisateurs",
"page.users.username": "Nom d'utilisateur",
"page.webauthn_rename.title": "Rename Passkey",
"page.webauthn_rename.title": "Renommer la clé d'accès",
"pagination.first": "Première page",
"pagination.last": "Dernière page",
"pagination.next": "Suivant",
+58 -50
View File
@@ -13,7 +13,7 @@
"action.update": "नवीनीकरण करे",
"alert.account_linked": "आपका बाहरी खाता अब लिंक हो गया है!",
"alert.account_unlinked": "आपका बाहरी खाता अब अलग कर दिया गया है!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "सभी फ़ीड्स पृष्ठभूमि में ताज़ा की जा रही हैं। जब यह प्रक्रिया चल रही हो, तो आप मिनीफ्लक्स का उपयोग जारी रख सकते हैं।",
"alert.feed_error": "इस फ़ीड में एक समस्या है",
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
@@ -27,26 +27,25 @@
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_unread_entry": "कोई अपठित वस्तुत नहीं है।",
"alert.no_user": "आप एकमात्र उपयोगकर्ता हैं।",
"alert.pocket_linked": "आपका पॉकेट खाता अब लिंक हो गया है!",
"alert.prefs_saved": "प्राथमिकताएं सहेजी गईं!",
"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."
"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।",
"आपने बहुत अधिक फ़ीड ताज़ा करने की प्रक्रिया शुरू कर दी है। कृपया पुनः प्रयास करने से पहले %d मिनट प्रतीक्षा करें।"
],
"confirm.loading": " प्रगति में है ...",
"confirm.no": " नहीं",
"confirm.question": "मंजूर है?",
"confirm.question.refresh": "क्या आप बल द्वारा ताज़ा करना चाहते हैं?",
"confirm.yes": "हाँ",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "खोजें:",
"enclosure_media_controls.seek.title": "%s सेकंड खोजें",
"enclosure_media_controls.speed": "गति:",
"enclosure_media_controls.speed.faster": "तेज",
"enclosure_media_controls.speed.faster.title": "%sx गुना तेज",
"enclosure_media_controls.speed.reset": "रीसेट करें",
"enclosure_media_controls.speed.reset.title": "गति 1x पर रीसेट करें",
"enclosure_media_controls.speed.slower": "धीमा",
"enclosure_media_controls.speed.slower.title": "%sx गुना धीमा",
"entry.bookmark.toast.off": "तारांकित न करे",
"entry.bookmark.toast.on": "तारांकित",
"entry.bookmark.toggle.off": "सितारा हटा दो",
@@ -77,35 +76,39 @@
"entry.status.toast.read": "पढ़ा हुआ चिह्नित करे",
"entry.status.toast.unread": "अपठित के रूप में चिह्नित",
"entry.tags.label": "टैग:",
"entry.tags.more_tags_label": [
"%d और टैग दिखाएँ",
"%d और टैग दिखाएँ"
],
"entry.unshare.label": "न साझा कारें",
"error.api_key_already_exists": "यह एपीआई कुंजी पहले से मौजूद है।",
"error.bad_credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड।",
"error.category_already_exists": "यह श्रेणी पहले से मौजूद है।",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.database_error": "डेटाबेस त्रुटि: %v",
"error.different_passwords": "पासवर्ड एक जैसे नहीं हैं।",
"error.duplicate_fever_username": "पहले से ही समान फीवर उपयोगकर्ता नाम वाला कोई और है!",
"error.duplicate_googlereader_username": "समान गूगल रीडर उपयोगकर्ता नाम वाला कोई और पहले से मौजूद है!",
"error.duplicate_linked_account": "इस प्रदाता के साथ पहले से ही कोई व्यक्ति जुड़ा हुआ है!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "यह फ़ीड पहले से मौजूद है।",
"error.empty_file": "यह फ़ाइल खाली है।",
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
"error.feed_category_not_found": "यह श्रेणी मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "फ़ीड प्रारूप का पता नहीं लगा सकते: %v",
"error.feed_invalid_blocklist_rule": "ब्लॉक सूची नियम अमान्य है।",
"error.feed_invalid_keeplist_rule": "सूची रखें नियम अमान्य है।",
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "यह फ़ीड मौजूद नहीं है या इस उपयोगकर्ता से संबंधित नहीं है।",
"error.feed_title_not_empty": "फ़ीड शीर्षक खाली नहीं हो सकता.",
"error.feed_url_not_empty": "फ़ीड यूआरएल खाली नहीं हो सकता.",
"error.fields_mandatory": "सभी फील्ड अनिवार्य।",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_bad_gateway": "खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।",
"error.http_body_read": "HTTP बॉडी पढ़ने में असमर्थ: %v",
"error.http_client_error": "HTTP क्लाइंट त्रुटि: %v",
"error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
"error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
"error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
@@ -114,10 +117,12 @@
"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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.invalid_categories_sorting_order": "अमान्य श्रेणी क्रम।",
"error.invalid_default_home_page": "अमान्य डिफ़ॉल्ट मुखपृष्ठ!",
"error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
"error.invalid_entry_direction": "अमान्य प्रवेश दिशा।",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "अमान्य प्रविष्टि क्रम।",
"error.invalid_feed_proxy_url": "अमान्य प्रॉक्सी यूआरएल।",
"error.invalid_feed_url": "दृष्टिकोण यूआरएल.",
"error.invalid_gesture_nav": "अमान्य इशारा नेविगेशन।",
"error.invalid_language": "अमान्य भाषा.",
@@ -127,8 +132,6 @@
"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.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.pocket_access_token": "पॉकेट से एक्सेस टोकन प्राप्त करने में असमर्थ!",
"error.pocket_request_token": "पॉकेट से अनुरोध टोकन लाने में असमर्थ!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
@@ -149,8 +152,8 @@
"error.unable_to_create_api_key": "यह एपीआई कुंजी बनाने में असमर्थ।",
"error.unable_to_create_category": "यह श्रेणी बनाने में असमर्थ.",
"error.unable_to_create_user": "इस उपयोगकर्ता को बनाने में असमर्थ।",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "RSS-Bridge का उपयोग करके फ़ीड का पता लगाने में असमर्थ: %v.",
"error.unable_to_parse_feed": "इस फ़ीड को पार्स करने में असमर्थ: %v.",
"error.unable_to_update_category": "इस श्रेणी को अपडेट करने में असमर्थ।",
"error.unable_to_update_feed": "इस फ़ीड को अपडेट करने में असमर्थ.",
"error.unable_to_update_user": "इस उपयोगकर्ता को अपडेट करने में असमर्थ.",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Rules",
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "ब्लॉक नियम",
"form.feed.label.block_filter_entry_rules": "प्रविष्टि अवरोधन नियम",
"form.feed.label.blocklist_rules": "रेगेक्स-आधारित अवरोधन फिल्टर",
"form.feed.label.category": "श्रेणी",
"form.feed.label.cookie": "कुकीज़ सेट करें",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "एप्लिकेशन स्तर पर कॉन्फ़िगर किए गए प्रॉक्सी का उपयोग करें",
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
"form.feed.label.keeplist_rules": "नियम बनाए रखें",
"form.feed.label.keep_filter_entry_rules": "प्रविष्टि अनुमति नियम",
"form.feed.label.keeplist_rules": "रेगेक्स-आधारित रखने वाले फिल्टर",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
@@ -197,7 +202,7 @@
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "नियम फिर से लिखें",
"form.feed.label.rewrite_rules": "सामग्री पुनर्लेखन नियम",
"form.feed.label.scraper_rules": "खुरचनी नियम",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.title": "शीर्षक",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
"form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
"form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",
"form.integration.karakeep_activate": "Save entries to Karakeep",
"form.integration.karakeep_api_key": "Karakeep API key",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Save entries to Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
"form.integration.linkwarden_endpoint": "लिंकवर्डन बेस यूआरएलL",
"form.integration.matrix_bot_activate": "नए लेखों को मैट्रिक्स में स्थानांतरित करें",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "बुकमार्क को अपठित के रूप में चिह्नित करें",
"form.integration.pinboard_tags": "पिनबोर्ड टैग",
"form.integration.pinboard_token": "पिनबोर्ड एपीआई टोकन",
"form.integration.pocket_access_token": "पॉकेट एक्सेस टोकन",
"form.integration.pocket_activate": "विषय-कविता को पॉकेट में सहेजें",
"form.integration.pocket_connect_link": "अपना पॉकेट खाता कनेक्ट करें",
"form.integration.pocket_consumer_key": "पॉकेट उपभोक्ता कुंजी",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -325,6 +330,7 @@
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
"form.prefs.label.cjk_reading_speed": "चीनी, कोरियाई और जापानी के लिए पढ़ने की गति (प्रति मिनट वर्ण)",
"form.prefs.label.custom_css": "कस्टम सीएसएस",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"form.prefs.label.open_external_links_in_new_tab": "बाहरी लिंक को एक नए टैब में खोलें (लिंक में target=\"_blank\" जोड़ता है)",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.theme": "थीम",
"form.prefs.label.timezone": "समय क्षेत्र",
@@ -405,6 +412,7 @@
"page.about.build_date": "बनाने की तिथि:",
"page.about.credits": "आभार सूची",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "वैश्विक विन्यास विकल्प",
"page.about.go_version": "गो संस्करण:",
"page.about.license": "अनुज्ञा:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "आखरी इस्त्तमाल किया गया",
"page.api_keys.table.token": "टोकन",
"page.api_keys.title": "एपीआई कुंजी",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories.entries": "विषयवस्तुया",
"page.categories.feed_count": [
"%d फ़ीड बाकी है।",
@@ -436,6 +440,10 @@
"page.categories.feeds": "सदस्यता ले",
"page.categories.no_feed": "कोई फ़ीड नहीं है।",
"page.categories.title": "श्रेणियाँ",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "%s श्रेणी संपाद करे",
"page.edit_feed.etag_header": "ईटाग हैडर:",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "रजिस्टर पासकी",
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries.title": "साझा किया हुआ प्रविष्टि",
"page.starred.title": "तारांकित",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.starred.title": "तारांकित",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.unread.title": "अपठित",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.unread.title": "अपठित",
"page.users.actions": "कार्रवाई",
"page.users.admin.no": "नहीं",
"page.users.admin.yes": "हां",
@@ -569,14 +577,14 @@
"page.users.title": "उपभोक्ता",
"page.users.username": "यूसर्नेम",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"pagination.first": "पहला",
"pagination.last": "अंतिम",
"pagination.next": "अगला",
"pagination.previous": "पिछला",
"search.label": "खोजे",
"search.placeholder": "खोजे...",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "खोजें",
"skip_to_content": "सामग्री पर जाएं",
"time_elapsed.days": [
"%d दिन पहले",
"%d दिन पहले"
@@ -594,7 +602,7 @@
"%d महिनो पहले"
],
"time_elapsed.not_yet": "अभी तक नहीं",
"time_elapsed.now": "बिल्कुल अभी",
"time_elapsed.now": "अभी",
"time_elapsed.weeks": [
"%d सप्ताह पहले",
"%d हफ्तों पहले"
@@ -604,6 +612,6 @@
"%d वर्षों पहले"
],
"time_elapsed.yesterday": "कल",
"tooltip.keyboard_shortcuts": "कुंजीपटल संक्षिप्त रीति: %s",
"tooltip.keyboard_shortcuts": "कुंजीपटल शॉर्टकट: %s",
"tooltip.logged_user": "%s के रूप में लॉग इन किया"
}
+195 -188
View File
@@ -13,7 +13,7 @@
"action.update": "Perbarui",
"alert.account_linked": "Akun eksternal Anda sudah terhubung!",
"alert.account_unlinked": "Akun eksternal Anda sudah terputus!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Semua umpan sedang disegarkan di latar belakang. Anda bisa lanjut menggunakan Miniflux sembari proses ini berlanjut.",
"alert.feed_error": "Ada masalah dengan umpan ini",
"alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
@@ -27,25 +27,24 @@
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
"alert.no_unread_entry": "Belum ada artikel yang dibaca.",
"alert.no_user": "Anda adalah satu-satunya pengguna.",
"alert.pocket_linked": "Akun Pocket Anda sudah terhubung!",
"alert.prefs_saved": "Preferensi disimpan!",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
"Anda terlalu banyak menyegarkan umpan. Mohon tunggu %d menit sebelum mencoba lagi."
],
"confirm.loading": "Sedang progres...",
"confirm.no": "tidak",
"confirm.question": "Apakah Anda yakin?",
"confirm.question.refresh": "Apakah Anda ingin memaksa penyegaran?",
"confirm.yes": "ya",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "Putar:",
"enclosure_media_controls.seek.title": "Putar %s detik",
"enclosure_media_controls.speed": "Kecepatan:",
"enclosure_media_controls.speed.faster": "Lebih cepat",
"enclosure_media_controls.speed.faster.title": "Lebih cepat %sx",
"enclosure_media_controls.speed.reset": "Atur ulang",
"enclosure_media_controls.speed.reset.title": "Atur ulang ke 1x",
"enclosure_media_controls.speed.slower": "Lebih lambat",
"enclosure_media_controls.speed.slower.title": "Lebih lambat %sx",
"entry.bookmark.toast.off": "Batal Markahi",
"entry.bookmark.toast.on": "Markahi",
"entry.bookmark.toggle.off": "Batal Markahi",
@@ -75,80 +74,83 @@
"entry.status.toast.read": "Ditandai sebagai telah dibaca",
"entry.status.toast.unread": "Ditandai sebagai belum dibaca",
"entry.tags.label": "Tanda:",
"entry.tags.more_tags_label": [
"Tampilkan %d tag lainnya"
],
"entry.unshare.label": "Batal bagikan",
"error.api_key_already_exists": "Kunci API ini sudah ada.",
"error.bad_credentials": "Nama pengguna atau kata sandi tidak valid.",
"error.category_already_exists": "Kategori ini telah ada.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
"error.database_error": "Galat basis data: %v.",
"error.different_passwords": "Kata sandi tidak sama.",
"error.duplicate_fever_username": "Sudah ada orang lain dengan nama pengguna Fever yang sama!",
"error.duplicate_googlereader_username": "Sudah ada orang lain dengan nama pengguna Google Reader yang sama!",
"error.duplicate_linked_account": "Sudah ada orang lain yang terhubung dengan penyedia ini!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicate_fever_username": "Sudah ada pengguna lain dengan nama pengguna Fever yang sama!",
"error.duplicate_googlereader_username": "Sudah ada pengguna lain dengan nama pengguna Google Reader yang sama!",
"error.duplicate_linked_account": "Sudah ada pengguna lain yang terhubung dengan penyedia ini!",
"error.duplicated_feed": "Umpan ini sudah ada.",
"error.empty_file": "Berkas ini kosong.",
"error.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
"error.feed_already_exists": "Umpan ini sudah ada.",
"error.feed_category_not_found": "Kategori ini tidak ada atau tidak dipunyai oleh pengguna ini.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "Tidak dapat mendeteksi format umpan: %v.",
"error.feed_invalid_blocklist_rule": "Aturan blokir tidak valid.",
"error.feed_invalid_keeplist_rule": "Aturan simpan tidak valid.",
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Umpan ini tidak ada atau tidak dipunyai oleh pengguna ini",
"error.feed_title_not_empty": "Judul umpan tidak boleh kosong.",
"error.feed_url_not_empty": "URL umpan tidak boleh kosong.",
"error.fields_mandatory": "Semua bidang diharuskan.",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"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_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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.http_bad_gateway": "Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_body_read": "Tidak dapat membaca badan HTTP: %v.",
"error.http_client_error": "Galat klien HTTP: %v.",
"error.http_empty_response": "Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
"error.http_empty_response_body": "Badan balasan HTTP kosong.",
"error.http_forbidden": "Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
"error.http_gateway_timeout": "Situs ini tidak tersedia saat ini karena kesalahan akses jaringan peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_internal_server_error": "Situs ini tidak tersedia saat ini karena galat peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_not_authorized": "Akses ke situs ini tidak diizinkan. Mungkin nama pengguna atau kata sandinya salah.",
"error.http_resource_not_found": "Sumber daya yang diminta tidak ditemukan. Periksa kembali URL-nya.",
"error.http_response_too_large": "Balasan HTTP terlalu besar. Anda bisa menaikkan batas ukuran balasan HTTP di pengaturan global (membutuhkan pemulaian ulang peladen).",
"error.http_service_unavailable": "Situs ini tidak tersedia saat ini dikarenakan galat internal peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.http_too_many_requests": "Terlalu banyak koneksi dari Miniflux yang dibuat ke situs ini. Coba lagi nanti atau ubah konfigurasi aplikasi.",
"error.http_unexpected_status_code": "Situs ini tidak dapat dijangkau saat ini dikarenakan kode status HTTP tak diduga: %d Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
"error.invalid_categories_sorting_order": "Urutan penyortiran kategori tidak valid.",
"error.invalid_default_home_page": "Beranda baku tidak valid!",
"error.invalid_display_mode": "Mode tampilan aplikasi web tidak valid.",
"error.invalid_entry_direction": "Urutan entri tidak valid.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Urutan entri tidak valid.",
"error.invalid_feed_proxy_url": "URL proksi tidak valid.",
"error.invalid_feed_url": "URL umpan tidak valid.",
"error.invalid_gesture_nav": "Navigasi gestur tidak valid.",
"error.invalid_language": "Bahasa tidak valid.",
"error.invalid_site_url": "URL situs tidak valid.",
"error.invalid_theme": "Tema tidak valid.",
"error.invalid_timezone": "Zona waktu tidak valid.",
"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.network_operation": "Miniflux tidak dapat menjangkau situs ini dikarenakan galat jaringan: %v.",
"error.network_timeout": "Situs ini terlalu lambat dan permintaan ke situs terlalu lama: %v",
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.",
"error.pocket_access_token": "Tidak bisa mendapatkan token akses dari Pocket!",
"error.pocket_request_token": "Tidak bisa mendapatkan token permintaan dari Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "URL proksi tidak boleh kosong.",
"error.settings_block_rule_fieldname_invalid": "Aturan blokir tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)",
"error.settings_block_rule_invalid_regex": "Aturan blokir tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid",
"error.settings_block_rule_regex_required": "Aturan blokir tidak valid: aturan pola #%d tidak disediakan",
"error.settings_block_rule_separator_required": "Aturan blokir tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='",
"error.settings_invalid_domain_list": "Daftar domain tidak valid. Mohon sediakan daftar domain yang dipisah spasi.",
"error.settings_keep_rule_fieldname_invalid": "Aturan simpan tidak valid: aturan #%d tidak mempunyai nama bidang yang valid (Opsi: %s)",
"error.settings_keep_rule_invalid_regex": "Aturan simpan tidak valid: aturan pola #%d bukan ekspresi regular (regex) yang valid",
"error.settings_keep_rule_regex_required": "Aturan simpan tidak valid: aturan pola #%d tidak disediakan",
"error.settings_keep_rule_separator_required": "Aturan simpan tidak valid: aturan pola #%d diharuskan dipisah menggunakan '='",
"error.settings_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.",
"error.site_url_not_empty": "URL situs tidak boleh kosong.",
"error.subscription_not_found": "Tidak bisa mencari langganan apa pun.",
"error.title_required": "Judul diharuskan.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.title_required": "Judul harus ada.",
"error.tls_error": "Galat TLS: %q. Anda bisa mematikan verifikasi TLS di pengaturan umpan jika Anda mau.",
"error.unable_to_create_api_key": "Tidak bisa membuat kunci API ini.",
"error.unable_to_create_category": "Tidak bisa membuat kategori ini.",
"error.unable_to_create_user": "Tidak bisa membuat pengguna tersebut.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "Tidak dapat mendeteksi umpan menggunakan RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Tidak dapat membaca umpan: %v.",
"error.unable_to_update_category": "Tidak bisa memperbarui kategori ini.",
"error.unable_to_update_feed": "Tidak bisa memperbarui umpan ini.",
"error.unable_to_update_user": "Tidak bisa memperbarui pengguna tersebut.",
@@ -158,62 +160,64 @@
"form.api_key.label.description": "Label Kunci API",
"form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.category.label.title": "Judul",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.general": "Umum",
"form.feed.fieldset.integration": "Pengaturan Pihak Ketiga",
"form.feed.fieldset.network_settings": "Pengaturan Jaringan",
"form.feed.fieldset.rules": "Aturan",
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Aturan Blokir",
"form.feed.label.apprise_service_urls": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
"form.feed.label.block_filter_entry_rules": "Aturan Pemblokiran Entri",
"form.feed.label.blocklist_rules": "Filter Pemblokiran Berbasis Regex",
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Atur Kuki",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.description": "Deskripsi",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "Matikan HTTP/2 untuk menghindari pelacakan",
"form.feed.label.disabled": "Jangan perbarui umpan ini",
"form.feed.label.feed_password": "Kata Sandi Umpan",
"form.feed.label.feed_url": "URL Umpan",
"form.feed.label.feed_username": "Nama Pengguna Umpan",
"form.feed.label.fetch_via_proxy": "Gunakan proxy yang dikonfigurasi di tingkat aplikasi",
"form.feed.label.fetch_via_proxy": "Gunakan proksi yang dikonfigurasi di tingkat aplikasi",
"form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
"form.feed.label.keeplist_rules": "Aturan Simpan",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Aturan Tulis Ulang",
"form.feed.label.keep_filter_entry_rules": "Aturan Izin Entri",
"form.feed.label.keeplist_rules": "Filter Simpan Berbasis Regex",
"form.feed.label.no_media_player": "Tidak ada pemutar media (audio/video)",
"form.feed.label.ntfy_activate": "Kirim artikel ke ntfy",
"form.feed.label.ntfy_default_priority": "Prioritas baku Ntfy",
"form.feed.label.ntfy_high_priority": "Priroritas tinggi Ntfy",
"form.feed.label.ntfy_low_priority": "Prioritas rendah Ntfy",
"form.feed.label.ntfy_max_priority": "Prioritas maksimal Ntfy",
"form.feed.label.ntfy_min_priority": "Prioritas minimal Ntfy",
"form.feed.label.ntfy_priority": "Prioritas Ntfy",
"form.feed.label.ntfy_topic": "Topik Ntfy (opsional)",
"form.feed.label.proxy_url": "URL Proksi",
"form.feed.label.pushover_activate": "Kirim artikel ke pushover.net",
"form.feed.label.pushover_default_priority": "Prioritas baku Pushover",
"form.feed.label.pushover_high_priority": "Prioritas tinggi Pushover",
"form.feed.label.pushover_low_priority": "Prioritas rendah Pushover",
"form.feed.label.pushover_max_priority": "Prioritas maksimal Pushover",
"form.feed.label.pushover_min_priority": "Prioritas minimal Pushover",
"form.feed.label.pushover_priority": "Prioritas pesan Pushover",
"form.feed.label.rewrite_rules": "Aturan Penulisan Ulang Konten",
"form.feed.label.scraper_rules": "Aturan Pengambil Data",
"form.feed.label.site_url": "URL Situs",
"form.feed.label.title": "Judul",
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
"form.feed.label.user_agent": "Timpa User Agent Baku",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Timpa URL Webhook",
"form.import.label.file": "Berkas OPML",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.apprise_activate": "Kirim artikel ke Apprise",
"form.integration.apprise_services_url": "Daftar yang dipisahkan koma untuk URL layanan Apprise",
"form.integration.apprise_url": "URL API Apprise",
"form.integration.betula_activate": "Simpan artikel ke Betula",
"form.integration.betula_token": "Token Betula",
"form.integration.betula_url": "URL Peladen Betula",
"form.integration.cubox_activate": "Simpan artikel ke Cubox",
"form.integration.cubox_api_link": "Tautan API Cubox",
"form.integration.discord_activate": "Kirim artikel ke Discord",
"form.integration.discord_webhook_link": "Tautan Webhook Discord",
"form.integration.espial_activate": "Simpan artikel ke Espial",
"form.integration.espial_api_key": "Kunci API Espial",
"form.integration.espial_endpoint": "Titik URL API Espial",
@@ -229,36 +233,39 @@
"form.integration.instapaper_activate": "Simpan artikel ke Instapaper",
"form.integration.instapaper_password": "Kata Sandi Instapaper",
"form.integration.instapaper_username": "Nama Pengguna Instapaper",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.karakeep_activate": "Simpan artikel ke Karakeep",
"form.integration.karakeep_api_key": "Kunci API Karakeep",
"form.integration.karakeep_url": "Titik URL API Karakeep",
"form.integration.linkace_activate": "Simpan artikel ke LinkAce",
"form.integration.linkace_api_key": "Kunci API LinkAce",
"form.integration.linkace_check_disabled": "Matikan pemeriksaan tautan",
"form.integration.linkace_endpoint": "Titik URL API LinkAce",
"form.integration.linkace_is_private": "Tandai tautan sebagai pribadi",
"form.integration.linkace_tags": "Tanda LinkAce",
"form.integration.linkding_activate": "Simpan artikel ke Linkding",
"form.integration.linkding_api_key": "Kunci API Linkding",
"form.integration.linkding_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.linkding_endpoint": "Titik URL API Linkding",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_tags": "Tanda Linkding",
"form.integration.linkwarden_activate": "Simpan artikel ke Linkwarden",
"form.integration.linkwarden_api_key": "Kunci API Linkwarden",
"form.integration.linkwarden_endpoint": "Titik URL API Linkwarden",
"form.integration.linkwarden_endpoint": "URL Dasar Linkwarden",
"form.integration.matrix_bot_activate": "Kirim entri baru ke Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_user": "Nama Pengguna Matrix",
"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.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default used if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.notion_activate": "Simpan artikel ke Notion",
"form.integration.notion_page_id": "ID Halaman Notion",
"form.integration.notion_token": "Token Rahasia Notion",
"form.integration.ntfy_activate": "Kirim artikel ke ntfy",
"form.integration.ntfy_api_token": "Token API Ntfy (opsional)",
"form.integration.ntfy_icon_url": "URL ikon Ntfy (opsional)",
"form.integration.ntfy_internal_links": "Gunakan tautan internal ketika mengklik (opsional)",
"form.integration.ntfy_password": "Kata sandi Ntfy (opsional)",
"form.integration.ntfy_topic": "Topik Ntfy (yang akan digunakan jika tidak diatur di umpan)",
"form.integration.ntfy_url": "URL Ntfy (opsional, bawaan ke ntfy.sh)",
"form.integration.ntfy_username": "Nama pengguna Ntfy (opsional)",
"form.integration.nunux_keeper_activate": "Simpan artikel ke Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Kunci API Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Titik URL API Nunux Keeper",
@@ -269,45 +276,42 @@
"form.integration.pinboard_bookmark": "Tandai markah sebagai belum dibaca",
"form.integration.pinboard_tags": "Tanda di Pinboard",
"form.integration.pinboard_token": "Token API Pinboard",
"form.integration.pocket_access_token": "Token Akses Pocket",
"form.integration.pocket_activate": "Simpan artikel ke Pocket",
"form.integration.pocket_connect_link": "Hubungkan akun Pocket Anda",
"form.integration.pocket_consumer_key": "Kunci Pelanggan Pocket",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.pushover_activate": "Kirim artikel ke Pushover",
"form.integration.pushover_device": "Perangkat Pushover (opsional)",
"form.integration.pushover_prefix": "Prefiks URL Pushover (opsional)",
"form.integration.pushover_token": "Token API aplikasi Pushover",
"form.integration.pushover_user": "Kunci pengguna Pushover",
"form.integration.raindrop_activate": "Simpan artikel ke Raindrop",
"form.integration.raindrop_collection_id": "ID Koleksi",
"form.integration.raindrop_tags": "Tanda (dipisahkan koma)",
"form.integration.raindrop_token": "Token (Tes)",
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_labels": "Tagar Readeck",
"form.integration.readeck_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"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.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "Save articles to Shiori",
"form.integration.shiori_endpoint": "Shiori API Endpoint",
"form.integration.shiori_password": "Shiori Password",
"form.integration.shiori_username": "Shiori Username",
"form.integration.slack_activate": "Push entries to Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.readwise_activate": "Simpan artikel ke Readwise",
"form.integration.readwise_api_key": "Token Akses Readwise",
"form.integration.readwise_api_key_link": "Dapatkan Token Akses Readwise Anda",
"form.integration.rssbridge_activate": "Periksa RSS-Bridge ketika menambahkan langganan",
"form.integration.rssbridge_token": "Token autentikasi RSS-Bridge",
"form.integration.rssbridge_url": "URL peladen RSS-Bridge",
"form.integration.shaarli_activate": "Simpan artikel ke Shaarli",
"form.integration.shaarli_api_secret": "Rahasia API Shaarli",
"form.integration.shaarli_endpoint": "URL Shaarli",
"form.integration.shiori_activate": "Simpan artikel ke Shiori",
"form.integration.shiori_endpoint": "Titik URL API Shiori",
"form.integration.shiori_password": "Kata Sandi Shiori",
"form.integration.shiori_username": "Nama Pengguna Shiori",
"form.integration.slack_activate": "Kirim artikel ke Slack",
"form.integration.slack_webhook_link": "Tautan Webhook Slack",
"form.integration.telegram_bot_activate": "Kirim artikel baru ke percakapan Telegram",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_buttons": "Matikan tombol",
"form.integration.telegram_bot_disable_notification": "Matikan notifikasi",
"form.integration.telegram_bot_disable_web_page_preview": "Matikan tinjauan halaman web",
"form.integration.telegram_bot_token": "Token Bot",
"form.integration.telegram_chat_id": "ID Obrolan",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_topic_id": "ID Topik",
"form.integration.wallabag_activate": "Simpan artikel ke Wallabag",
"form.integration.wallabag_client_id": "ID Klien Wallabag",
"form.integration.wallabag_client_secret": "Rahasia Klien Wallabag",
@@ -315,14 +319,15 @@
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
"form.integration.wallabag_password": "Kata Sandi Wallabag",
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.integration.webhook_activate": "Aktifkan Webhook",
"form.integration.webhook_secret": "Rahasia Webhook",
"form.integration.webhook_url": "URL Webhook baku",
"form.prefs.fieldset.application_settings": "Pengaturan Aplikasi",
"form.prefs.fieldset.authentication_settings": "Pengaturan Autentikasi",
"form.prefs.fieldset.global_feed_settings": "Pengaturan Umpan Global",
"form.prefs.fieldset.reader_settings": "Pengaturan Pembaca",
"form.prefs.help.external_font_hosts": "Daftar yang dipisah spasi untuk peladen penyedia fonta eksternal yang diperbolehkan. Seperti: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Baca artikel dengan membuka tautan eksternal",
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
"form.prefs.label.cjk_reading_speed": "Kecepatan membaca untuk bahasa Tiongkok, Korea, dan Jepang (karakter per menit)",
"form.prefs.label.custom_css": "Modifikasi CSS",
@@ -334,15 +339,16 @@
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
"form.prefs.label.entry_sorting": "Pengurutan Entri",
"form.prefs.label.entry_swipe": "Aktifkan tindakan geser pada entri di ponsel",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Peladen penyedia fonta eksternal",
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
"form.prefs.label.keyboard_shortcuts": "Aktifkan pintasan papan tik",
"form.prefs.label.language": "Bahasa",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Tandai entri sebagai telah dibaca secara manual",
"form.prefs.label.mark_read_on_media_completion": "Tandai entri sebagai telah dibaca ketika audio/video sudah 90% didengar/ditonton",
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_view_or_media_completion": "Tandai entri sebagai telah dibaca ketika dilihat. Untuk audio/video, tandai sebagai telah dibaca ketika sudah 90% didengar/ditonton.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Buka tautan eksternal di tab baru (menambahkan target=\"_blank\" ke tautan)",
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Zona Waktu",
@@ -379,7 +385,7 @@
"menu.feeds": "Umpan",
"menu.flush_history": "Hapus riwayat",
"menu.history": "Riwayat",
"menu.home_page": "Home page",
"menu.home_page": "Beranda",
"menu.import": "Impor",
"menu.integrations": "Integrasi",
"menu.logout": "Keluar",
@@ -393,7 +399,7 @@
"menu.settings": "Pengaturan",
"menu.shared_entries": "Entri yang Dibagikan",
"menu.show_all_entries": "Tampilkan semua entri",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.show_only_starred_entries": "Tampilkan hanya entri yang dimarkahkan",
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
"menu.starred": "Markah",
"menu.title": "Menu",
@@ -403,6 +409,7 @@
"page.about.build_date": "Tanggal Penyusunan:",
"page.about.credits": "Pengembang",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Pengaturan Konfigurasi Global",
"page.about.go_version": "Versi Go:",
"page.about.license": "Lisensi:",
@@ -422,9 +429,6 @@
"page.api_keys.table.last_used_at": "Terakhir Digunakan",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Kunci API",
"page.categories_count": [
"%d category"
],
"page.categories.entries": "Artikel",
"page.categories.feed_count": [
"Ada %d umpan."
@@ -432,6 +436,9 @@
"page.categories.feeds": "Langganan",
"page.categories.no_feed": "Tidak ada umpan.",
"page.categories.title": "Kategori",
"page.categories_count": [
"%d kategori"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "Sunting Kategori: %s",
"page.edit_feed.etag_header": "Tajuk ETag:",
@@ -446,7 +453,7 @@
"%d galat"
],
"page.feeds.last_check": "Terakhir diperiksa:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Akan diperiksa kembali:",
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
"page.feeds.title": "Umpan",
"page.history.title": "Riwayat",
@@ -497,12 +504,12 @@
"page.keyboard_shortcuts.toggle_entry_attachments": "Buka/tutup lampiran entri",
"page.keyboard_shortcuts.toggle_read_status_next": "Ubah status baca, fokus ke selanjutnya",
"page.keyboard_shortcuts.toggle_read_status_prev": "Ubah status baca, fokus ke sebelumnya",
"page.login.google_signin": "Masuk dengan Google",
"page.login.oidc_signin": "Masuk dengan %s",
"page.login.google_signin": "Masuk menggunakan Google",
"page.login.oidc_signin": "Masuk menggunakan %s",
"page.login.title": "Masuk",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login": "Masuk menggunakan passkey",
"page.login.webauthn_login.error": "Tidak dapat masuk menggunakan passkey",
"page.login.webauthn_login.help": "Mohon untuk memasukkan nama pengguna Anda jika Anda menggunakan kunci keamanan. Tidak diperlukan jika anda menggunakan Passkey (kredensial dapat ditemukan).",
"page.new_api_key.title": "Kunci API Baru",
"page.new_category.title": "Kategori Baru",
"page.new_user.title": "Pengguna Baru",
@@ -510,7 +517,7 @@
"page.offline.refresh_page": "Coba untuk memuat ulang halaman ini",
"page.offline.title": "Mode Luring",
"page.read_entry_count": [
"%d read entry"
"%d entri dibaca"
],
"page.search.title": "Hasil Pencarian",
"page.sessions.table.actions": "Tindakan",
@@ -524,31 +531,31 @@
"page.settings.title": "Pengaturan",
"page.settings.unlink_google_account": "Putuskan akun Google saya",
"page.settings.unlink_oidc_account": "Putuskan akun %s saya",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.actions": "Tindakan",
"page.settings.webauthn.added_on": "Ditambahkan Pada",
"page.settings.webauthn.delete": [
"Remove %d passkey"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.shared_entries_count": [
"%d shared entry"
"Hapus %d passkey"
],
"page.settings.webauthn.last_seen_on": "Terakhir Digunakan",
"page.settings.webauthn.passkey_name": "Nama Passkey",
"page.settings.webauthn.passkeys": "Passkey",
"page.settings.webauthn.register": "Daftar passkey",
"page.settings.webauthn.register.error": "Tidak dapat mendaftarkan passkey",
"page.shared_entries.title": "Entri yang Dibagikan",
"page.starred_entry_count": [
"%d starred entry"
"page.shared_entries_count": [
"%d entri yang dibagikan"
],
"page.starred.title": "Markah",
"page.total_entry_count": [
"%d entry in total"
"page.starred_entry_count": [
"%d entri dimarkahi"
],
"page.unread_entry_count": [
"%d unread entry"
"page.total_entry_count": [
"%d entri secara total"
],
"page.unread.title": "Belum Dibaca",
"page.unread_entry_count": [
"%d entri belum dibaca"
],
"page.users.actions": "Tindakan",
"page.users.admin.no": "Tidak",
"page.users.admin.yes": "Ya",
@@ -557,15 +564,15 @@
"page.users.never_logged": "Tidak Pernah",
"page.users.title": "Pengguna",
"page.users.username": "Nama Pengguna",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"page.webauthn_rename.title": "Ubah Nama Passkey",
"pagination.first": "Pertama",
"pagination.last": "Terakhir",
"pagination.next": "Berikutnya",
"pagination.previous": "Sebelumnya",
"search.label": "Cari",
"search.placeholder": "Cari...",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "Cari",
"skip_to_content": "Langsung ke konten",
"time_elapsed.days": [
"%d hari yang lalu"
],
+62 -54
View File
@@ -13,7 +13,7 @@
"action.update": "Aggiorna",
"alert.account_linked": "Il tuo account esterno ora è collegato!",
"alert.account_unlinked": "Il tuo account esterno ora è scollegato!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Tutti i feed vengono aggiornati in background. Puoi continuare a usare Miniflux mentre questo processo è in esecuzione.",
"alert.feed_error": "Sembra ci sia un problema con questo feed",
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
@@ -27,28 +27,27 @@
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
"alert.no_unread_entry": "Nessun articolo da leggere.",
"alert.no_user": "Tu sei l'unico utente.",
"alert.pocket_linked": "Il tuo account Pocket ora è collegato!",
"alert.prefs_saved": "Preferenze salvate!",
"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."
"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuto prima di riprovare.",
"Hai richiesto troppi aggiornamenti dei feed. Attendi %d minuti prima di riprovare."
],
"confirm.loading": "In corso...",
"confirm.no": "no",
"confirm.question": "Sei sicuro?",
"confirm.question.refresh": "Vuoi forzare l'aggiornamento?",
"confirm.yes": "sì",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"entry.bookmark.toast.off": "Non speciali",
"entry.bookmark.toast.on": "Ha recitato",
"enclosure_media_controls.seek": "Sposta:",
"enclosure_media_controls.seek.title": "Sposta di %s secondi",
"enclosure_media_controls.speed": "Velocità:",
"enclosure_media_controls.speed.faster": "Più veloce",
"enclosure_media_controls.speed.faster.title": "Più veloce di %sx",
"enclosure_media_controls.speed.reset": "Reimposta",
"enclosure_media_controls.speed.reset.title": "Reimposta velocità a 1x",
"enclosure_media_controls.speed.slower": "Più lento",
"enclosure_media_controls.speed.slower.title": "Più lento di %sx",
"entry.bookmark.toast.off": "Non preferito",
"entry.bookmark.toast.on": "Preferito",
"entry.bookmark.toggle.off": "Rimuovi dai preferiti",
"entry.bookmark.toggle.on": "Aggiungi ai preferiti",
"entry.comments.label": "Commenti",
@@ -77,35 +76,39 @@
"entry.status.toast.read": "Contrassegnato come letto",
"entry.status.toast.unread": "Contrassegnato come non letto",
"entry.tags.label": "Tag:",
"entry.unshare.label": "Unshare",
"entry.tags.more_tags_label": [
"Mostra %d altro tag",
"Mostra %d altri tag"
],
"entry.unshare.label": "Rimuovi condivisione",
"error.api_key_already_exists": "Questa chiave API esiste già.",
"error.bad_credentials": "Nome utente o password non validi.",
"error.category_already_exists": "Questa categoria esiste già.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
"error.database_error": "Errore del database: %v.",
"error.different_passwords": "Le password non coincidono.",
"error.duplicate_fever_username": "Esiste già un account Fever con lo stesso nome utente!",
"error.duplicate_googlereader_username": "Esiste già un account Google Reader con lo stesso nome utente!",
"error.duplicate_linked_account": "Esiste già un account configurato per questo servizio!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "Questo feed esiste già.",
"error.empty_file": "Questo file è vuoto.",
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
"error.feed_already_exists": "Questo feed esiste già.",
"error.feed_category_not_found": "Questa categoria non esiste o non appartiene a questo utente.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "Impossibile rilevare il formato del feed: %v.",
"error.feed_invalid_blocklist_rule": "La regola dell'elenco di blocco non è valida.",
"error.feed_invalid_keeplist_rule": "La regola dell'elenco di conservazione non è valida.",
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Questo feed non esiste o non appartiene a questo utente.",
"error.feed_title_not_empty": "Il titolo del feed non può essere vuoto.",
"error.feed_url_not_empty": "L'URL del feed non può essere vuoto.",
"error.fields_mandatory": "Tutti i campi sono obbligatori.",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_bad_gateway": "Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.",
"error.http_body_read": "Impossibile leggere il corpo HTTP: %v.",
"error.http_client_error": "Errore del client HTTP: %v.",
"error.http_empty_response": "La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?",
"error.http_empty_response_body": "Il corpo della risposta HTTP è vuoto.",
"error.http_forbidden": "L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
@@ -114,22 +117,22 @@
"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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.invalid_categories_sorting_order": "L'ordinamento delle categorie non è valido.",
"error.invalid_default_home_page": "Pagina iniziale predefinita non valida!",
"error.invalid_display_mode": "Modalità di visualizzazione web app non valida.",
"error.invalid_entry_direction": "Ordinamento non valido.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "L'ordinamento delle voci non è valido.",
"error.invalid_feed_proxy_url": "URL del proxy non valido.",
"error.invalid_feed_url": "URL del feed non valido.",
"error.invalid_gesture_nav": "Navigazione gestuale non valida.",
"error.invalid_language": "Lingua non valida.",
"error.invalid_site_url": "URL del sito non valido.",
"error.invalid_theme": "Tema non valido.",
"error.invalid_timezone": "Fuso orario non valido.",
"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.network_operation": "Miniflux non riesce a raggiungere questo sito web a causa di un errore di rete: %v.",
"error.network_timeout": "Questo sito web è troppo lento e la richiesta è scaduta: %v",
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
"error.pocket_access_token": "Non sono riuscito ad ottenere l'access token da Pocket!",
"error.pocket_request_token": "Non sono riuscito ad ottenere il request token da Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "L'URL del proxy non può essere vuoto.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
@@ -149,8 +152,8 @@
"error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
"error.unable_to_create_category": "Non sono riuscito ad aggiungere questa categoria.",
"error.unable_to_create_user": "Non sono riuscito ad aggiungere questo user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "Impossibile rilevare il feed usando RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Impossibile analizzare questo feed: %v.",
"error.unable_to_update_category": "Non sono riuscito ad aggiornare questa categoria.",
"error.unable_to_update_feed": "Non sono riuscito ad aggiornare questo feed.",
"error.unable_to_update_user": "Non sono riuscito ad aggiornare questo utente.",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Rules",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Regole di blocco",
"form.feed.label.block_filter_entry_rules": "Regole di Blocco delle Voci",
"form.feed.label.blocklist_rules": "Filtri di Blocco Basati su Regex",
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Installare i cookies",
"form.feed.label.crawler": "Scarica il contenuto integrale",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Usa il proxy configurato a livello di applicazione",
"form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.keeplist_rules": "Regole di autorizzazione",
"form.feed.label.keep_filter_entry_rules": "Regole di Permesso delle Voci",
"form.feed.label.keeplist_rules": "Filtri di Mantenimento Basati su Regex",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
@@ -197,7 +202,7 @@
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Regole di impaginazione del contenuto",
"form.feed.label.rewrite_rules": "Regole di Riscrittura del Contenuto",
"form.feed.label.scraper_rules": "Regole di estrazione del contenuto",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.title": "Titolo",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Salva gli articoli su Instapaper",
"form.integration.instapaper_password": "Password dell'account Instapaper",
"form.integration.instapaper_username": "Nome utente dell'account Instapaper",
"form.integration.karakeep_activate": "Salva gli articoli su Karakeep",
"form.integration.karakeep_api_key": "API key dell'account Karakeep",
"form.integration.karakeep_url": "Endpoint dell'API di Karakeep",
"form.integration.linkace_activate": "Salva gli articoli su LinkAce",
"form.integration.linkace_api_key": "API key dell'account LinkAce",
"form.integration.linkace_check_disabled": "Disabilita i controlli",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Salva gli articoli su Linkwarden",
"form.integration.linkwarden_api_key": "API key dell'account Linkwarden",
"form.integration.linkwarden_endpoint": "Endpoint dell'API di Linkwarden",
"form.integration.linkwarden_endpoint": "URL di base di Linkwarden",
"form.integration.matrix_bot_activate": "Trasferimento di nuovi articoli a Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "Segna i preferiti come non letti",
"form.integration.pinboard_tags": "Tag di Pinboard",
"form.integration.pinboard_token": "Token dell'API di Pinboard",
"form.integration.pocket_access_token": "Access token dell'account Pocket",
"form.integration.pocket_activate": "Salva gli articoli su Pocket",
"form.integration.pocket_connect_link": "Collega il tuo account Pocket",
"form.integration.pocket_consumer_key": "Consumer key dell'account Pocket",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -325,6 +330,7 @@
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
"form.prefs.label.cjk_reading_speed": "Velocità di lettura per cinese, coreano e giapponese (caratteri al minuto)",
"form.prefs.label.custom_css": "CSS personalizzati",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Apri i link esterni in una nuova scheda (aggiunge target=\"_blank\" ai link)",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Fuso orario",
@@ -405,6 +412,7 @@
"page.about.build_date": "Data della build:",
"page.about.credits": "Crediti",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Opzioni di configurazione globali",
"page.about.go_version": "Go versione:",
"page.about.license": "Licenza:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Ultimo uso",
"page.api_keys.table.token": "Gettone",
"page.api_keys.title": "Chiavi API",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories.entries": "Articoli",
"page.categories.feed_count": [
"C'è %d feed.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Abbonamenti",
"page.categories.no_feed": "Nessun feed.",
"page.categories.title": "Categorie",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "Modifica categoria: %s",
"page.edit_feed.etag_header": "Header ETag:",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Registra la chiave di accesso",
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
"page.shared_entries.title": "Voci condivise",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries.title": "Voci condivise",
"page.starred.title": "Preferiti",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
],
"page.starred.title": "Preferiti",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
],
"page.unread.title": "Da leggere",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
],
"page.unread.title": "Da leggere",
"page.users.actions": "Azioni",
"page.users.admin.no": "No",
"page.users.admin.yes": "Sì",
@@ -569,14 +577,14 @@
"page.users.title": "Utenti",
"page.users.username": "Nome utente",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"pagination.first": "Primo",
"pagination.last": "Ultimo",
"pagination.next": "Successivo",
"pagination.previous": "Precedente",
"search.label": "Cerca",
"search.placeholder": "Cerca...",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "Cerca",
"skip_to_content": "Salta al contenuto",
"time_elapsed.days": [
"%d giorno fa",
"%d giorni fa"
+63 -56
View File
@@ -13,7 +13,7 @@
"action.update": "更新",
"alert.account_linked": "外部アカウントとリンクされました!",
"alert.account_unlinked": "外部アカウントとのリンクが解除されました!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "すべてのフィードがバックグラウンドで更新されています。この処理中も Miniflux を使い続けることができます。",
"alert.feed_error": "このフィードには問題があります。",
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
@@ -27,25 +27,24 @@
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_unread_entry": "未読の記事はありません。",
"alert.no_user": "あなたが唯一のユーザーです。",
"alert.pocket_linked": "Pocket アカウントとリンクされました!",
"alert.prefs_saved": "設定情報は保存されました!",
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
"フィードの更新を要求しすぎました。%d 分後に再度お試しください。"
],
"confirm.loading": "実行中…",
"confirm.no": "いいえ",
"confirm.question": "よろしいですか?",
"confirm.question.refresh": "強制的に更新しますか?",
"confirm.yes": "はい",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "シーク:",
"enclosure_media_controls.seek.title": "%s 秒シーク",
"enclosure_media_controls.speed": "速度:",
"enclosure_media_controls.speed.faster": "速く",
"enclosure_media_controls.speed.faster.title": "%sx 速く",
"enclosure_media_controls.speed.reset": "リセット",
"enclosure_media_controls.speed.reset.title": "速度を1xにリセット",
"enclosure_media_controls.speed.slower": "遅く",
"enclosure_media_controls.speed.slower.title": "%sx 遅く",
"entry.bookmark.toast.off": "星を外しました",
"entry.bookmark.toast.on": "星を付けました",
"entry.bookmark.toggle.off": "星を外す",
@@ -75,35 +74,38 @@
"entry.status.toast.read": "既読にしました",
"entry.status.toast.unread": "未読にしました",
"entry.tags.label": "タグ:",
"entry.tags.more_tags_label": [
"%d 個のタグ"
],
"entry.unshare.label": "共有を解除",
"error.api_key_already_exists": "この API キーは既に存在します。",
"error.bad_credentials": "ユーザー名かパスワードが間違っています。",
"error.category_already_exists": "このカテゴリは既に存在します。",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
"error.database_error": "データベースエラー: %v",
"error.different_passwords": "パスワードが一致しません。",
"error.duplicate_fever_username": "既に同じ名前の Fever ユーザー名が使われています!",
"error.duplicate_googlereader_username": "既に同じ名前の Google Reader ユーザー名が使われています!",
"error.duplicate_linked_account": "別なユーザーが既にこのサービスの同じユーザーとリンクしています。",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "このフィードは既に存在します。",
"error.empty_file": "このファイルは空です。",
"error.entries_per_page_invalid": "ページあたりの記事数が無効です。",
"error.feed_already_exists": "このフィードは既に存在します。",
"error.feed_category_not_found": "このカテゴリは存在しないか、このユーザーに属していません。",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "フィードの形式を検出できません: %v.",
"error.feed_invalid_blocklist_rule": "ブロックリストルールが無効です。",
"error.feed_invalid_keeplist_rule": "リストの保持ルールが無効です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "このフィードは存在しないか、このユーザーに属していません。",
"error.feed_title_not_empty": "フィードのタイトルを空にすることはできません。",
"error.feed_url_not_empty": "フィード URL を空にすることはできません。",
"error.fields_mandatory": "すべての項目が必要です。",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_bad_gateway": "ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。",
"error.http_body_read": "HTTP本文を読み取れません: %v",
"error.http_client_error": "HTTPクライアントエラー: %v",
"error.http_empty_response": "HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか?",
"error.http_empty_response_body": "HTTP応答本文が空です。",
"error.http_forbidden": "このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
@@ -112,22 +114,22 @@
"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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.invalid_categories_sorting_order": "カテゴリの表示順が無効です。",
"error.invalid_default_home_page": "デフォルトのトップページが無効です",
"error.invalid_display_mode": "Web アプリの表示モードが無効です。",
"error.invalid_entry_direction": "記事の表示順が無効です。",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "記事の表示順が無効です。",
"error.invalid_feed_proxy_url": "プロキシURLが無効です。",
"error.invalid_feed_url": "フィード URL が無効です。",
"error.invalid_gesture_nav": "ジェスチャー ナビゲーションが無効です。",
"error.invalid_language": "言語が無効です。",
"error.invalid_site_url": "サイト URL が無効です。",
"error.invalid_theme": "テーマが無効です。",
"error.invalid_timezone": "タイムゾーンが無効です。",
"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.network_operation": "Miniflux はネットワークエラーのためこのウェブサイトに到達できません: %v.",
"error.network_timeout": "このウェブサイトは応答が遅すぎるためタイムアウトしました: %v",
"error.password_min_length": "パスワードは6文字以上である必要があります。",
"error.pocket_access_token": "Pocket の access token が取得できません!",
"error.pocket_request_token": "Pocket の request token が取得できません!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "プロキシURLを空にすることはできません",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
@@ -147,8 +149,8 @@
"error.unable_to_create_api_key": "この API キーを作成できません。",
"error.unable_to_create_category": "このカテゴリは作成できません。",
"error.unable_to_create_user": "このユーザーは作成できません。",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "RSS-Bridge を使ってフィードを検出できません: %v.",
"error.unable_to_parse_feed": "このフィードを解析できません: %v.",
"error.unable_to_update_category": "このカテゴリは更新できません。",
"error.unable_to_update_feed": "このフィードは更新できません。",
"error.unable_to_update_user": "このユーザーは更新できません。",
@@ -164,7 +166,8 @@
"form.feed.fieldset.rules": "Rules",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Block ルール",
"form.feed.label.block_filter_entry_rules": "エントリブロッキングルール",
"form.feed.label.blocklist_rules": "正規表現ベースのブロッキングフィルター",
"form.feed.label.category": "カテゴリ",
"form.feed.label.cookie": "Cookie の設定",
"form.feed.label.crawler": "オリジナルの内容を取得",
@@ -177,7 +180,8 @@
"form.feed.label.fetch_via_proxy": "アプリケーションレベルで設定されたプロキシを使用する",
"form.feed.label.hide_globally": "未読一覧に記事を表示しない",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.keeplist_rules": "Keep ルール",
"form.feed.label.keep_filter_entry_rules": "エントリ許可ルール",
"form.feed.label.keeplist_rules": "正規表現ベースのキープフィルター",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
@@ -195,7 +199,7 @@
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Rewrite ルール",
"form.feed.label.rewrite_rules": "コンテンツ書き換えルール",
"form.feed.label.scraper_rules": "Scraper ルール",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.title": "タイトル",
@@ -229,6 +233,9 @@
"form.integration.instapaper_activate": "Instapaper に記事を保存する",
"form.integration.instapaper_password": "Instapaper のパスワード",
"form.integration.instapaper_username": "Instapaper のユーザー名",
"form.integration.karakeep_activate": "Karakeep に記事を保存する",
"form.integration.karakeep_api_key": "Karakeep の API key",
"form.integration.karakeep_url": "Karakeep の API Endpoint",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
@@ -242,7 +249,7 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Linkwarden に記事を保存する",
"form.integration.linkwarden_api_key": "Linkwarden の API key",
"form.integration.linkwarden_endpoint": "Linkwarden の API Endpoint",
"form.integration.linkwarden_endpoint": "リンクワーデン ベース URL",
"form.integration.matrix_bot_activate": "新しい記事をMatrixに転送する",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
@@ -269,10 +276,6 @@
"form.integration.pinboard_bookmark": "ブックマークを未読にする",
"form.integration.pinboard_tags": "Pinboard の Tag",
"form.integration.pinboard_token": "Pinboard の API Token",
"form.integration.pocket_access_token": "Pocket の Access Token",
"form.integration.pocket_activate": "Pocket に記事を保存する",
"form.integration.pocket_connect_link": "Pocket account に接続",
"form.integration.pocket_consumer_key": "Pocket の Consumer Key",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -291,6 +294,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -323,6 +327,7 @@
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
"form.prefs.label.cjk_reading_speed": "中国語、韓国語、日本語の読書速度(文字数/分)",
"form.prefs.label.custom_css": "カスタム CSS",
@@ -343,6 +348,7 @@
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"form.prefs.label.open_external_links_in_new_tab": "外部リンクを新しいタブで開く(リンクに target=\"_blank\" を追加)",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
"form.prefs.label.theme": "テーマ",
"form.prefs.label.timezone": "タイムゾーン",
@@ -403,6 +409,7 @@
"page.about.build_date": "ビルド日時:",
"page.about.credits": "著作権表示",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "グローバル構成オプション",
"page.about.go_version": "Go バージョン:",
"page.about.license": "ライセンス:",
@@ -422,9 +429,6 @@
"page.api_keys.table.last_used_at": "最終使用",
"page.api_keys.table.token": "トークン",
"page.api_keys.title": "API キー",
"page.categories_count": [
"%d category"
],
"page.categories.entries": "記事一覧",
"page.categories.feed_count": [
"%d 件のフィードがあります。"
@@ -432,6 +436,9 @@
"page.categories.feeds": "フィード一覧",
"page.categories.no_feed": "フィードはありません。",
"page.categories.title": "カテゴリ",
"page.categories_count": [
"%d 件のカテゴリ"
],
"page.category_label": "Category: %s",
"page.edit_category.title": "カテゴリを編集: %s",
"page.edit_feed.etag_header": "ETag ヘッダー:",
@@ -510,7 +517,7 @@
"page.offline.refresh_page": "ページを更新してみてください",
"page.offline.title": "オフラインモード",
"page.read_entry_count": [
"%d read entry"
"%d 件の既読エントリ"
],
"page.search.title": "検索結果",
"page.sessions.table.actions": "アクション",
@@ -534,21 +541,21 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.shared_entries_count": [
"%d shared entry"
],
"page.shared_entries.title": "共有エントリ",
"page.starred_entry_count": [
"%d starred entry"
"page.shared_entries_count": [
"%d 件の共有エントリ"
],
"page.starred.title": "星付き",
"page.total_entry_count": [
"%d entry in total"
"page.starred_entry_count": [
"%d 件の星付きエントリ"
],
"page.unread_entry_count": [
"%d unread entry"
"page.total_entry_count": [
"合計 %d 件のエントリ"
],
"page.unread.title": "未読",
"page.unread_entry_count": [
"%d 件の未読エントリ"
],
"page.users.actions": "アクション",
"page.users.admin.no": "非管理者",
"page.users.admin.yes": "管理者",
@@ -558,14 +565,14 @@
"page.users.title": "ユーザー一覧",
"page.users.username": "ユーザー名",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"pagination.first": "最初",
"pagination.last": "最後",
"pagination.next": "次",
"pagination.previous": "前",
"search.label": "検索",
"search.placeholder": "…を検索",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "検索",
"skip_to_content": "コンテンツへスキップ",
"time_elapsed.days": [
"%d 日前"
],
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit",
"alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit",
"alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng",
"alert.pocket_linked": "Í-keng kā lí ê Pocket kháu-chō kiat chòe-hé--ah!",
"alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!",
"alert.too_many_feeds_refresh": [
"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi."
@@ -37,8 +36,8 @@
"confirm.question": "Kám ū khak-tēng?",
"confirm.question.refresh": "Kám beh kiông-chè têng lia̍h?",
"confirm.yes": "Sī",
"enclosure_media_controls.seek": "sóa-ūi:",
"enclosure_media_controls.seek.title": "sóa %s bió",
"enclosure_media_controls.seek": "Sóa-ūi:",
"enclosure_media_controls.seek.title": "Sóa %s bió",
"enclosure_media_controls.speed": "Sok-tō͘",
"enclosure_media_controls.speed.faster": "Cheng-ka sok-tō͘",
"enclosure_media_controls.speed.faster.title": "Cheng-ka sok-tō͘ %sx",
@@ -75,6 +74,9 @@
"entry.status.toast.read": "Chù chòe tha̍k kè chòe soah",
"entry.status.toast.unread": "Chù chòe ah-bōe tha̍k chòe soah",
"entry.tags.label": "Khan-á:",
"entry.tags.more_tags_label": [
"Kah %d khan-á"
],
"entry.unshare.label": "Chhú-siau hun-hióng",
"error.api_key_already_exists": "Chit ê API só-sî í-keng chûn-chāi",
"error.bad_credentials": "M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.",
@@ -112,10 +114,12 @@
"error.http_service_unavailable": "Chit ê bāng-chām in-ūi in ka-kī lāi-pō͘ ū būn-tôem̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.http_too_many_requests": "Miniflux tùi chit ê bāng-chām ê chhéng-kiû siuⁿ kè chōe, chhiáⁿ têng chhì-khòaⁿ-māi ah-sī tiâu-chéng thêng-sek siat-tēng.",
"error.http_unexpected_status_code": "Chit ê bāng-chām chòe liáu chi̍t ê liāu-bōe-tio̍h ê HTTP chōng-thài bé: %d, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
"error.invalid_categories_sorting_order": "Lūi-pia̍t ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.",
"error.invalid_default_home_page": "Ū-siat chú-ia̍h ū būn-tôe!",
"error.invalid_display_mode": "Ū būn-tôe ê su-li̍p bô͘-sek.",
"error.invalid_entry_direction": "Ū būn-tôe ê su-li̍p hong-hiòng.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Siau-sit ê chōe pái bô-hāu, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi.",
"error.invalid_feed_proxy_url": "Proxy URL ū būn-tôe.",
"error.invalid_feed_url": "Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.",
"error.invalid_gesture_nav": "Chhiú-sè tō-lám ū būn-tôe.",
"error.invalid_language": "Ū būn-tôe ê gú-giân.",
@@ -125,9 +129,7 @@
"error.network_operation": "Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.",
"error.network_timeout": "Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.",
"error.password_min_length": "Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.",
"error.pocket_access_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h access token",
"error.pocket_request_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h request token",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "Proxy URL bōe-sái sī khang--ê.",
"error.settings_block_rule_fieldname_invalid": "Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
"error.settings_block_rule_invalid_regex": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
"error.settings_block_rule_regex_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
@@ -164,7 +166,8 @@
"form.feed.fieldset.rules": "Kui-chek",
"form.feed.label.allow_self_signed_certificates": "ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng",
"form.feed.label.apprise_service_urls": "Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
"form.feed.label.blocklist_rules": "Hong-só kui-chek",
"form.feed.label.block_filter_entry_rules": "Entry Blocking Rules",
"form.feed.label.blocklist_rules": "Regex-Based Blocking Filters",
"form.feed.label.category": "lūi-pia̍t",
"form.feed.label.cookie": "Siat-tēng Cookies",
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
@@ -177,7 +180,8 @@
"form.feed.label.fetch_via_proxy": "Iōng tī su-hāu-khì siat-tēng ê proxy",
"form.feed.label.hide_globally": "Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit",
"form.feed.label.ignore_http_cache": "Pàng-ba̍k HTTP cache",
"form.feed.label.keeplist_rules": "Pó-liû kui-chek",
"form.feed.label.keep_filter_entry_rules": "Entry Allow Rules",
"form.feed.label.keeplist_rules": "Regex-Based Keep Filters",
"form.feed.label.no_media_player": "Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)",
"form.feed.label.ntfy_activate": "Thui-sàng siau-sit khì ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy ū-siat iu-sian sūn-sū",
@@ -188,14 +192,14 @@
"form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū",
"form.feed.label.ntfy_topic": "Ntfy topic (soán thiⁿ)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to Pushover",
"form.feed.label.pushover_default_priority": "Default priority",
"form.feed.label.pushover_high_priority": "High priority",
"form.feed.label.pushover_low_priority": "Low priority",
"form.feed.label.pushover_max_priority": "Max priority",
"form.feed.label.pushover_min_priority": "Minimal priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Têng siá kui-chek",
"form.feed.label.pushover_activate": "Pó-chûn siau-sit kàu pushover.net",
"form.feed.label.pushover_default_priority": "Pushover ū-siat iu-sian sūn-sū",
"form.feed.label.pushover_high_priority": "Pushover koân iu-sian sūn-sū",
"form.feed.label.pushover_low_priority": "Pushover kē iu-sian sūn-sū",
"form.feed.label.pushover_max_priority": "Pushover siōng koân iu-sian sūn-sū",
"form.feed.label.pushover_min_priority": "Pushover siōng kē iu-sian sūn-sū",
"form.feed.label.pushover_priority": "Pushover siau-sit iu-sian sūn-sū",
"form.feed.label.rewrite_rules": "Content Rewrite Rules",
"form.feed.label.scraper_rules": "Lia̍h ê kui-chek",
"form.feed.label.site_url": "Bāng-chām bāng-chí",
"form.feed.label.title": "Piau-tôe",
@@ -229,6 +233,9 @@
"form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
"form.integration.instapaper_password": "Instapaper bi̍t-bé",
"form.integration.instapaper_username": "Instapaper Kháu-chō miâ",
"form.integration.karakeep_activate": "Pó-chûn siau-sit kàu Karakeep",
"form.integration.karakeep_api_key": "Karakeep API só-sî",
"form.integration.karakeep_url": "Karakeep API thâu",
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
"form.integration.linkace_api_key": "LinkAce API só-sî",
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
@@ -242,7 +249,7 @@
"form.integration.linkding_tags": "Linkding khan-á",
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
"form.integration.linkwarden_endpoint": "Linkwarden API thâu",
"form.integration.linkwarden_endpoint": "Linkwarden Base URL",
"form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
"form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
"form.integration.matrix_bot_password": "Matrix bi̍t-bé",
@@ -269,15 +276,11 @@
"form.integration.pinboard_bookmark": "Chù chòe ah-bōe tha̍k",
"form.integration.pinboard_tags": "Pinboard khan-á",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pocket_access_token": "Pocket token",
"form.integration.pocket_activate": "Pó-chûn siau-sit kàu Pocket",
"form.integration.pocket_connect_link": "Kah Pocket kháu-chō kiat chòe-hé",
"form.integration.pocket_consumer_key": "Pocket sú-iōng-lâng só-sî",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.pushover_activate": "Pó-chûn siau-sit kàu Pushover",
"form.integration.pushover_device": "Pushover ki-hì (soán thiⁿ)",
"form.integration.pushover_prefix": "Pushover URL tó͘-bí (soán thiⁿ)",
"form.integration.pushover_token": "Pushover application API só-sî",
"form.integration.pushover_user": "Pushover sú-iōng-lâng só-sî",
"form.integration.raindrop_activate": "Pó-chûn siau-sit kàu Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "khan-á (iōng tō͘-tiám keh khui)",
@@ -291,6 +294,7 @@
"form.integration.readwise_api_key": "Readwise Reader Acess Token",
"form.integration.readwise_api_key_link": "Chhú-tek lí ê Readwise Acess Token",
"form.integration.rssbridge_activate": "Sin cheng-ka siau-sit lâi-goân ê sî tio̍h RSS-Bridge",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge su-hāu-khì的bāng-chí",
"form.integration.shaarli_activate": "Pó-chûn siau-sit kàu Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API só-sî",
@@ -323,6 +327,7 @@
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
"form.prefs.label.always_open_external_links": "Chhiau-chhē bûn-chiong sī iōng gōa-pō͘ liân-kiat phah khui",
"form.prefs.label.categories_sorting_order": "Lūi-pia̍t hián-sī sūn-sū",
"form.prefs.label.cjk_reading_speed": "Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)",
"form.prefs.label.custom_css": "Chū tēng ê CSS",
@@ -343,14 +348,15 @@
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
"form.prefs.label.open_external_links_in_new_tab": "Chhiau-chhē gōa-pō͘ liân-kiat sī tī sin ê ia̍h phah khui (kā liân-kiat chhē target=\"_blank\")",
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
"form.prefs.label.theme": "Chú-tôe",
"form.prefs.label.timezone": "Sî-khu",
"form.prefs.select.alphabetical": "Chiàu lī-bú pâi",
"form.prefs.select.browser": "Tī iû-lám khì phah khui",
"form.prefs.select.browser": "Iû-lâm-khì",
"form.prefs.select.created_time": "Siau-sit kiàn-li̍p sî-kan",
"form.prefs.select.fullscreen": "Choân êng-bō͘",
"form.prefs.select.minimal_ui": "Siōng iông-chhun--ê",
"form.prefs.select.minimal_ui": "Siōng sió UI",
"form.prefs.select.none": "Bô",
"form.prefs.select.older_first": "Ùi kū--ê khai-sí pâi",
"form.prefs.select.publish_time": "Siau-sit hoat-pò͘ sî-kan",
@@ -402,7 +408,8 @@
"page.about.author": "Chok-chiá: ",
"page.about.build_date": "Kiàn-tì li̍t-kî:",
"page.about.credits": "Pán-koân",
"page.about.db_usage": "Database size:",
"page.about.db_usage": "Database chhài-chhiú:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Choân-he̍k siat-tēng soán-hāng",
"page.about.go_version": "Go pán-pún:",
"page.about.license": "Pàng-koân:",
@@ -422,9 +429,6 @@
"page.api_keys.table.last_used_at": "Siōng-bóe pái sú-iōng",
"page.api_keys.table.token": "Só-sî",
"page.api_keys.title": "API só-sî",
"page.categories_count": [
"%d ê lūi-pia̍t"
],
"page.categories.entries": "Siau-sit",
"page.categories.feed_count": [
"Ū %d ê Siau-sit lâi-goân"
@@ -432,6 +436,9 @@
"page.categories.feeds": "Siau-sit lâi-goân",
"page.categories.no_feed": "Ah-bô siau-sit lâi-goân",
"page.categories.title": "Lūi-pia̍t",
"page.categories_count": [
"%d ê lūi-pia̍t"
],
"page.category_label": "Lūi-pia̍t: %s",
"page.edit_category.title": "Pian-chi̍p lūi-pia̍t: %s",
"page.edit_feed.etag_header": "ETag piau-thâu:",
@@ -534,21 +541,21 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Chù-chheh Passkey",
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
"page.shared_entries_count": [
"Í-keng hun-hióng %d ê siau-sit"
],
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
"page.starred.title": "Siu-chông",
"page.starred_entry_count": [
"%d ê siu-chông ê siau-sit"
],
"page.starred.title": "Siu-chông",
"page.total_entry_count": [
"Lóng-chóng %d ê siau-sit"
],
"page.unread.title": "Ah-bōe tha̍k",
"page.unread_entry_count": [
"%d ê siau-sit ah-bōe tha̍k"
],
"page.unread.title": "Ah-bōe tha̍k",
"page.users.actions": "chhau-chok",
"page.users.admin.no": "Hóⁿ",
"page.users.admin.yes": "Sī",
@@ -565,7 +572,7 @@
"search.label": "Chhiau-chhē",
"search.placeholder": "Chhiau-chhē...",
"search.submit": "Chhiau-chhē",
"skip_to_content": "Sóa kah chú-iàu ê lōe-iông",
"skip_to_content": "Thiaⁿ--khì chhòng-bûn",
"time_elapsed.days": [
"%d kang chêng"
],
@@ -589,4 +596,4 @@
"time_elapsed.yesterday": "cha-hng",
"tooltip.keyboard_shortcuts": "Khoài-sok khí:%s",
"tooltip.logged_user": "Chit-má teng-lo̍k--ê: %s"
}
}
+46 -38
View File
@@ -1,6 +1,6 @@
{
"action.cancel": "annuleren",
"action.download": "Download",
"action.download": "Downloaden",
"action.edit": "Bewerken",
"action.home_screen": "Toevoegen aan startscherm",
"action.import": "Importeren",
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.",
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
"alert.no_user": "Je bent de enige gebruiker.",
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!",
"alert.prefs_saved": "Instellingen opgeslagen!",
"alert.too_many_feeds_refresh": [
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
@@ -43,7 +42,7 @@
"enclosure_media_controls.speed": "Snelheid:",
"enclosure_media_controls.speed.faster": "Versnel",
"enclosure_media_controls.speed.faster.title": "Versnel met %sx",
"enclosure_media_controls.speed.reset": "Reset",
"enclosure_media_controls.speed.reset": "Resetten",
"enclosure_media_controls.speed.reset.title": "Reset snelheid naar 1x",
"enclosure_media_controls.speed.slower": "Vertraag",
"enclosure_media_controls.speed.slower.title": "Vertraag met %sx",
@@ -77,6 +76,10 @@
"entry.status.toast.read": "Gemarkeerd als gelezen",
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
"entry.tags.label": "Tags:",
"entry.tags.more_tags_label": [
"Toon %d extra tag",
"Toon %d extra tags"
],
"entry.unshare.label": "Delen ongedaan maken",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
@@ -114,10 +117,12 @@
"error.http_service_unavailable": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.http_too_many_requests": "Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.",
"error.http_unexpected_status_code": "De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
"error.invalid_categories_sorting_order": "Ongeldige volgorde van categorieën.",
"error.invalid_default_home_page": "Ongeldige startpagina!",
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Ongeldige volgorde van artikelen.",
"error.invalid_feed_proxy_url": "Ongeldige proxy-URL.",
"error.invalid_feed_url": "Ongeldige feed URL.",
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
"error.invalid_language": "Ongeldige taal.",
@@ -127,14 +132,12 @@
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "De proxy-URL mag niet leeg zijn.",
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_invalid_domain_list": "Ongeldige domeinlijst. Geef een spatiegescheiden lijst van domeinen op.",
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
@@ -166,7 +169,8 @@
"form.feed.fieldset.rules": "Regels",
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
"form.feed.label.blocklist_rules": "Blokkeerregels",
"form.feed.label.block_filter_entry_rules": "Blokkeerregels voor Items",
"form.feed.label.blocklist_rules": "Regex-gebaseerde Blokkeerfilters",
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.crawler": "Download originele inhoud",
@@ -179,7 +183,8 @@
"form.feed.label.fetch_via_proxy": "Gebruik de proxy die op applicatieniveau is geconfigureerd",
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.keeplist_rules": "Bewaarregels",
"form.feed.label.keep_filter_entry_rules": "Toestaan Regels voor Items",
"form.feed.label.keeplist_rules": "Regex-gebaseerde Bewaarfilters",
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
@@ -188,16 +193,16 @@
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.ntfy_topic": "Ntfy onderwerp (optioneel)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Herschrijfregels",
"form.feed.label.pushover_activate": "Stuur artikelen naar pushover.net",
"form.feed.label.pushover_default_priority": "Pushover standaard prioriteit",
"form.feed.label.pushover_high_priority": "Pushover hoge prioriteit",
"form.feed.label.pushover_low_priority": "Pushover lage prioriteit",
"form.feed.label.pushover_max_priority": "Pushover maximale prioriteit",
"form.feed.label.pushover_min_priority": "Pushover minimale prioriteit",
"form.feed.label.pushover_priority": "Pushover berichtprioriteit",
"form.feed.label.rewrite_rules": "Inhoud Herschrijfregels",
"form.feed.label.scraper_rules": "Extractieregels",
"form.feed.label.site_url": "Website URL",
"form.feed.label.title": "Titel",
@@ -212,8 +217,8 @@
"form.integration.betula_activate": "Artikelen opslaan in Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.cubox_activate": "Artikelen opslaan in Cubox",
"form.integration.cubox_api_link": "Cubox API-link",
"form.integration.discord_activate": "Artikelen opslaan in Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.espial_activate": "Artikelen opslaan in Espial",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
"form.integration.instapaper_password": "Instapaper wachtwoord",
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
"form.integration.karakeep_activate": "Artikelen opslaan in Karakeep",
"form.integration.karakeep_api_key": "Karakeep API-sleutel",
"form.integration.karakeep_url": "Karakeep URL",
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
"form.integration.linkace_api_key": "LinkAce API-sleutel",
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
@@ -244,7 +252,7 @@
"form.integration.linkding_tags": "Linkding tags",
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
"form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_endpoint": "Linkwarden Basis URL",
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
@@ -256,7 +264,7 @@
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_internal_links": "Gebruik interne links bij klikken (optioneel)",
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
"form.integration.ntfy_topic": "Ntfy topic (standaard gebruikt als deze niet is ingesteld in feed)",
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
@@ -271,10 +279,6 @@
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
"form.integration.pinboard_tags": "Pinboard tags",
"form.integration.pinboard_token": "Pinboard API token",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_activate": "Artikelen opslaan in Pocket",
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -293,6 +297,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen",
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -324,7 +329,8 @@
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.help.external_font_hosts": "Spatiegescheiden lijst van externe font-hosts die zijn toegestaan. Bijvoorbeeld: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Lees artikelen door externe links te openen",
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
"form.prefs.label.custom_css": "Aangepaste CSS",
@@ -336,7 +342,7 @@
"form.prefs.label.entry_order": "Artikelen sorteren",
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Externe font-hosts",
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
"form.prefs.label.language": "Taal",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Open externe links in een nieuw tabblad (voegt target=\"_blank\" toe aan links)",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
"form.prefs.label.theme": "Thema",
"form.prefs.label.timezone": "Tijdzone",
@@ -404,7 +411,8 @@
"page.about.author": "Auteur:",
"page.about.build_date": "Compilatiedatum:",
"page.about.credits": "Credits",
"page.about.db_usage": "Database size:",
"page.about.db_usage": "Databasegrootte:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Globale Configuratie Opties",
"page.about.go_version": "Go versie:",
"page.about.license": "Licentie:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API-sleutels",
"page.categories_count": [
"%d categorie",
"%d categorieën"
],
"page.categories.entries": "Artikelen",
"page.categories.feed_count": [
"Er is %d feed.",
@@ -436,6 +440,10 @@
"page.categories.feeds": "Feeds",
"page.categories.no_feed": "Geen feed.",
"page.categories.title": "Categorieën",
"page.categories_count": [
"%d categorie",
"%d categorieën"
],
"page.category_label": "Categorie: %s",
"page.edit_category.title": "Bewerk categorie: %s",
"page.edit_feed.etag_header": "ETAG header:",
@@ -507,7 +515,7 @@
"page.login.title": "Inloggen",
"page.login.webauthn_login": "Inloggen met passkey",
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login.help": "Voer je gebruikersnaam in als je een beveiligingssleutel gebruikt. Dit is niet nodig als je een Passkey (ontdekkingsbare referenties) gebruikt.",
"page.new_api_key.title": "Nieuwe API-sleutel",
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
@@ -541,25 +549,25 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Passkey registreren",
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
"page.shared_entries.title": "Gedeelde artikelen",
"page.shared_entries_count": [
"%d gedeeld artikel",
"%d gedeelde artikelen"
],
"page.shared_entries.title": "Gedeelde artikelen",
"page.starred.title": "Favorieten",
"page.starred_entry_count": [
"%d favoriet artikel",
"%d favoriete artikelen"
],
"page.starred.title": "Favorieten",
"page.total_entry_count": [
"%d artikel totaal",
"%d artikelen totaal"
],
"page.unread.title": "Ongelezen",
"page.unread_entry_count": [
"%d ongelezen artikel",
"%d ongelezen artikelen"
],
"page.unread.title": "Ongelezen",
"page.users.actions": "Acties",
"page.users.admin.no": "Nee",
"page.users.admin.yes": "Ja",
+30 -21
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.",
"alert.no_user": "Jesteś jedynym użytkownikiem.",
"alert.pocket_linked": "Twoje konto Pocket jest teraz połączone!",
"alert.prefs_saved": "Ustawienia zapisane!",
"alert.too_many_feeds_refresh": [
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.",
@@ -79,6 +78,11 @@
"entry.status.toast.read": "Oznaczono jako przeczytany",
"entry.status.toast.unread": "Oznaczono jako nieprzeczytany",
"entry.tags.label": "Znaczniki:",
"entry.tags.more_tags_label": [
"Dodaj znacznik",
"Dodaj %d znaczniki",
"Dodaj %d znaczników"
],
"entry.unshare.label": "Cofnij udostępnianie",
"error.api_key_already_exists": "Ten klucz API już istnieje.",
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
@@ -96,7 +100,7 @@
"error.feed_category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
"error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.",
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
"error.feed_invalid_keeplist_rule": "Reguła listy zachowania jest nieprawidłowa.",
"error.feed_invalid_keeplist_rule": "Reguła listy zachowywania jest nieprawidłowa.",
"error.feed_mandatory_fields": "Adres URL i kategoria są obowiązkowe.",
"error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.",
"error.feed_title_not_empty": "Tytuł kanału nie może być pusty.",
@@ -116,9 +120,11 @@
"error.http_service_unavailable": "Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_too_many_requests": "Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.",
"error.http_unexpected_status_code": "Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.invalid_categories_sorting_order": "Nieprawidłowa kolejność sortowania kategorii.",
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji sieciowej.",
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
"error.invalid_entry_order": "Nieprawidłowa kolejność sortowania wpisów.",
"error.invalid_feed_proxy_url": "Nieprawidłowy adres URL serwera proxy.",
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
@@ -129,8 +135,6 @@
"error.network_operation": "Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.",
"error.network_timeout": "Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
"error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
"error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
"error.proxy_url_not_empty": "Adres URL serwera proxy nie może być pusty.",
"error.settings_block_rule_fieldname_invalid": "Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_block_rule_invalid_regex": "Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
@@ -168,7 +172,8 @@
"form.feed.fieldset.rules": "Reguły",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty",
"form.feed.label.apprise_service_urls": "Rozdzielana przecinkami lista adresów URL usług Appprise",
"form.feed.label.blocklist_rules": "Reguły blokowania",
"form.feed.label.block_filter_entry_rules": "Reguły blokowania wpisów",
"form.feed.label.blocklist_rules": "Filtry blokowania oparte na wyrażeniach regularnych",
"form.feed.label.category": "Kategoria",
"form.feed.label.cookie": "Ustaw ciasteczka",
"form.feed.label.crawler": "Pobierz oryginalną treść",
@@ -181,7 +186,8 @@
"form.feed.label.fetch_via_proxy": "Użyj serwera proxy skonfigurowanego na poziomie aplikacji",
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.keeplist_rules": "Reguły utrzymywania",
"form.feed.label.keep_filter_entry_rules": "Reguły zachowywania wpisów",
"form.feed.label.keeplist_rules": "Filtry zachowywania oparte na wyrażeniach regularnych",
"form.feed.label.no_media_player": "Brak odtwarzacza multimedialnego (audio i wideo)",
"form.feed.label.ntfy_activate": "Prześlij wpisy do ntfy",
"form.feed.label.ntfy_default_priority": "Domyślny priorytet ntfy",
@@ -199,7 +205,7 @@
"form.feed.label.pushover_max_priority": "Maksymalny priorytet Pushover",
"form.feed.label.pushover_min_priority": "Minimalny priorytet Pushover",
"form.feed.label.pushover_priority": "Priorytet wiadomości Pushover",
"form.feed.label.rewrite_rules": "Reguły zapisu",
"form.feed.label.rewrite_rules": "Reguły przepisywania treści",
"form.feed.label.scraper_rules": "Reguły ekstrakcji",
"form.feed.label.site_url": "Adres URL strony",
"form.feed.label.title": "Tytuł",
@@ -233,6 +239,9 @@
"form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
"form.integration.instapaper_password": "Hasło do Instapaper",
"form.integration.instapaper_username": "Login do Instapaper",
"form.integration.karakeep_activate": "Zapisuj wpisy w Karakeep",
"form.integration.karakeep_api_key": "Klucz API do Karakeep",
"form.integration.karakeep_url": "Punkt końcowy API Karakeep",
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
"form.integration.linkace_api_key": "Klucz API do LinkAce",
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
@@ -246,7 +255,7 @@
"form.integration.linkding_tags": "Znaczniki Linkding",
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
"form.integration.linkwarden_endpoint": "Punkt końcowy API Linkwarden",
"form.integration.linkwarden_endpoint": "Podstawowy adres URL Linkwardena",
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.matrix_bot_password": "Hasło do Matrix",
@@ -273,10 +282,6 @@
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.pinboard_tags": "Znaczniki Pinboard",
"form.integration.pinboard_token": "Token API do Pinboard",
"form.integration.pocket_access_token": "Token dostępu do Pocket",
"form.integration.pocket_activate": "Zapisuj wpisy w Pocket",
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
"form.integration.pocket_consumer_key": "Klucz klienta do Pocket",
"form.integration.pushover_activate": "Prześlij wpisy do Pushover",
"form.integration.pushover_device": "Urządzenie Pushover (opcjonalne)",
"form.integration.pushover_prefix": "Prefiks adresu URL Pushover (opcjonalny)",
@@ -295,6 +300,7 @@
"form.integration.readwise_api_key": "Token dostępu do czytnika Readwise",
"form.integration.readwise_api_key_link": "Zdobądź token dostępu Readwise",
"form.integration.rssbridge_activate": "Sprawdź RSS-Bridge podczas dodawania subskrypcji",
"form.integration.rssbridge_token": "Token uwierzytelniający RSS-Bridge",
"form.integration.rssbridge_url": "Adres URL serwera RSS-Bridge",
"form.integration.shaarli_activate": "Zapisuj artykuły w Shaarli",
"form.integration.shaarli_api_secret": "Tajny klucz API do Shaarli",
@@ -304,7 +310,7 @@
"form.integration.shiori_password": "Hasło do Shiori",
"form.integration.shiori_username": "Login do Shiori",
"form.integration.slack_activate": "Przesyłaj wpisy do Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.slack_webhook_link": "Łącze webhooka Slack",
"form.integration.telegram_bot_activate": "Przesyłaj nowe wpisy do czatu Telegram",
"form.integration.telegram_bot_disable_buttons": "Wyłącz przyciski",
"form.integration.telegram_bot_disable_notification": "Wyłącz powiadomienie",
@@ -327,6 +333,7 @@
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
"form.prefs.label.always_open_external_links": "Czytaj artykuły, otwierając łącza zewnętrzne",
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
"form.prefs.label.cjk_reading_speed": "Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)",
"form.prefs.label.custom_css": "Niestandardowy CSS",
@@ -347,6 +354,7 @@
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
"form.prefs.label.open_external_links_in_new_tab": "Otwieraj łącza zewnętrzne w nowej karcie (dodaje target=\"_blank\" do łączy)",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.timezone": "Strefa czasowa",
@@ -407,6 +415,7 @@
"page.about.build_date": "Data opracowania:",
"page.about.credits": "Prawa autorskie",
"page.about.db_usage": "Rozmiar bazy danych:",
"page.about.git_commit": "Zatwierdzenie Git:",
"page.about.global_config_options": "Globalne opcje konfiguracji",
"page.about.go_version": "Wersja Go:",
"page.about.license": "Licencja:",
@@ -426,11 +435,6 @@
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Klucze API",
"page.categories_count": [
"%d kategoria",
"%d kategorie",
"%d kategorii"
],
"page.categories.entries": "Wpisy",
"page.categories.feed_count": [
"Jest %d kanał.",
@@ -440,6 +444,11 @@
"page.categories.feeds": "Kanały",
"page.categories.no_feed": "Brak kanałów.",
"page.categories.title": "Kategorie",
"page.categories_count": [
"%d kategoria",
"%d kategorie",
"%d kategorii"
],
"page.category_label": "Kategoria: %s",
"page.edit_category.title": "Edytuj kategorię: %s",
"page.edit_feed.etag_header": "Nagłówek ETag:",
@@ -548,29 +557,29 @@
"page.settings.webauthn.passkeys": "Klucze dostępu",
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
"page.shared_entries.title": "Udostępnione wpisy",
"page.shared_entries_count": [
"%d udostępniony wpis",
"%d udostępnione wpisy",
"%d udostępnionych wpisów"
],
"page.shared_entries.title": "Udostępnione wpisy",
"page.starred.title": "Ulubione",
"page.starred_entry_count": [
"%d ulubiony wpis",
"%d ulubione wpisy",
"%d ulubionych wpisów"
],
"page.starred.title": "Ulubione",
"page.total_entry_count": [
"%d wpis łącznie",
"%d wpisy łącznie",
"%d wpisów łącznie"
],
"page.unread.title": "Nieprzeczytane",
"page.unread_entry_count": [
"%d nieprzeczytany wpis",
"%d nieprzeczytane wpisy",
"%d nieprzeczytanych wpisów"
],
"page.unread.title": "Nieprzeczytane",
"page.users.actions": "Działania",
"page.users.admin.no": "Nie",
"page.users.admin.yes": "Tak",
+161 -153
View File
@@ -13,7 +13,7 @@
"action.update": "Atualizar",
"alert.account_linked": "Sua conta externa está vinculada!",
"alert.account_unlinked": "Sua conta externa está desvinculada!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Todas as fontes estão sendo atualizadas em segundo plano. Você pode continuar usando o Miniflux enquanto este processo está em execução.",
"alert.feed_error": "Ocorreu um problema com esta fonte.",
"alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
@@ -27,26 +27,25 @@
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
"alert.no_unread_entry": "Não há itens não lidos.",
"alert.no_user": "Você é o único usuário.",
"alert.pocket_linked": "Sua conta do Pocket está vinculada!",
"alert.prefs_saved": "Suas preferências foram salvas!",
"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."
"Você acionou muitas atualizações de fontes. Por favor, aguarde %d minuto antes de tentar novamente.",
"Você acionou muitas atualizações de fontes. Por favor, aguarde %d minutos antes de tentar novamente."
],
"confirm.loading": "Carregando...",
"confirm.no": "Não",
"confirm.question": "Tem certeza?",
"confirm.question.refresh": "Você deseja forçar a atualização?",
"confirm.yes": "Sim",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "Procurar:",
"enclosure_media_controls.seek.title": "Procurar %s segundos",
"enclosure_media_controls.speed": "Velocidade:",
"enclosure_media_controls.speed.faster": "Mais Rápido",
"enclosure_media_controls.speed.faster.title": "Mais rápido em %sx",
"enclosure_media_controls.speed.reset": "Resetar",
"enclosure_media_controls.speed.reset.title": "Resetar velocidade para 1x",
"enclosure_media_controls.speed.slower": "Mais Lento",
"enclosure_media_controls.speed.slower.title": "Mais lento em %sx",
"entry.bookmark.toast.off": "Desfavoritado",
"entry.bookmark.toast.on": "Favoritado",
"entry.bookmark.toggle.off": "Remover dos Favoritos",
@@ -77,75 +76,79 @@
"entry.status.toast.read": "Marcado como lido",
"entry.status.toast.unread": "Marcado como não lido",
"entry.tags.label": "Etiquetas:",
"entry.tags.more_tags_label": [
"Mostrar mais %d etiqueta",
"Mostrar mais %d etiquetas"
],
"entry.unshare.label": "Descompartilhar",
"error.api_key_already_exists": "Essa chave de API já existe.",
"error.bad_credentials": "Usuário ou senha são inválidos.",
"error.category_already_exists": "Esta categoria já existe.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Esta categoria não existe ou não pertence a este usuário.",
"error.database_error": "Erro no banco de dados: %v.",
"error.different_passwords": "As senhas não são iguais.",
"error.duplicate_fever_username": "Alguém já está utilizando esse nome de usuário do Fever!",
"error.duplicate_googlereader_username": "Alguém já está utilizando esse nome de usuário do Google Reader!",
"error.duplicate_linked_account": "Alguém já está vinculado a esse serviço!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "Esta fonte já existe.",
"error.empty_file": "Esse arquivo está vazio.",
"error.entries_per_page_invalid": "O número de itens por página é inválido.",
"error.feed_already_exists": "Este feed já existe.",
"error.feed_category_not_found": "Esta categoria não existe ou não pertence a este usuário.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "Não foi possível detectar o formato da fonte: %v.",
"error.feed_invalid_blocklist_rule": "A regra da lista de bloqueio é inválida.",
"error.feed_invalid_keeplist_rule": "A regra de manutenção da lista é inválida.",
"error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Esta fonte não existe ou não pertence a este usuário.",
"error.feed_title_not_empty": "O título do feed não pode estar vazio.",
"error.feed_url_not_empty": "O URL do feed não pode estar vazio.",
"error.fields_mandatory": "Todos os campos são obrigatórios.",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"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_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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.http_bad_gateway": "O site não está disponível no momento devido a um erro de gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_body_read": "Não foi possível ler o corpo HTTP: %v.",
"error.http_client_error": "Erro do cliente HTTP: %v.",
"error.http_empty_response": "A resposta HTTP está vazia. Talvez este site esteja usando um mecanismo de proteção contra bots?",
"error.http_empty_response_body": "O corpo da resposta HTTP está vazio.",
"error.http_forbidden": "O acesso a este site está proibido. Talvez este site tenha um mecanismo de proteção contra bots?",
"error.http_gateway_timeout": "O site não está disponível no momento devido a um erro de tempo limite do gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_internal_server_error": "O site não está disponível no momento devido a um erro interno do servidor. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_not_authorized": "O acesso a este site não está autorizado. Pode ser um nome de usuário ou senha incorretos.",
"error.http_resource_not_found": "O recurso solicitado não foi encontrado. Por favor, verifique a URL.",
"error.http_response_too_large": "A resposta HTTP é muito grande. Você pode aumentar o limite de tamanho da resposta HTTP nas configurações globais (requer reinício do servidor).",
"error.http_service_unavailable": "O site não está disponível no momento devido a um erro interno do servidor. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.http_too_many_requests": "O Miniflux gerou muitas solicitações para este site. Por favor, tente novamente mais tarde ou altere a configuração do aplicativo.",
"error.http_unexpected_status_code": "O site não está disponível no momento devido a um código de status HTTP inesperado: %d. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
"error.invalid_categories_sorting_order": "A ordem de classificação das categorias não é válida.",
"error.invalid_default_home_page": "Página inicial por defeito inválida!",
"error.invalid_display_mode": "Modo de exibição de aplicativo inválido da web.",
"error.invalid_entry_direction": "Direção de entrada inválida.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "A ordem de entrada é inválida.",
"error.invalid_feed_proxy_url": "URL de proxy inválido.",
"error.invalid_feed_url": "URL de feed inválido.",
"error.invalid_gesture_nav": "Navegação por gestos inválida.",
"error.invalid_language": "Idioma inválido.",
"error.invalid_site_url": "URL de site inválido.",
"error.invalid_theme": "Tema inválido.",
"error.invalid_timezone": "Fuso horário inválido.",
"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.network_operation": "O Miniflux não conseguiu acessar este site devido a um erro de rede: %v.",
"error.network_timeout": "Este site está muito lento e a solicitação expirou: %v",
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.",
"error.pocket_access_token": "Não foi possível obter um token de acesso no Pocket!",
"error.pocket_request_token": "Não foi possível obter um pedido de token no Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "A URL do proxy não pode estar vazia.",
"error.settings_block_rule_fieldname_invalid": "Regra de bloqueio inválida: a regra #%d está sem um nome de campo válido (Opções: %s)",
"error.settings_block_rule_invalid_regex": "Regra de bloqueio inválida: o padrão da regra #%d não é uma expressão regular válida",
"error.settings_block_rule_regex_required": "Regra de bloqueio inválida: o padrão da regra #%d não foi fornecido",
"error.settings_block_rule_separator_required": "Regra de bloqueio inválida: o padrão da regra #%d deve ser separado por um '='",
"error.settings_invalid_domain_list": "Lista de domínios inválida. Por favor, forneça uma lista de domínios separados por espaço.",
"error.settings_keep_rule_fieldname_invalid": "Regra de permissão inválida: a regra #%d está sem um nome de campo válido (Opções: %s)",
"error.settings_keep_rule_invalid_regex": "Regra de permissão inválida: o padrão da regra #%d não é uma expressão regular válida",
"error.settings_keep_rule_regex_required": "Regra de permissão inválida: o padrão da regra #%d não foi fornecido",
"error.settings_keep_rule_separator_required": "Regra de permissão inválida: o padrão da regra #%d deve ser separado por um '='",
"error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.",
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
"error.settings_reading_speed_is_positive": "As velocidades de leitura devem ser inteiros positivos.",
"error.site_url_not_empty": "O URL do site não pode estar vazio.",
"error.subscription_not_found": "Não foi possível encontrar uma inscrição.",
"error.title_required": "O título é obrigatório.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "Erro TLS: %q. Você pode desabilitar a verificação TLS nas configurações do feed se desejar.",
"error.unable_to_create_api_key": "Não foi possível criar uma chave de API.",
"error.unable_to_create_category": "Não foi possível criar essa categoria.",
"error.unable_to_create_user": "Não foi possível criar esse usuário.",
@@ -160,18 +163,19 @@
"form.api_key.label.description": "Etiqueta da chave de API",
"form.category.hide_globally": "Ocultar entradas na lista global não lida",
"form.category.label.title": "Título",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.general": "Geral",
"form.feed.fieldset.integration": "Serviços de Terceiros",
"form.feed.fieldset.network_settings": "Configurações de Rede",
"form.feed.fieldset.rules": "Regras",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Regras de bloqueio",
"form.feed.label.apprise_service_urls": "Lista de URLs de serviços Apprise separadas por vírgula",
"form.feed.label.block_filter_entry_rules": "Regras de Bloqueio de Entradas",
"form.feed.label.blocklist_rules": "Filtros de Bloqueio Baseados em Regex",
"form.feed.label.category": "Categoria",
"form.feed.label.cookie": "Definir Cookies",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.description": "Descrição",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "Desativar HTTP/2 para evitar fingerprinting",
"form.feed.label.disabled": "Não atualizar esta fonte",
"form.feed.label.feed_password": "Senha da fonte",
"form.feed.label.feed_url": "URL da fonte",
@@ -179,35 +183,36 @@
"form.feed.label.fetch_via_proxy": "Usar o proxy configurado no nível da aplicação",
"form.feed.label.hide_globally": "Ocultar entradas na lista global não lida",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.keeplist_rules": "Regras de permissão",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.keep_filter_entry_rules": "Regras de Permissão de Entradas",
"form.feed.label.keeplist_rules": "Filtros de Manutenção Baseados em Regex",
"form.feed.label.no_media_player": "Sem reprodutor de mídia (áudio/vídeo)",
"form.feed.label.ntfy_activate": "Enviar itens para o ntfy",
"form.feed.label.ntfy_default_priority": "Prioridade padrão do ntfy",
"form.feed.label.ntfy_high_priority": "Alta prioridade do ntfy",
"form.feed.label.ntfy_low_priority": "Baixa prioridade do ntfy",
"form.feed.label.ntfy_max_priority": "Prioridade máxima do ntfy",
"form.feed.label.ntfy_min_priority": "Prioridade mínima do ntfy",
"form.feed.label.ntfy_priority": "Prioridade do ntfy",
"form.feed.label.ntfy_topic": "Tópico do ntfy (opcional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Regras para o Rewrite",
"form.feed.label.pushover_activate": "Enviar itens para o pushover.net",
"form.feed.label.pushover_default_priority": "Prioridade padrão do Pushover",
"form.feed.label.pushover_high_priority": "Alta prioridade do Pushover",
"form.feed.label.pushover_low_priority": "Baixa prioridade do Pushover",
"form.feed.label.pushover_max_priority": "Prioridade máxima do Pushover",
"form.feed.label.pushover_min_priority": "Prioridade mínima do Pushover",
"form.feed.label.pushover_priority": "Prioridade da mensagem do Pushover",
"form.feed.label.rewrite_rules": "Regras de Reescrita de Conteúdo",
"form.feed.label.scraper_rules": "Regras do scraper",
"form.feed.label.site_url": "URL do site",
"form.feed.label.title": "Título",
"form.feed.label.urlrewrite_rules": "Regras de reescrita de URL",
"form.feed.label.user_agent": "Sobrescrever o agente de usuário (user-agent) padrão",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Sobrescrever URL do webhook",
"form.import.label.file": "Arquivo OPML",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_activate": "Enviar itens para o Apprise",
"form.integration.apprise_services_url": "Lista de URLs de serviços Apprise separadas por vírgula",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
@@ -231,12 +236,15 @@
"form.integration.instapaper_activate": "Salvar itens no Instapaper",
"form.integration.instapaper_password": "Senha do Instapaper",
"form.integration.instapaper_username": "Nome do usuário do Instapaper",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.karakeep_activate": "Salvar itens no Karakeep",
"form.integration.karakeep_api_key": "Chave de API do Karakeep",
"form.integration.karakeep_url": "Endpoint de API do Karakeep",
"form.integration.linkace_activate": "Salvar itens no LinkAce",
"form.integration.linkace_api_key": "Chave de API do LinkAce",
"form.integration.linkace_check_disabled": "Desativar verificação de link",
"form.integration.linkace_endpoint": "Endpoint de API do LinkAce",
"form.integration.linkace_is_private": "Marcar link como privado",
"form.integration.linkace_tags": "Etiquetas do LinkAce",
"form.integration.linkding_activate": "Salvar itens no Linkding",
"form.integration.linkding_api_key": "Chave de API do Linkding",
"form.integration.linkding_bookmark": "Salvar marcador como não lido",
@@ -244,20 +252,20 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Salvar itens no Linkwarden",
"form.integration.linkwarden_api_key": "Chave de API do Linkwarden",
"form.integration.linkwarden_endpoint": "Endpoint de API do Linkwarden",
"form.integration.linkwarden_endpoint": "URL base do Linkwarden",
"form.integration.matrix_bot_activate": "Transferir novos artigos para o Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala 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_user": "Nome de utilizador para Matrix",
"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.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.notion_activate": "Salvar itens no Notion",
"form.integration.notion_page_id": "ID da página do Notion",
"form.integration.notion_token": "Token secreto do Notion",
"form.integration.ntfy_activate": "Enviar itens para o ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (opcional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (opcional)",
"form.integration.ntfy_internal_links": "Usar links internos ao clicar (opcional)",
"form.integration.ntfy_password": "Ntfy Password (opcional)",
"form.integration.ntfy_topic": "Ntfy topic (default if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
@@ -271,32 +279,29 @@
"form.integration.pinboard_bookmark": "Salvar marcador como não lido",
"form.integration.pinboard_tags": "Etiquetas (tags) do Pinboard",
"form.integration.pinboard_token": "Token de API do Pinboard",
"form.integration.pocket_access_token": "Token de acesso do Pocket",
"form.integration.pocket_activate": "Salvar itens no Pocket",
"form.integration.pocket_connect_link": "Conectar a conta do Pocket",
"form.integration.pocket_consumer_key": "Chave de consumo (Consumer Key) do Pocket",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.pushover_activate": "Enviar itens para o Pushover",
"form.integration.pushover_device": "Dispositivo Pushover (opcional)",
"form.integration.pushover_prefix": "Prefixo da URL do Pushover (opcional)",
"form.integration.pushover_token": "Token de API do aplicativo Pushover",
"form.integration.pushover_user": "Chave do usuário Pushover",
"form.integration.raindrop_activate": "Salvar itens no Raindrop",
"form.integration.raindrop_collection_id": "ID da coleção",
"form.integration.raindrop_tags": "Etiquetas (separadas por vírgula)",
"form.integration.raindrop_token": "Token (teste)",
"form.integration.readeck_activate": "Salvar itens no Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck",
"form.integration.readeck_endpoint": "Endpoint 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.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.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.readwise_activate": "Salvar itens no Readwise Reader",
"form.integration.readwise_api_key": "Token de acesso do Readwise Reader",
"form.integration.readwise_api_key_link": "Obtenha seu token de acesso do Readwise",
"form.integration.rssbridge_activate": "Verificar RSS-Bridge ao adicionar inscrições",
"form.integration.rssbridge_token": "Token de autenticação do RSS-Bridge",
"form.integration.rssbridge_url": "URL do servidor RSS-Bridge",
"form.integration.shaarli_activate": "Salvar artigos no Shaarli",
"form.integration.shaarli_api_secret": "Segredo da API do Shaarli",
"form.integration.shaarli_endpoint": "URL do Shaarli",
"form.integration.shiori_activate": "Salvar itens no Shiori",
"form.integration.shiori_endpoint": "Endpoint da API do Shiori",
"form.integration.shiori_password": "Senha do Shiori",
@@ -320,11 +325,12 @@
"form.integration.webhook_activate": "Enable Webhooks",
"form.integration.webhook_secret": "Webhooks Secret",
"form.integration.webhook_url": "Default Webhook URL",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.fieldset.application_settings": "Configurações do aplicativo",
"form.prefs.fieldset.authentication_settings": "Configurações de autenticação",
"form.prefs.fieldset.global_feed_settings": "Configurações globais de fontes",
"form.prefs.fieldset.reader_settings": "Configurações do leitor",
"form.prefs.help.external_font_hosts": "Lista separada por espaço de hosts de fontes externas permitidos. Por exemplo: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Ler artigos abrindo links externos",
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
"form.prefs.label.cjk_reading_speed": "Velocidade de leitura para chinês, coreano e japonês (caracteres por minuto)",
"form.prefs.label.custom_css": "CSS customizado",
@@ -336,15 +342,16 @@
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
"form.prefs.label.entry_sorting": "Ordenação dos itens",
"form.prefs.label.entry_swipe": "Ativar entrada de furto em telas sensíveis ao toque",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Hosts de fontes externas",
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
"form.prefs.label.keyboard_shortcuts": "Habilitar atalhos do teclado",
"form.prefs.label.language": "Idioma",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Marcar itens como lidos manualmente",
"form.prefs.label.mark_read_on_media_completion": "Marcar como lido apenas quando a reprodução de áudio/vídeo atingir 90%% de conclusão",
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas como lidas quando visualizadas",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar itens como lidos quando visualizados. Para áudio/vídeo, marcar como lido em 90%% de conclusão",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
"form.prefs.label.open_external_links_in_new_tab": "Abrir links externos em uma nova aba (adiciona target=\"_blank\" aos links)",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
"form.prefs.label.theme": "Tema",
"form.prefs.label.timezone": "Fuso horário",
@@ -405,6 +412,7 @@
"page.about.build_date": "Compilado em:",
"page.about.credits": "Créditos",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "opções de configuração global",
"page.about.go_version": "Go versão:",
"page.about.license": "Licença:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Ultima utilização",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Chaves de API",
"page.categories_count": [
"%d category",
"%d categories"
],
"page.categories.entries": "Itens",
"page.categories.feed_count": [
"Existe %d fonte.",
@@ -436,7 +440,11 @@
"page.categories.feeds": "Inscrições",
"page.categories.no_feed": "Sem fonte.",
"page.categories.title": "Categorias",
"page.category_label": "Category: %s",
"page.categories_count": [
"%d categoria",
"%d categorias"
],
"page.category_label": "Categoria: %s",
"page.edit_category.title": "Editar categoria: %s",
"page.edit_feed.etag_header": "Cabeçalho 'ETag':",
"page.edit_feed.last_check": "Última verificação:",
@@ -451,7 +459,7 @@
"%d erros"
],
"page.feeds.last_check": "Última verificação:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Próxima verificação:",
"page.feeds.read_counter": "Número de itens lidos",
"page.feeds.title": "Fontes",
"page.history.title": "Histórico",
@@ -499,7 +507,7 @@
"page.keyboard_shortcuts.subtitle.sections": "Navegação de seções",
"page.keyboard_shortcuts.title": "Atalhos de teclado",
"page.keyboard_shortcuts.toggle_bookmark_status": "Marcar ou desmarcar como favorito",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "Alternar abrir/fechar anexos do item",
"page.keyboard_shortcuts.toggle_read_status_next": "Inverter estado de leitura do item, focar próximo item",
"page.keyboard_shortcuts.toggle_read_status_prev": "Inverter estado de leitura do item, focar item anterior",
"page.login.google_signin": "Iniciar Sessão com sua conta do Google",
@@ -515,8 +523,8 @@
"page.offline.refresh_page": "Tente atualizar a página",
"page.offline.title": "Modo offline",
"page.read_entry_count": [
"%d read entry",
"%d read entries"
"%d item lido",
"%d itens lidos"
],
"page.search.title": "Resultados da busca",
"page.sessions.table.actions": "Ações",
@@ -530,36 +538,36 @@
"page.settings.title": "Ajustes",
"page.settings.unlink_google_account": "Desvincular minha conta do Google",
"page.settings.unlink_oidc_account": "Desvincular minha conta do %s",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.actions": "Ações",
"page.settings.webauthn.added_on": "Adicionado em",
"page.settings.webauthn.delete": [
"Remover %d senha",
"Remover %d senhas"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.last_seen_on": "Último uso",
"page.settings.webauthn.passkey_name": "Nome da senha",
"page.settings.webauthn.passkeys": "Senhas",
"page.settings.webauthn.register": "Registrar senha",
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries"
],
"page.shared_entries.title": "Itens compartilhados",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries"
"page.shared_entries_count": [
"%d item compartilhado",
"%d itens compartilhados"
],
"page.starred.title": "Favoritos",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total"
"page.starred_entry_count": [
"%d item favorito",
"%d itens favoritos"
],
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries"
"page.total_entry_count": [
"%d item no total",
"%d itens no total"
],
"page.unread.title": "Não lidos",
"page.unread_entry_count": [
"%d item não lido",
"%d itens não lidos"
],
"page.users.actions": "Ações",
"page.users.admin.no": "Não",
"page.users.admin.yes": "Sim",
@@ -568,15 +576,15 @@
"page.users.never_logged": "Nunca",
"page.users.title": "Usuários",
"page.users.username": "Nome de usuário",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"page.webauthn_rename.title": "Renomear senha",
"pagination.first": "Primeira",
"pagination.last": "Última",
"pagination.next": "Próximo",
"pagination.previous": "Anterior",
"search.label": "Buscar",
"search.placeholder": "Buscar por...",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "Buscar",
"skip_to_content": "Pular para o conteúdo",
"time_elapsed.days": [
"há %d dia",
"há %d dias"
+33 -24
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Nu sunt înregistrări pentru această etichetă.",
"alert.no_unread_entry": "Nu sunt intrări necitite.",
"alert.no_user": "Sunteți singurul utilizator.",
"alert.pocket_linked": "Contul dvs. Pocket este atașat!",
"alert.prefs_saved": "Preferințe salvate!",
"alert.too_many_feeds_refresh": [
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minut înainte de a reîncerca.",
@@ -79,6 +78,11 @@
"entry.status.toast.read": "Marcat ca citit",
"entry.status.toast.unread": "Marcat ca necitit",
"entry.tags.label": "Etichete:",
"entry.tags.more_tags_label": [
"Afișează încă o etichetă",
"Afișează încă %d etichete",
"Afișează încă %d de etichete"
],
"entry.unshare.label": "Elimină partajarea",
"error.api_key_already_exists": "Această cheie API există deja.",
"error.bad_credentials": "Utilizator sau parolă invalide.",
@@ -116,10 +120,12 @@
"error.http_service_unavailable": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.http_too_many_requests": "Miniflux a generat prea multe solicitări pe acest site web. Vă rog, încercați mai tîrziu sau modificați configurațiile aplicației.",
"error.http_unexpected_status_code": "Acest site web nu este disponibil momentan din cauza unei erori HTTP: %d. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
"error.invalid_categories_sorting_order": "Ordinea de sortare a categoriilor nu este validă.",
"error.invalid_default_home_page": "Pagină de start invalidă!",
"error.invalid_display_mode": "Mod invalid de afișare în aplicația web.",
"error.invalid_entry_direction": "Direcție invalidă ăn intrare.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Direcție de sortare invalidă.",
"error.invalid_feed_proxy_url": "URL proxy invalid.",
"error.invalid_feed_url": "Adresa URL a fluxului este invalidă.",
"error.invalid_gesture_nav": "Gest de navigare invalid.",
"error.invalid_language": "Limbă invalidă.",
@@ -129,14 +135,12 @@
"error.network_operation": "Miniflux nu poate ajunge la acest site din cauza unei erori de rețea: %v.",
"error.network_timeout": "Acest site web este prea lent și conexiunea nu s-a realizat: %v",
"error.password_min_length": "Parola trebuie să aibă cel puțin 6 caractere.",
"error.pocket_access_token": "Nu poate obține token-ul de acces de la Pocket!",
"error.pocket_request_token": "Nu poate obține token-ul solicitat de la Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "URL-ul proxy nu poate fi gol.",
"error.settings_block_rule_fieldname_invalid": "Regulă de bloc invalidă: regulii #%d îi lipsește un nume valid de câmp (Opțiuni: %s)",
"error.settings_block_rule_invalid_regex": "Regulă de bloc invalidă: modelul regulii #%d's nu este regex valid",
"error.settings_block_rule_regex_required": "Regulă de bloc invalidă: modelul regulii #%d's nu este furnizat",
"error.settings_block_rule_separator_required": "Regulă de bloc invalidă: modelul regulii #%d's trebuie separat de '='",
"error.settings_invalid_domain_list": "Lista domeniilor invalidă. Vă rugăm să ne furnizați o listă separată de virgulă a domeniilor.",
"error.settings_invalid_domain_list": "Lista domeniilor este invalidă. Vă rugăm să furnizați o listă de domenii separate prin spațiu.",
"error.settings_keep_rule_fieldname_invalid": "Regulă Keep invalidă: regulii #%d îi lipsește un nume valid (Opțiuni: %s)",
"error.settings_keep_rule_invalid_regex": "Regulă Keep invalidă: modelul regulii #%d's nu este regex valid",
"error.settings_keep_rule_regex_required": "Regulă Keep invalidă: modelul regulii #%d nu este furnizat",
@@ -168,7 +172,8 @@
"form.feed.fieldset.rules": "Reguli",
"form.feed.label.allow_self_signed_certificates": "Permite certificatele auto-semnate sau invalide",
"form.feed.label.apprise_service_urls": "Lista de URL-uri ale serviciilor Apprise separate prin virgule",
"form.feed.label.blocklist_rules": "Reguli de Blocare",
"form.feed.label.block_filter_entry_rules": "Reguli de Blocare a Intrărilor",
"form.feed.label.blocklist_rules": "Filtre de Blocare Bazate pe Regex",
"form.feed.label.category": "Categorie",
"form.feed.label.cookie": "Setare Cookie-uri",
"form.feed.label.crawler": "Aduce conținutul original",
@@ -181,7 +186,8 @@
"form.feed.label.fetch_via_proxy": "Utilizați proxy-ul configurat la nivelul aplicației",
"form.feed.label.hide_globally": "Ascunde intrările în lista globală de articole necitite",
"form.feed.label.ignore_http_cache": "Ignoră cache HTTP",
"form.feed.label.keeplist_rules": "Reguli de Păstrare",
"form.feed.label.keep_filter_entry_rules": "Reguli de Permitere a Intrărilor",
"form.feed.label.keeplist_rules": "Filtre de Păstrare Bazate pe Regex",
"form.feed.label.no_media_player": "Nu există player media (audio/video)",
"form.feed.label.ntfy_activate": "Împinge intrările la ntfy",
"form.feed.label.ntfy_default_priority": "Prioritate predefinită Ntfy",
@@ -191,7 +197,7 @@
"form.feed.label.ntfy_min_priority": "Prioritate minimă Ntfy",
"form.feed.label.ntfy_priority": "Prioritate Ntfy",
"form.feed.label.ntfy_topic": "Subiect Ntfy (opțional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.proxy_url": "URL Proxy",
"form.feed.label.pushover_activate": "Activează Pushover",
"form.feed.label.pushover_default_priority": "Prioritate implicită Pushover",
"form.feed.label.pushover_high_priority": "Prioritate ridicată Pushover",
@@ -199,7 +205,7 @@
"form.feed.label.pushover_max_priority": "Prioritate maximă Pushover",
"form.feed.label.pushover_min_priority": "Prioritate minimă Pushover",
"form.feed.label.pushover_priority": "Prioritate Pushover",
"form.feed.label.rewrite_rules": "Rescrie Regulile",
"form.feed.label.rewrite_rules": "Reguli de Rescriere a Conținutului",
"form.feed.label.scraper_rules": "Reguli de Eliminare",
"form.feed.label.site_url": "Adresă URL",
"form.feed.label.title": "Titlu",
@@ -218,7 +224,7 @@
"form.integration.cubox_api_link": "Link APi Cubox",
"form.integration.discord_activate": "Împinge intrările pe Discord",
"form.integration.discord_webhook_link": "Link Webhook Discord",
"form.integration.espial_activate": "Save entries to Espial",
"form.integration.espial_activate": "Salvează intrările în Espial",
"form.integration.espial_api_key": "Cheie API Espial",
"form.integration.espial_endpoint": "Punct acces API Espial",
"form.integration.espial_tags": "Etichete Espial",
@@ -233,6 +239,9 @@
"form.integration.instapaper_activate": "Salvează înregistrările pe Instapaper",
"form.integration.instapaper_password": "Parolă Instapaper",
"form.integration.instapaper_username": "Utilizator Instapaper",
"form.integration.karakeep_activate": "Salvare înregistrări în Karakeep",
"form.integration.karakeep_api_key": "Cheie API Karakeep",
"form.integration.karakeep_url": "Punct acces API Karakeep",
"form.integration.linkace_activate": "Salvează intrările în LinkAce",
"form.integration.linkace_api_key": "Cheie API LinkAce",
"form.integration.linkace_check_disabled": "Dezactivează verificarea link-urilor",
@@ -246,7 +255,7 @@
"form.integration.linkding_tags": "TAG-uri Linkding",
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
"form.integration.linkwarden_endpoint": "Endpoint API Linkwarden",
"form.integration.linkwarden_endpoint": "URL-ul de bază Linkwarden",
"form.integration.matrix_bot_activate": "Împinge intrările noi pe Matrix",
"form.integration.matrix_bot_chat_id": "ID-ul Camerei Matrix",
"form.integration.matrix_bot_password": "Parola utilizatorului Matrix",
@@ -273,10 +282,6 @@
"form.integration.pinboard_bookmark": "Marchează bookmark ca necitit",
"form.integration.pinboard_tags": "Etichete Pinboard",
"form.integration.pinboard_token": "Token API Pinboard",
"form.integration.pocket_access_token": "Token Acces Pocket",
"form.integration.pocket_activate": "Salvează înregistrările în Pocket",
"form.integration.pocket_connect_link": "Conectează contul Pocket personal",
"form.integration.pocket_consumer_key": "Cheie Consumator Pocket",
"form.integration.pushover_activate": "Activează Pushover",
"form.integration.pushover_device": "Dispozitiv Pushover (opțional)",
"form.integration.pushover_prefix": "Prefix Pushover (opțional)",
@@ -295,6 +300,7 @@
"form.integration.readwise_api_key": "Token Acces Readwise Reader",
"form.integration.readwise_api_key_link": "Obțineți Token-ul de Acess pe Readwise",
"form.integration.rssbridge_activate": "Verifică RSS-Bridge la adăugarea de abonamente",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "URL server RSS-Bridge",
"form.integration.shaarli_activate": "Salvează articolele în Shaarli",
"form.integration.shaarli_api_secret": "Secret API Shaarli",
@@ -327,6 +333,7 @@
"form.prefs.fieldset.global_feed_settings": "Setări Globale pt. Flux",
"form.prefs.fieldset.reader_settings": "Setări Citire",
"form.prefs.help.external_font_hosts": "Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Citește articolele deschizând linkurile externe",
"form.prefs.label.categories_sorting_order": "Sortare categorii",
"form.prefs.label.cjk_reading_speed": "Viteză de citire pentru Chineză, Coreană și Japoneză (caractere pe minut)",
"form.prefs.label.custom_css": "CSS personalizat",
@@ -347,6 +354,7 @@
"form.prefs.label.mark_read_on_view": "Marchează intrările ca citite la vizualizare",
"form.prefs.label.mark_read_on_view_or_media_completion": "Marchează intrările ca citite la vizualizare. Pentru audio/video, marchează ca citit la redarea a 90%% de conținut",
"form.prefs.label.media_playback_rate": "Viteza de rulare audio/video",
"form.prefs.label.open_external_links_in_new_tab": "Deschide linkurile externe într-o filă nouă (adaugă target=\"_blank\" la linkuri)",
"form.prefs.label.show_reading_time": "Afișare timp estimat de citire pentru înregistrări",
"form.prefs.label.theme": "Temă",
"form.prefs.label.timezone": "Fus orar",
@@ -407,6 +415,7 @@
"page.about.build_date": "Dată Build:",
"page.about.credits": "Credit",
"page.about.db_usage": "Utilizare Bază de Date",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Opțiuni globale de configurare",
"page.about.go_version": "Versiune Go:",
"page.about.license": "Licență:",
@@ -426,11 +435,6 @@
"page.api_keys.table.last_used_at": "Utilizat ultima dată",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "Chei API",
"page.categories_count": [
"%d categorie",
"%d categorii",
"%d categorie găsită"
],
"page.categories.entries": "Intrări",
"page.categories.feed_count": [
"Este %d flux.",
@@ -440,6 +444,11 @@
"page.categories.feeds": "Fluxuri",
"page.categories.no_feed": "Nici un flux.",
"page.categories.title": "Categorii",
"page.categories_count": [
"%d categorie",
"%d categorii",
"%d categorie găsită"
],
"page.category_label": "Categorie: %s",
"page.edit_category.title": "Editare Categorie: %s",
"page.edit_feed.etag_header": "Antet ETag:",
@@ -548,29 +557,29 @@
"page.settings.webauthn.passkeys": "Chei Acces",
"page.settings.webauthn.register": "Înregistrare cheie acces",
"page.settings.webauthn.register.error": "Eroare la înregistrarea cheii de acces",
"page.shared_entries.title": "Înregistrări partajate",
"page.shared_entries_count": [
"%d înregistrare partajată",
"%d înregistrări partajate",
"%d înregistrări partajate"
],
"page.shared_entries.title": "Înregistrări partajate",
"page.starred.title": "Marcate",
"page.starred_entry_count": [
"%d înregistrare marcată",
"%d Înregistrări marcate",
"%d Înregistrări marcate"
],
"page.starred.title": "Marcate",
"page.total_entry_count": [
"%d intrare în total",
"%d intrări în total",
"%d intrări în total"
],
"page.unread.title": "Necitite",
"page.unread_entry_count": [
"%d înregistrare necitită",
"%d înregistrări necitite",
"%d înregistrări necitite"
],
"page.unread.title": "Necitite",
"page.users.actions": "Acțiuni",
"page.users.admin.no": "Nu",
"page.users.admin.yes": "Da",
+181 -172
View File
@@ -13,7 +13,7 @@
"action.update": "Обновить",
"alert.account_linked": "Ваш внешний аккаунт теперь привязан!",
"alert.account_unlinked": "Ваш внешний аккаунт теперь отвязан!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Все подписки обновляются в фоновом режиме. Вы можете продолжать использовать Miniflux пока идёт этот процесс.",
"alert.feed_error": "С этой подпиской есть проблема",
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
@@ -27,27 +27,26 @@
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_unread_entry": "Нет непрочитанных статей.",
"alert.no_user": "Вы единственный пользователь.",
"alert.pocket_linked": "Ваш Pocket аккаунт теперь привязан!",
"alert.prefs_saved": "Предпочтения сохранены!",
"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."
"Вы запустили слишком много обновлений подписок. Подождите %d минуту для нового запуска",
"Вы запустили слишком много обновлений подписок. Подождите %d минут для нового запуска",
"Вы запустили слишком много обновлений подписок. Подождите %d минут для нового запуска"
],
"confirm.loading": "В процессе…",
"confirm.no": "нет",
"confirm.question": "Вы уверены?",
"confirm.question.refresh": "Вы хотите выполнить принудительное обновление?",
"confirm.yes": "да",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "Перемотка:",
"enclosure_media_controls.seek.title": "Перемотать на %s секунд",
"enclosure_media_controls.speed": "Скорость:",
"enclosure_media_controls.speed.faster": "Быстрее",
"enclosure_media_controls.speed.faster.title": "Ускорить в %s раз",
"enclosure_media_controls.speed.reset": "Сбросить",
"enclosure_media_controls.speed.reset.title": "Сбросить скорость до 1x",
"enclosure_media_controls.speed.slower": "Медленнее",
"enclosure_media_controls.speed.slower.title": "Замедлить в %s раз",
"entry.bookmark.toast.off": "Без пометок",
"entry.bookmark.toast.on": "Помеченные",
"entry.bookmark.toggle.off": "Удалить из Избранного",
@@ -79,80 +78,85 @@
"entry.status.toast.read": "Помечено как прочитанное",
"entry.status.toast.unread": "Помечено как непрочитанное",
"entry.tags.label": "Теги:",
"entry.tags.more_tags_label": [
"Ещё %d тег",
"Ещё %d тега",
"Ещё %d тегов"
],
"entry.unshare.label": "Удалить из общедоступных",
"error.api_key_already_exists": "Этот API-ключ уже существует.",
"error.bad_credentials": "Неверное имя пользователя или пароль.",
"error.category_already_exists": "Эта категория уже существует.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Эта категория не существует или не принадлежит этому пользователю.",
"error.database_error": "Ошибка базы данных: %v.",
"error.different_passwords": "Пароли не совпадают.",
"error.duplicate_fever_username": "Уже есть кто-то с таким же именем пользователя Fever!",
"error.duplicate_googlereader_username": "Уже есть кто-то с таким же именем пользователя Google Reader!",
"error.duplicate_linked_account": "Уже есть кто-то, кто ассоциирован с этим аккаунтом!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "Эта подписка уже существует.",
"error.empty_file": "Этот файл пуст.",
"error.entries_per_page_invalid": "Недопустимое значение количества записей на странице.",
"error.feed_already_exists": "Эта подписка уже существует.",
"error.feed_category_not_found": "Эта категория не существует или не принадлежит этому пользователю.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "Не удалось определить формат подписки: %v.",
"error.feed_invalid_blocklist_rule": "Правило черного списка некорректно.",
"error.feed_invalid_keeplist_rule": "Правило белого списка некорректно.",
"error.feed_mandatory_fields": "Ссылка и категория обязательны.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Эта подписка не существует или не принадлежит этому пользователю.",
"error.feed_title_not_empty": "Заголовок подписки не может быть пустым.",
"error.feed_url_not_empty": "URL-адрес подписки не может быть пустым.",
"error.fields_mandatory": "Все поля обязательны.",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"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_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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.http_bad_gateway": "В данный момент сайт недоступен из-за ошибки шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_body_read": "Невозможно прочитать тело HTTP-сообщения: %v.",
"error.http_client_error": "Ошибка HTTP-клиента: %v.",
"error.http_empty_response": "Пустой ответ HTTP. Возможно этот сайт использует защиту от ботов?",
"error.http_empty_response_body": "Пустое тело HTTP-ответа.",
"error.http_forbidden": "Доступ к сайту запрещён. Возможно этот сайт использует защиту от ботов?",
"error.http_gateway_timeout": "В данный момент сайт недоступен из-за превышения времени ожидания ответа от шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_internal_server_error": "В данный момент сайт недоступен из-за ошибки сервера. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_not_authorized": "Доступ к сайту запрещён. Возможно используется неправильное имя пользователя или пароль.",
"error.http_resource_not_found": "Запрашиваемый ресурс не найден. Пожалуйста, проверьте URL.",
"error.http_response_too_large": "Превышен размер HTTP-ответа. Вы можете увеличить лимит размера HTTP-ответа в настройках (для применения новых настроек потребуется перезагрузка приложения).",
"error.http_service_unavailable": "В данный момент сайт недоступен из-за ошибки сервера. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.http_too_many_requests": "Miniflux отправил слишком много запросов к этому сайту. Пожалуйста, попробуйте позже или измените настройки приложения.",
"error.http_unexpected_status_code": "В данный момент сайт недоступен из-за непредвиденного кода HTTP-ответа: %d. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
"error.invalid_categories_sorting_order": "Недопустимый порядок сортировки категорий.",
"error.invalid_default_home_page": "Недопустимая домашняя страница по умолчанию!",
"error.invalid_display_mode": "Недопустимый режим отображения веб-приложения.",
"error.invalid_entry_direction": "Недопустимая сортировка записей.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Недопустимый порядок статей.",
"error.invalid_feed_proxy_url": "Недействительный URL прокси.",
"error.invalid_feed_url": "Недействительная ссылка подписки.",
"error.invalid_gesture_nav": "Недопустимая навигация жестами.",
"error.invalid_language": "Недопустимый язык.",
"error.invalid_site_url": "Недействительный ссылка сайта.",
"error.invalid_theme": "Недопустимая тема.",
"error.invalid_timezone": "Недопустымый часовой пояс.",
"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.invalid_timezone": "Недопустимый часовой пояс.",
"error.network_operation": "Miniflux не может открыть сайт из-за ошибки сети: %v.",
"error.network_timeout": "Этот сайт слишком медленный и время ожидания запроса истекло: %v",
"error.password_min_length": "Вы должны использовать минимум 6 символов.",
"error.pocket_access_token": "Не удалось получить ключ доступа от Pocket!",
"error.pocket_request_token": "Не удалось получить request token от Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "URL прокси не может быть пустым.",
"error.settings_block_rule_fieldname_invalid": "Недопустимое правило блокировки: у правила #%d отсутствует корректное имя поля (Возможные варианты: %s)",
"error.settings_block_rule_invalid_regex": "Недопустимое правило блокировки: шаблон правила #%d не является корректным регулярным выражением",
"error.settings_block_rule_regex_required": "Недопустимое правило блокировки: не указан шаблон для правила #%d",
"error.settings_block_rule_separator_required": "Недопустимое правило блокировки: шаблон правила #%d должен быть отделен символом '='",
"error.settings_invalid_domain_list": "Недопустимый список доменов. Пожалуйста, укажите список доменов, разделенных пробелами.",
"error.settings_keep_rule_fieldname_invalid": "Недопустимое правило сохранения: у правила #%d отсутствует корректное имя поля (Возможные варианты: %s)",
"error.settings_keep_rule_invalid_regex": "Недопустимое правило сохранения: шаблон правила #%d не является корректным регулярным выражением",
"error.settings_keep_rule_regex_required": "Недопустимое правило сохранения: не указан шаблон для правила #%d",
"error.settings_keep_rule_separator_required": "Недопустимое правило сохранения: шаблон правила #%d должен быть отделен символом '='",
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
"error.settings_reading_speed_is_positive": "Скорость чтения должна быть целым положительным числом.",
"error.site_url_not_empty": "Ссылка на сайт не может быть пустой.",
"error.subscription_not_found": "Не удалось найти подписки.",
"error.title_required": "Название обязательно.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "Ошибка TLS: %q. Вы можете отключить проверку TLS в настройках подписки.",
"error.unable_to_create_api_key": "Невозможно создать этот API-ключ.",
"error.unable_to_create_category": "Не удалось создать эту категорию.",
"error.unable_to_create_user": "Не удалось создать этого пользователя.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.unable_to_detect_rssbridge": "Не удалось обнаружить подписку с помощью RSS-Bridge: %v.",
"error.unable_to_parse_feed": "Не удалось обработать эту подписку: %v.",
"error.unable_to_update_category": "Не удалось обновить эту категорию.",
"error.unable_to_update_feed": "Не удалось обновить эту подписку.",
"error.unable_to_update_user": "Не удалось обновить этого пользователя.",
@@ -162,18 +166,19 @@
"form.api_key.label.description": "Описание API-ключа",
"form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.category.label.title": "Название",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.general": "Общие",
"form.feed.fieldset.integration": "Сторонние сервисы",
"form.feed.fieldset.network_settings": "Настройки сети",
"form.feed.fieldset.rules": "Правила",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.apprise_service_urls": "Список ссылок сервисов Apprise, разделенный запятой",
"form.feed.label.blocklist_rules": "Правила черного списка",
"form.feed.label.block_filter_entry_rules": "Правила блокировки записей",
"form.feed.label.blocklist_rules": "Фильтры блокировки на основе регулярных выражений",
"form.feed.label.category": "Категория",
"form.feed.label.cookie": "Установить куки",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.description": "Описание",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "Отключить HTTP/2 для предотвращения фингерпринтинга",
"form.feed.label.disabled": "Не обновлять эту подписку",
"form.feed.label.feed_password": "Пароль подписки",
"form.feed.label.feed_url": "Адрес подписки",
@@ -181,41 +186,42 @@
"form.feed.label.fetch_via_proxy": "Использовать прокси, настроенный на уровне приложения",
"form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP кеш",
"form.feed.label.keeplist_rules": "Правила белого списка",
"form.feed.label.keep_filter_entry_rules": "Правила разрешения записей",
"form.feed.label.keeplist_rules": "Фильтры сохранения на основе регулярных выражений",
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Правила перезаписи",
"form.feed.label.ntfy_activate": "Отправлять статьи в ntfy",
"form.feed.label.ntfy_default_priority": "По умолчанию",
"form.feed.label.ntfy_high_priority": "Высший",
"form.feed.label.ntfy_low_priority": "Низкий",
"form.feed.label.ntfy_max_priority": "Высокий",
"form.feed.label.ntfy_min_priority": "Минимальный",
"form.feed.label.ntfy_priority": "Приоритет ntfy",
"form.feed.label.ntfy_topic": "Топик ntfy (опционально)",
"form.feed.label.proxy_url": "URL прокси",
"form.feed.label.pushover_activate": "Отправлять статьи в pushover.net",
"form.feed.label.pushover_default_priority": "По умолчанию",
"form.feed.label.pushover_high_priority": "Высокий",
"form.feed.label.pushover_low_priority": "Низкий",
"form.feed.label.pushover_max_priority": "Высший",
"form.feed.label.pushover_min_priority": "Минимальный",
"form.feed.label.pushover_priority": "Приоритет сообщений Pushover",
"form.feed.label.rewrite_rules": "Правила переписывания содержимого",
"form.feed.label.scraper_rules": "Правила сборщика",
"form.feed.label.site_url": "Адрес сайта",
"form.feed.label.title": "Название",
"form.feed.label.urlrewrite_rules": "Правила перезаписи URL",
"form.feed.label.user_agent": "Переопределить User-Agent по умолчанию",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Переопределить URL вебхука",
"form.import.label.file": "OPML файл",
"form.import.label.url": "Ссылка",
"form.integration.apprise_activate": "Отправить статьи в Apprise",
"form.integration.apprise_services_url": "Список ссылок сервисов Apprise, разделенный запятой",
"form.integration.apprise_url": "Ссылка на Apprise API",
"form.integration.betula_activate": "Сохранять статьи в Бетулу",
"form.integration.betula_token": "Токен Бетулы",
"form.integration.betula_url": "Адрес сервера Бетулы",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.betula_activate": "Сохранять статьи в Betula",
"form.integration.betula_token": "Токен Betula",
"form.integration.betula_url": "Адрес сервера Betula",
"form.integration.cubox_activate": "Сохранять статьи в Cubox",
"form.integration.cubox_api_link": "Ссылка на Cubox API",
"form.integration.discord_activate": "Отправить статьи в Discord",
"form.integration.discord_webhook_link": "Ссылка на Discord Webhook",
"form.integration.espial_activate": "Сохранять статьи в Espial",
@@ -233,12 +239,15 @@
"form.integration.instapaper_activate": "Сохранять статьи в Instapaper",
"form.integration.instapaper_password": "Пароль Instapaper",
"form.integration.instapaper_username": "Имя пользователя Instapaper",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.karakeep_activate": "Сохранять статьи в Karakeep",
"form.integration.karakeep_api_key": "API-ключ Karakeep",
"form.integration.karakeep_url": "Конечная точка Karakeep API",
"form.integration.linkace_activate": "Сохранять статьи в LinkAce",
"form.integration.linkace_api_key": "API-ключ LinkAce",
"form.integration.linkace_check_disabled": "Отключить проверку ссылок",
"form.integration.linkace_endpoint": "Конечная точка LinkAce API",
"form.integration.linkace_is_private": "Отмечать ссылки как приватные",
"form.integration.linkace_tags": "Теги LinkAce",
"form.integration.linkding_activate": "Сохранять статьи в Linkding",
"form.integration.linkding_api_key": "API-ключ Linkding",
"form.integration.linkding_bookmark": "Помечать закладки как непрочитанное",
@@ -246,8 +255,8 @@
"form.integration.linkding_tags": "Теги Linkding",
"form.integration.linkwarden_activate": "Сохранять статьи в Linkwarden",
"form.integration.linkwarden_api_key": "API-ключ Linkwarden",
"form.integration.linkwarden_endpoint": "Конечная точка Linkwarden API",
"form.integration.matrix_bot_activate": "Репостить новые статьи в Matrix",
"form.integration.linkwarden_endpoint": "Базовый URL-адрес Linkwarden",
"form.integration.matrix_bot_activate": "Отправлять статьи в Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
@@ -255,14 +264,14 @@
"form.integration.notion_activate": "Сохранить статьи в Notion",
"form.integration.notion_page_id": "Идентификатор страницы Notion",
"form.integration.notion_token": "Секретный токен Notion",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_activate": "Отправлять статьи в ntfy",
"form.integration.ntfy_api_token": "API-токен ntfy (опционально)",
"form.integration.ntfy_icon_url": "URL иконки ntfy (опционально)",
"form.integration.ntfy_internal_links": "Использовать внутренние ссылки по клику (опционально)",
"form.integration.ntfy_password": "Пароль ntfy (опционально)",
"form.integration.ntfy_topic": "Тема ntfy (по умолчанию, если не задана в подписке)",
"form.integration.ntfy_url": "URL ntfy (опционально, по умолчанию ntfy.sh)",
"form.integration.ntfy_username": "Имя пользователя ntfy (опционально)",
"form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
"form.integration.nunux_keeper_api_key": "API-ключ Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
@@ -273,19 +282,15 @@
"form.integration.pinboard_bookmark": "Помечать закладки как непрочитанное",
"form.integration.pinboard_tags": "Теги Pinboard",
"form.integration.pinboard_token": "Токен Pinboard API",
"form.integration.pocket_access_token": "Ключ доступа к Pocket",
"form.integration.pocket_activate": "Сохранять статьи в Pocket",
"form.integration.pocket_connect_link": "Подключить аккаунт Pocket",
"form.integration.pocket_consumer_key": "Ключ пользователя Pocket",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
"form.integration.pushover_token": "Pushover application API token",
"form.integration.pushover_user": "Pushover user key",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.pushover_activate": "Отправлять статьи Pushover",
"form.integration.pushover_device": "Устройство Pushover (опционально)",
"form.integration.pushover_prefix": "URL-префикс Pushover (опционально)",
"form.integration.pushover_token": "API-токен приложения Pushover",
"form.integration.pushover_user": "Пользовательский ключ Pushover",
"form.integration.raindrop_activate": "Сохранять статьи в Raindrop",
"form.integration.raindrop_collection_id": "ID коллекции",
"form.integration.raindrop_tags": "Теги (через запятую)",
"form.integration.raindrop_token": "Токен (тестовый)",
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_api_key": "API-ключ Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
@@ -294,8 +299,9 @@
"form.integration.readwise_activate": "Сохранить статьи в Readwise",
"form.integration.readwise_api_key": "Токен доступа в Readwise",
"form.integration.readwise_api_key_link": "Получить токен доступа Readwise",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.rssbridge_activate": "Проверять RSS-Bridge при добавлении подписок",
"form.integration.rssbridge_token": "Токен аутентификации RSS-Bridge",
"form.integration.rssbridge_url": "URL сервера RSS-Bridge",
"form.integration.shaarli_activate": "Сохранить статьи в Shaarli",
"form.integration.shaarli_api_secret": "Секретный ключ Shaarli API",
"form.integration.shaarli_endpoint": "Ссылка Shaarli",
@@ -305,13 +311,13 @@
"form.integration.shiori_username": "Имя пользователя Shiori",
"form.integration.slack_activate": "Отправить статьи в Slack",
"form.integration.slack_webhook_link": "Ссылка на Slack Webhook",
"form.integration.telegram_bot_activate": "Репостить новые статьи в Telegram-чат",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_activate": "Отправлять статьи в Telegram-чат",
"form.integration.telegram_bot_disable_buttons": "Отключить кнопки",
"form.integration.telegram_bot_disable_notification": "Отключить уведомления",
"form.integration.telegram_bot_disable_web_page_preview": "Отключить предпросмотр веб-страниц",
"form.integration.telegram_bot_token": "Токен бота",
"form.integration.telegram_chat_id": "ID чата",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_topic_id": "ID топика",
"form.integration.wallabag_activate": "Сохранять статьи в Wallabag",
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
@@ -322,11 +328,12 @@
"form.integration.webhook_activate": "Включить вебхуки",
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
"form.integration.webhook_url": "Адрес вебхуков",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.fieldset.application_settings": "Настройки приложения",
"form.prefs.fieldset.authentication_settings": "Настройки аутентификации",
"form.prefs.fieldset.global_feed_settings": "Глобальные настройки подписок",
"form.prefs.fieldset.reader_settings": "Настройки чтения",
"form.prefs.help.external_font_hosts": "Список разрешённых внешних хостов для шрифтов, разделенных пробелами. Например: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.label.always_open_external_links": "Читать статьи, открывая внешние ссылки",
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
"form.prefs.label.cjk_reading_speed": "Скорость чтения на китайском, корейском и японском языках (знаков в минуту)",
"form.prefs.label.custom_css": "Пользовательский CSS",
@@ -338,15 +345,16 @@
"form.prefs.label.entry_order": "Столбец сортировки статей",
"form.prefs.label.entry_sorting": "Сортировка статей",
"form.prefs.label.entry_swipe": "Включить пролистывание свайпом на сенсорных экранах",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Внешние хосты шрифтов",
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
"form.prefs.label.keyboard_shortcuts": "Включить горячие клавиши",
"form.prefs.label.language": "Язык",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Отмечать статьи как прочитанные вручную",
"form.prefs.label.mark_read_on_media_completion": "Отмечать как прочитанное только когда воспроизведение аудио/видео достигает 90%% завершения",
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_view_or_media_completion": "Отмечать статьи как прочитанные при просмотре. Для аудио/видео - при 90%% завершения воспроизведения",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"form.prefs.label.open_external_links_in_new_tab": "Открывать внешние ссылки в новой вкладке (добавляет target=\"_blank\" к ссылкам)",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
"form.prefs.label.theme": "Тема",
"form.prefs.label.timezone": "Часовой пояс",
@@ -383,7 +391,7 @@
"menu.feeds": "Подписки",
"menu.flush_history": "Очистить историю",
"menu.history": "История",
"menu.home_page": "Home page",
"menu.home_page": "Главная",
"menu.import": "Импорт",
"menu.integrations": "Интеграции",
"menu.logout": "Выйти",
@@ -400,17 +408,18 @@
"menu.show_only_starred_entries": "Показывать только избранные статьи",
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
"menu.starred": "Избранное",
"menu.title": "Menu",
"menu.title": "Меню",
"menu.unread": "Непрочитанное",
"menu.users": "Пользователи",
"page.about.author": "Автор:",
"page.about.build_date": "Дата сборки:",
"page.about.credits": "Авторы",
"page.about.db_usage": "Database size:",
"page.about.db_usage": "Размер базы данных:",
"page.about.git_commit": "Git-коммит:",
"page.about.global_config_options": "Глобальные параметры конфигурации",
"page.about.go_version": "Версия Go:",
"page.about.license": "Лицензия:",
"page.about.postgres_version": "Версия Postgres:",
"page.about.postgres_version": "Версия PostgreSQL:",
"page.about.title": "О приложении",
"page.about.version": "Версия:",
"page.add_feed.choose_feed": "Выберите подписку",
@@ -426,12 +435,7 @@
"page.api_keys.table.last_used_at": "Последнее использование",
"page.api_keys.table.token": "Токен",
"page.api_keys.title": "API-ключи",
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.categories.entries": "Cтатьи",
"page.categories.entries": "Статьи",
"page.categories.feed_count": [
"Есть %d подписка.",
"Есть %d подписки.",
@@ -440,7 +444,12 @@
"page.categories.feeds": "Подписки",
"page.categories.no_feed": "Нет подписок.",
"page.categories.title": "Категории",
"page.category_label": "Category: %s",
"page.categories_count": [
"%d категория",
"%d категории",
"%d категорий"
],
"page.category_label": "Категории: %s",
"page.edit_category.title": "Изменить категорию: %s",
"page.edit_feed.etag_header": "Заголовок ETag:",
"page.edit_feed.last_check": "Последняя проверка:",
@@ -455,8 +464,8 @@
"%d ошибки",
"%d ошибок"
],
"page.feeds.last_check": "Последняя проверка:",
"page.feeds.next_check": "Next check:",
"page.feeds.last_check": "Последнее обновление:",
"page.feeds.next_check": "Следующее обновление:",
"page.feeds.read_counter": "Количество прочитанных статей",
"page.feeds.title": "Подписки",
"page.history.title": "История",
@@ -465,7 +474,7 @@
"page.integration.bookmarklet.help": "Эта специальная ссылка позволит вам подписаться на сайт, используя обыкновенную закладку в вашем браузере.",
"page.integration.bookmarklet.instructions": "Перетащите эту ссылку в ваши закладки.",
"page.integration.bookmarklet.name": "Добавить в Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Конечная точка API",
"page.integration.miniflux_api_password": "Пароль",
"page.integration.miniflux_api_password_value": "Пароль вашего аккаунта",
@@ -512,7 +521,7 @@
"page.login.title": "Войти",
"page.login.webauthn_login": "Войти с паролем",
"page.login.webauthn_login.error": "Невозможно войти с паролем",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.login.webauthn_login.help": "Пожалуйста, введите имя пользователя, если вы используете ключ безопасности. Это не требуется при использовании Passkey (обнаруживаемые учетные данные).",
"page.new_api_key.title": "Новый API-ключ",
"page.new_category.title": "Новая категория",
"page.new_user.title": "Новый пользователь",
@@ -520,9 +529,9 @@
"page.offline.refresh_page": "Попробуйте обновить страницу",
"page.offline.title": "Автономный режим",
"page.read_entry_count": [
"%d read entry",
"%d read entries",
"%d read entries"
"%d прочитанная статья",
"%d прочитанных статьи",
"%d прочитанных статей"
],
"page.search.title": "Результаты поиска",
"page.sessions.table.actions": "Действия",
@@ -536,41 +545,41 @@
"page.settings.title": "Настройки",
"page.settings.unlink_google_account": "Отвязать мой Google аккаунт",
"page.settings.unlink_oidc_account": "Отвязать мой %s аккаунт",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.actions": "Действия",
"page.settings.webauthn.added_on": "Добавлен",
"page.settings.webauthn.delete": [
"Удалить %d пароль",
"Удалить %d пароля",
"Удалить %d пароля"
],
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.last_seen_on": "Последнее использование",
"page.settings.webauthn.passkey_name": "Название ключа доступа",
"page.settings.webauthn.passkeys": "Ключи доступа",
"page.settings.webauthn.register": "Зарегистрировать пароль",
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
"%d shared entries"
],
"page.shared_entries.title": "Общедоступные статьи",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries",
"%d starred entries"
"page.shared_entries_count": [
"%d общедоступная статья",
"%d общедоступных статьи",
"%d общедоступных статей"
],
"page.starred.title": "Избранное",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total",
"%d entries in total"
"page.starred_entry_count": [
"%d избранная статья",
"%d избранные статьи",
"%d избранных статей"
],
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries",
"%d unread entries"
"page.total_entry_count": [
"%d статья всего",
"%d статьи всего",
"%d статей всего"
],
"page.unread.title": "Непрочитанное",
"page.unread_entry_count": [
"%d непрочитанная статья",
"%d непрочитанных статьи",
"%d непрочитанных статей"
],
"page.users.actions": "Действия",
"page.users.admin.no": "Нет",
"page.users.admin.yes": "Да",
@@ -579,15 +588,15 @@
"page.users.never_logged": "Никогда",
"page.users.title": "Пользователи",
"page.users.username": "Имя пользователя",
"page.webauthn_rename.title": "Rename Passkey",
"pagination.first": "First",
"pagination.last": "Last",
"page.webauthn_rename.title": "Переименовать ключ доступа",
"pagination.first": "Первая",
"pagination.last": "Последняя",
"pagination.next": "Следующая",
"pagination.previous": "Предыдущая",
"search.label": "Поиск",
"search.placeholder": "Поиск…",
"search.submit": "Search",
"skip_to_content": "Skip to content",
"search.submit": "Искать",
"skip_to_content": "Перейти к содержимому",
"time_elapsed.days": [
"%d день назад",
"%d дня назад",
+50 -42
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş 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.",
@@ -77,6 +76,10 @@
"entry.status.toast.read": "Okundu olarak işaretlendi",
"entry.status.toast.unread": "Okunmamış olarak işaretlendi",
"entry.tags.label": "Etiketler:",
"entry.tags.more_tags_label": [
"%d tane daha etiket göster",
"%d tane daha etiket göster"
],
"entry.unshare.label": "Paylaşma",
"error.api_key_already_exists": "Bu API anahtarı zaten mevcut.",
"error.bad_credentials": "Geçersiz kullanıcı veya parola.",
@@ -114,10 +117,12 @@
"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_categories_sorting_order": "Geçersiz kategori sıralama düzeni.",
"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_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Geçersiz makele sıralaması.",
"error.invalid_feed_proxy_url": "Geçersiz proxy URL'si.",
"error.invalid_feed_url": "Geçersiz besleme URL'si.",
"error.invalid_gesture_nav": "Hareketle gezinme geçersiz.",
"error.invalid_language": "Geçersiz dil.",
@@ -127,14 +132,12 @@
"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.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "Proxy URL'si boş olamaz.",
"error.settings_block_rule_fieldname_invalid": "Geçersiz Engelleme kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
"error.settings_block_rule_invalid_regex": "Geçersiz Engelleme kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
"error.settings_block_rule_regex_required": "Geçersiz Engelleme kuralı: #%d kuralı modeli sağlanmadı",
"error.settings_block_rule_separator_required": "Geçersiz Engelleme kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_invalid_domain_list": "Geçersiz alan adı listesi. Lütfen boşlukla ayrılmış bir alan adı listesi girin.",
"error.settings_keep_rule_fieldname_invalid": "Geçersiz Koruma kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
"error.settings_keep_rule_invalid_regex": "Geçersiz Koruma kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
"error.settings_keep_rule_regex_required": "Geçersiz Koruma kuralı: #%d kuralı modeli sağlanmadı",
@@ -166,7 +169,8 @@
"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.block_filter_entry_rules": "Giriş Engelleme Kuralları",
"form.feed.label.blocklist_rules": "Regex Tabanlı Engelleme Filtreleri",
"form.feed.label.category": "Kategori",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.crawler": "Orijinal içeriği çek",
@@ -179,31 +183,32 @@
"form.feed.label.fetch_via_proxy": "Uygulama düzeyinde yapılandırılmış proxy'yi kullan",
"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.keep_filter_entry_rules": "Giriş İzin Kuralları",
"form.feed.label.keeplist_rules": "Regex Tabanlı Tutma Filtreleri",
"form.feed.label.no_media_player": "Medya oynatıcı yok (ses/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.ntfy_activate": "Makaleleri ntfy'ye gönder",
"form.feed.label.ntfy_default_priority": "Ntfy varsayılan öncelik",
"form.feed.label.ntfy_high_priority": "Ntfy yüksek öncelik",
"form.feed.label.ntfy_low_priority": "Ntfy düşük öncelik",
"form.feed.label.ntfy_max_priority": "Ntfy maksimum öncelik",
"form.feed.label.ntfy_min_priority": "Ntfy minimum öncelik",
"form.feed.label.ntfy_priority": "Ntfy öncelik",
"form.feed.label.ntfy_topic": "Ntfy konusu (isteğe bağlı)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Yeniden Yazma Kuralları",
"form.feed.label.pushover_activate": "Makaleleri pushover.net'e gönder",
"form.feed.label.pushover_default_priority": "Pushover varsayılan öncelik",
"form.feed.label.pushover_high_priority": "Pushover yüksek öncelik",
"form.feed.label.pushover_low_priority": "Pushover düşük öncelik",
"form.feed.label.pushover_max_priority": "Pushover maksimum öncelik",
"form.feed.label.pushover_min_priority": "Pushover minimum öncelik",
"form.feed.label.pushover_priority": "Pushover mesaj önceliği",
"form.feed.label.rewrite_rules": "İçerik 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.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Webhook URL'sini geçersiz kıl",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "Makaleleri Apprise'a gönder",
@@ -212,8 +217,8 @@
"form.integration.betula_activate": "Makaleleri Betula'ya kaydet",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula sunucu URLsi",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.cubox_activate": "Makaleleri Cubox'a kaydet",
"form.integration.cubox_api_link": "Cubox API bağlantısı",
"form.integration.discord_activate": "Makaleleri Discord'a gönder",
"form.integration.discord_webhook_link": "Discord hizmet Webhook'lerinin virgülle ayrılmış listesi",
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
@@ -231,6 +236,9 @@
"form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
"form.integration.instapaper_password": "Instapaper Parolası",
"form.integration.instapaper_username": "Instapaper Kullanıcı Adı",
"form.integration.karakeep_activate": "Makaleleri Karakeep'a kaydet",
"form.integration.karakeep_api_key": "Karakeep API anahtarı",
"form.integration.karakeep_url": "Karakeep API Uç Noktası",
"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",
@@ -244,7 +252,7 @@
"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.linkwarden_endpoint": "Linkwarden Temel URL'si",
"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",
@@ -256,7 +264,7 @@
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_internal_links": "Tıklamada dahili bağlantıları kullan (isteğe bağlı)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
@@ -271,10 +279,6 @@
"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.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -293,6 +297,7 @@
"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_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Makaleleri Shaarli'ye kaydet",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -324,7 +329,8 @@
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.global_feed_settings": "Genel Besleme Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.help.external_font_hosts": "İzin verilecek harici font sunucularının boşlukla ayrılmış listesi. Örneğin: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Makaleleri harici bağlantıları açarak oku",
"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",
@@ -336,7 +342,7 @@
"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.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Harici font sunucuları",
"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",
@@ -345,6 +351,7 @@
"form.prefs.label.mark_read_on_view": "Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
"form.prefs.label.open_external_links_in_new_tab": "Harici bağlantıları yeni bir sekmede aç (bağlantılara target=\"_blank\" ekler)",
"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",
@@ -405,6 +412,7 @@
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Global yapılandırma seçenekleri",
"page.about.go_version": "Go sürümü:",
"page.about.license": "Lisans:",
@@ -424,10 +432,6 @@
"page.api_keys.table.last_used_at": "Son Kullanılma",
"page.api_keys.table.token": "Token",
"page.api_keys.title": "API Anahtarları",
"page.categories_count": [
"%d kategori",
"%d kategori"
],
"page.categories.entries": "Makaleler",
"page.categories.feed_count": [
"%d besleme var.",
@@ -436,6 +440,10 @@
"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ığı:",
@@ -541,25 +549,25 @@
"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.shared_entries.title": "Paylaşılan makaleler",
"page.starred.title": "Yıldızlı",
"page.starred_entry_count": [
"%d yıldızlanmış makale",
"%d yıldızlanmış makale"
],
"page.starred.title": "Yıldızlı",
"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.unread.title": "Okunmadı",
"page.users.actions": "Eylemler",
"page.users.admin.no": "Hayır",
"page.users.admin.yes": "Evet",
+105 -96
View File
@@ -13,7 +13,7 @@
"action.update": "Зберегти",
"alert.account_linked": "Тепер ваш зовнішній обліковий запис від’єднано!",
"alert.account_unlinked": "Тепер ваш зовнішній обліковий запис підключено!",
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"alert.background_feed_refresh": "Всі стрічки оновлюються у фоновому режимі. Ви можете продовжувати користуватися Miniflux, поки триває цей процес.",
"alert.feed_error": "З цією стрічкою трапилась помилка",
"alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
@@ -27,27 +27,26 @@
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_unread_entry": "Немає непрочитаних статей.",
"alert.no_user": "Ви єдиний користувач.",
"alert.pocket_linked": "Тепер ваш обліковий запис Pocket підключено!",
"alert.prefs_saved": "Уподобання збережено!",
"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."
"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилину перед повторною спробою.",
"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилини перед повторною спробою.",
"Ви запустили надто багато оновлень стрічок. Будь ласка, зачекайте %d хвилин перед повторною спробою."
],
"confirm.loading": "В процесі...",
"confirm.no": "ні",
"confirm.question": "Ви впевнені?",
"confirm.question.refresh": "Ви хочете змусити оновити?",
"confirm.yes": "так",
"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.reset": "Reset",
"enclosure_media_controls.speed.reset.title": "Reset speed to 1x",
"enclosure_media_controls.speed.slower": "Slower",
"enclosure_media_controls.speed.slower.title": "Slower by %sx",
"enclosure_media_controls.seek": "Пошук:",
"enclosure_media_controls.seek.title": "Пошук %s секунд",
"enclosure_media_controls.speed": "Швидкість:",
"enclosure_media_controls.speed.faster": "Швидше",
"enclosure_media_controls.speed.faster.title": "Швидше на %sx",
"enclosure_media_controls.speed.reset": "Скинути",
"enclosure_media_controls.speed.reset.title": "Скинути швидкість до 1x",
"enclosure_media_controls.speed.slower": "Повільніше",
"enclosure_media_controls.speed.slower.title": "Повільніше на %sx",
"entry.bookmark.toast.off": "Без зірочки",
"entry.bookmark.toast.on": "З зірочкою",
"entry.bookmark.toggle.off": "Прибрати зірочку",
@@ -79,75 +78,80 @@
"entry.status.toast.read": "Відмічено прочитаним",
"entry.status.toast.unread": "Відмічено непрочитаним",
"entry.tags.label": "Теги:",
"entry.tags.more_tags_label": [
"Ще %d тег",
"Ще %d теги",
"Ще %d тегів"
],
"entry.unshare.label": "Не ділитися",
"error.api_key_already_exists": "Такий ключ API вже існує.",
"error.bad_credentials": "Невірне ім’я користувача або пароль.",
"error.category_already_exists": "Така категорія вже існує.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "Ця категорія не існує або не належить цьому користувачу.",
"error.database_error": "Помилка бази даних: %v.",
"error.different_passwords": "Паролі не співпадають.",
"error.duplicate_fever_username": "Вже є обліковий запис з таким самим користувачем Fever!",
"error.duplicate_googlereader_username": "Вже є обліковий запис з таким самим користувачем Google Reader!",
"error.duplicate_linked_account": "Вже є обліковий запис, під’єднаний до цього провайдера!",
"error.duplicated_feed": "This feed already exists.",
"error.duplicated_feed": "Ця стрічка вже існує.",
"error.empty_file": "Цей файл порожній.",
"error.entries_per_page_invalid": "Число записів на сторінку недійсне.",
"error.feed_already_exists": "Така стрічка вже існує.",
"error.feed_category_not_found": "Категорія не існує або належить до іншого користувача.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"error.feed_format_not_detected": "Не вдалося визначити формат стрічки: %v.",
"error.feed_invalid_blocklist_rule": "Правило списку блокувань недійсне.",
"error.feed_invalid_keeplist_rule": "Правило списку дозволень недійсне.",
"error.feed_mandatory_fields": "URL та категорія є обов’язковими.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.feed_not_found": "Ця стрічка не існує або не належить цьому користувачу.",
"error.feed_title_not_empty": "Назва стрічки не може бути порожньою.",
"error.feed_url_not_empty": "URL-адреса стрічки не може бути порожньою.",
"error.fields_mandatory": "Всі поля є обов’язковими.",
"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_body_read": "Unable to read the HTTP body: %v.",
"error.http_client_error": "HTTP client error: %v.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"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_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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"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_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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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.http_bad_gateway": "Сайт наразі недоступний через помилку шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_body_read": "Не вдалося прочитати HTTP-вміст: %v.",
"error.http_client_error": "Помилка HTTP-клієнта: %v.",
"error.http_empty_response": "Відповідь HTTP порожня. Можливо, цей сайт використовує захист від ботів?",
"error.http_empty_response_body": "Тіло відповіді HTTP порожнє.",
"error.http_forbidden": "Доступ до цього сайту заборонено. Можливо, сайт має захист від ботів?",
"error.http_gateway_timeout": "Сайт наразі недоступний через помилку тайм-ауту шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_internal_server_error": "Сайт наразі недоступний через внутрішню помилку сервера. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_not_authorized": "Доступ до цього сайту не дозволено. Можливо, неправильне ім’я користувача або пароль.",
"error.http_resource_not_found": "Запитаний ресурс не знайдено. Будь ласка, перевірте URL.",
"error.http_response_too_large": "Відповідь HTTP занадто велика. Ви можете збільшити ліміт розміру HTTP-відповіді у глобальних налаштуваннях (потрібен перезапуск сервера).",
"error.http_service_unavailable": "Сайт наразі недоступний через внутрішню помилку сервера. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.http_too_many_requests": "Miniflux згенерував надто багато запитів до цього сайту. Будь ласка, спробуйте пізніше або змініть налаштування програми.",
"error.http_unexpected_status_code": "Сайт наразі недоступний через неочікуваний HTTP-код: %d. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
"error.invalid_categories_sorting_order": "Недійсний порядок сортування категорій.",
"error.invalid_default_home_page": "Недійсна домашня сторінка за замовчуванням!",
"error.invalid_display_mode": "Недійсний режим відображення.",
"error.invalid_entry_direction": "Недійсний напрямок запису.",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "Недійсний порядок запису.",
"error.invalid_feed_proxy_url": "Недійсний proxy URL.",
"error.invalid_feed_url": "Недійсна URL-адреса стрічки.",
"error.invalid_gesture_nav": "Недійсна навігація жестами.",
"error.invalid_language": "Недійсна мова.",
"error.invalid_site_url": "Недійсна URL-адреса сайту.",
"error.invalid_theme": "Недійсна тема.",
"error.invalid_timezone": "Недійсний часовий пояс.",
"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.network_operation": "Miniflux не може отримати доступ до цього сайту через помилку мережі: %v.",
"error.network_timeout": "Цей сайт занадто повільний і запит перевищив час очікування: %v",
"error.password_min_length": "Пароль має складати щонайменше 6 символів.",
"error.pocket_access_token": "Не вдалося отримати токен доступу з Pocket!",
"error.pocket_request_token": "Не вдалося отримати токен доступу з Pocket!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.proxy_url_not_empty": "Proxy URL не може бути порожнім.",
"error.settings_block_rule_fieldname_invalid": "Недійсне правило блокування: у правилі #%d відсутнє коректне ім’я поля (Опції: %s)",
"error.settings_block_rule_invalid_regex": "Недійсне правило блокування: шаблон правила #%d не є коректним регулярним виразом",
"error.settings_block_rule_regex_required": "Недійсне правило блокування: не вказано шаблон для правила #%d",
"error.settings_block_rule_separator_required": "Недійсне правило блокування: шаблон правила #%d має бути розділений знаком '='",
"error.settings_invalid_domain_list": "Недійсний список доменів. Будь ласка, вкажіть список доменів, розділених пробілами.",
"error.settings_keep_rule_fieldname_invalid": "Недійсне правило дозволення: у правилі #%d відсутнє коректне ім’я поля (Опції: %s)",
"error.settings_keep_rule_invalid_regex": "Недійсне правило дозволення: шаблон правила #%d не є коректним регулярним виразом",
"error.settings_keep_rule_regex_required": "Недійсне правило дозволення: не вказано шаблон для правила #%d",
"error.settings_keep_rule_separator_required": "Недійсне правило дозволення: шаблон правила #%d має бути розділений знаком '='",
"error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"error.settings_reading_speed_is_positive": "Швидкість читання має бути додатнім цілим числом.",
"error.site_url_not_empty": "URL-адреса сайту не може бути порожньою.",
"error.subscription_not_found": "Не знайшлося жодної підписки.",
"error.title_required": "Назва є обов’язковою.",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.tls_error": "Помилка TLS: %q. Ви можете відключити перевірку TLS в налаштуваннях фіду, якщо хочете.",
"error.unable_to_create_api_key": "Не вдається створити такий ключ API",
"error.unable_to_create_category": "Не вдається сворити категорію.",
"error.unable_to_create_user": "Не вдається створити користувача.",
@@ -162,18 +166,19 @@
"form.api_key.label.description": "Назва ключа API",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.category.label.title": "Назва",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.general": "Загальні",
"form.feed.fieldset.integration": "Сторонні сервіси",
"form.feed.fieldset.network_settings": "Налаштування мережі",
"form.feed.fieldset.rules": "Правила",
"form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Правила блокування",
"form.feed.label.apprise_service_urls": "Список URL сервісів Apprise, розділених комами",
"form.feed.label.block_filter_entry_rules": "Правила блокування записів",
"form.feed.label.blocklist_rules": "Фільтри блокування на основі регулярних виразів",
"form.feed.label.category": "Категорія",
"form.feed.label.cookie": "Встановити кукі",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.description": "Опис",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.disable_http2": "Вимкнути HTTP/2 для уникнення відбитків",
"form.feed.label.disabled": "Не оновлювати цю стрічку",
"form.feed.label.feed_password": "Пароль для завантаження",
"form.feed.label.feed_url": "URL-адреса стрічки",
@@ -181,41 +186,42 @@
"form.feed.label.fetch_via_proxy": "Використовувати проксі, налаштований на рівні програми",
"form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.feed.label.ignore_http_cache": "Ігнорувати кеш HTTP",
"form.feed.label.keeplist_rules": "Правила дозволення",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_topic": "Ntfy topic (optional)",
"form.feed.label.keep_filter_entry_rules": "Правила дозволу записів",
"form.feed.label.keeplist_rules": "Фільтри збереження на основі регулярних виразів",
"form.feed.label.no_media_player": "Немає медіаплеєра (аудіо/відео)",
"form.feed.label.ntfy_activate": "Надсилати записи у ntfy",
"form.feed.label.ntfy_default_priority": "Стандартний пріоритет ntfy",
"form.feed.label.ntfy_high_priority": "Високий пріоритет ntfy",
"form.feed.label.ntfy_low_priority": "Низький пріоритет ntfy",
"form.feed.label.ntfy_max_priority": "Максимальний пріоритет ntfy",
"form.feed.label.ntfy_min_priority": "Мінімальний пріоритет ntfy",
"form.feed.label.ntfy_priority": "Пріоритет ntfy",
"form.feed.label.ntfy_topic": "Тема ntfy (необов’язково)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "Правила Rewrite",
"form.feed.label.pushover_activate": "Надсилати записи у pushover.net",
"form.feed.label.pushover_default_priority": "Стандартний пріоритет Pushover",
"form.feed.label.pushover_high_priority": "Високий пріоритет Pushover",
"form.feed.label.pushover_low_priority": "Низький пріоритет Pushover",
"form.feed.label.pushover_max_priority": "Максимальний пріоритет Pushover",
"form.feed.label.pushover_min_priority": "Мінімальний пріоритет Pushover",
"form.feed.label.pushover_priority": "Пріоритет повідомлення Pushover",
"form.feed.label.rewrite_rules": "Правила перезапису вмісту",
"form.feed.label.scraper_rules": "Правила Scraper",
"form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.title": "Назва",
"form.feed.label.urlrewrite_rules": "Правила перезапису URL-адрес",
"form.feed.label.user_agent": "Назначити User Agent",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "Перевизначити URL вебхука",
"form.import.label.file": "Файл OPML",
"form.import.label.url": "URL-адреса",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.apprise_activate": "Надсилати записи у Apprise",
"form.integration.apprise_services_url": "Список URL сервісів Apprise, розділених комами",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_url": "Betula server URL",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.integration.cubox_activate": "Зберігати статті до Cubox",
"form.integration.cubox_api_link": "Посилання на Cubox API",
"form.integration.discord_activate": "Push entries to Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.espial_activate": "Зберігати статті до Espial",
@@ -233,6 +239,9 @@
"form.integration.instapaper_activate": "Зберігати статті до Instapaper",
"form.integration.instapaper_password": "Пароль Instapaper",
"form.integration.instapaper_username": "Ім’я користувача Instapaper",
"form.integration.karakeep_activate": "Зберігати статті до Karakeep",
"form.integration.karakeep_api_key": "Ключ API Karakeep",
"form.integration.karakeep_url": "Karakeep API Endpoint",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_check_disabled": "Disable link check",
@@ -246,7 +255,7 @@
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkwarden_activate": "Зберігати статті до Linkwarden",
"form.integration.linkwarden_api_key": "Ключ API Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden API Endpoint",
"form.integration.linkwarden_endpoint": "Базова URL-адреса Linkwarden",
"form.integration.matrix_bot_activate": "Перенесення нових статей в Матрицю",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
@@ -255,10 +264,10 @@
"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.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_activate": "Надсилати записи у ntfy",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
"form.integration.ntfy_internal_links": "Використовувати внутрішні посилання при натисканні (необов’язково)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_topic": "Ntfy topic (default if not set in feed)",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
@@ -273,10 +282,6 @@
"form.integration.pinboard_bookmark": "Відмічати закладку як непрочитану",
"form.integration.pinboard_tags": "Теги для Pinboard",
"form.integration.pinboard_token": "API ключ від Pinboard",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_activate": "Зберігати статті до Pocket",
"form.integration.pocket_connect_link": "Підключити ваш обліковий запис Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -295,6 +300,7 @@
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
@@ -326,7 +332,8 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"form.prefs.help.external_font_hosts": "Список дозволених зовнішніх хостів шрифтів, розділених пробілами. Наприклад: 'fonts.gstatic.com fonts.googleapis.com'.",
"form.prefs.label.always_open_external_links": "Читати статті, відкриваючи зовнішні посилання",
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
"form.prefs.label.cjk_reading_speed": "Швидкість читання для китайської, корейської та японської мови (символів на хвилину)",
"form.prefs.label.custom_css": "Спеціальний CSS",
@@ -338,7 +345,7 @@
"form.prefs.label.entry_order": "Стовпець сортування записів",
"form.prefs.label.entry_sorting": "Сортування записів",
"form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.label.external_font_hosts": "Зовнішні хости шрифтів",
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
"form.prefs.label.keyboard_shortcuts": "Увімкнути комбінації клавиш",
"form.prefs.label.language": "Мова",
@@ -347,6 +354,7 @@
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"form.prefs.label.open_external_links_in_new_tab": "Відкривати зовнішні посилання у новій вкладці (додає target=\"_blank\" до посилань)",
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
"form.prefs.label.theme": "Тема",
"form.prefs.label.timezone": "Часовий пояс",
@@ -407,6 +415,7 @@
"page.about.build_date": "Дата побудови:",
"page.about.credits": "Титри",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "Параметри глобальної конфігурації",
"page.about.go_version": "Версія Go:",
"page.about.license": "Ліцензія:",
@@ -426,11 +435,6 @@
"page.api_keys.table.last_used_at": "Дата останнього використання",
"page.api_keys.table.token": "Токен",
"page.api_keys.title": "Ключі API",
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.categories.entries": "Статті",
"page.categories.feed_count": [
"Містить %d стрічку.",
@@ -440,6 +444,11 @@
"page.categories.feeds": "Підписки",
"page.categories.no_feed": "Немає стрічки.",
"page.categories.title": "Категорії",
"page.categories_count": [
"%d category",
"%d categories",
"%d categories"
],
"page.category_label": "Категорія: %s",
"page.edit_category.title": "Редагування категорії: %s",
"page.edit_feed.etag_header": "Заголовок ETag:",
@@ -548,29 +557,29 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "Зареєструвати пароль",
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
"page.shared_entries.title": "Спільні записи",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entries",
"%d shared entries"
],
"page.shared_entries.title": "Спільні записи",
"page.starred.title": "З зірочкою",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entries",
"%d starred entries"
],
"page.starred.title": "З зірочкою",
"page.total_entry_count": [
"%d entry in total",
"%d entries in total",
"%d entries in total"
],
"page.unread.title": "Непрочитане",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entries",
"%d unread entries"
],
"page.unread.title": "Непрочитане",
"page.users.actions": "Дії",
"page.users.admin.no": "Ні",
"page.users.admin.yes": "Так",
+305 -298
View File
@@ -6,40 +6,39 @@
"action.import": "导入",
"action.login": "登录",
"action.or": "或",
"action.remove": "除",
"action.remove_feed": "除此源",
"action.remove": "除",
"action.remove_feed": "除此订阅源",
"action.save": "保存",
"action.subscribe": "订阅",
"action.update": "更新",
"alert.account_linked": "您的外部账号已关联!",
"alert.account_unlinked": "您的外部帐户已解除关联!",
"alert.background_feed_refresh": "所有订阅源正在后台新。此过程中您仍可继续使用 Miniflux。",
"alert.feed_error": "源存在问题",
"alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "分类下没有文章",
"alert.no_feed": "目前没有源",
"alert.no_feed_entry": "源中没有文章",
"alert.no_feed_in_category": "没有该类别的源。",
"alert.no_history": "前没有历史",
"alert.no_search_result": "搜索没有结果",
"alert.no_shared_entry": "没有分享文章。",
"alert.no_tag_entry": "没有此标签匹配的条目。",
"alert.no_unread_entry": "目前没有未读文章",
"alert.no_user": "您是目前仅有的用户",
"alert.pocket_linked": "您的 Pocket 帐户现已关联",
"alert.prefs_saved": "设置已存储!",
"alert.account_unlinked": "您的外部帐户已解除关联!",
"alert.background_feed_refresh": "所有订阅源正在后台新。您可以在刷新过程中继续使用 Miniflux。",
"alert.feed_error": "此订阅源存在问题",
"alert.no_bookmark": "没有收藏的条目。",
"alert.no_category": "没有分类",
"alert.no_category_entry": "分类下没有条目。",
"alert.no_feed": "你没有任何订阅源。",
"alert.no_feed_entry": "此订阅源中没有条目。",
"alert.no_feed_in_category": "此分类中没有订阅源。",
"alert.no_history": "前没有历史记录。",
"alert.no_search_result": "搜索没有结果",
"alert.no_shared_entry": "没有分享条目。",
"alert.no_tag_entry": "没有匹配此标签的条目。",
"alert.no_unread_entry": "没有未读条目。",
"alert.no_user": "您是唯一的用户",
"alert.prefs_saved": "偏好设置已保存!",
"alert.too_many_feeds_refresh": [
"多次触发订阅源更新,请等待 %d 分钟后重试。"
"您触发了太多次订阅源刷新。请在 %d 分钟后重试。"
],
"confirm.loading": "行中…",
"confirm.loading": "行中…",
"confirm.no": "否",
"confirm.question": "您确吗?",
"confirm.question.refresh": "您是否要强制刷新?",
"confirm.question": "您确吗?",
"confirm.question.refresh": "您确定要强制刷新",
"confirm.yes": "是",
"enclosure_media_controls.seek": "查找:",
"enclosure_media_controls.seek": "查找",
"enclosure_media_controls.seek.title": "查找 %s 秒",
"enclosure_media_controls.speed": "速度:",
"enclosure_media_controls.speed": "速度",
"enclosure_media_controls.speed.faster": "快进",
"enclosure_media_controls.speed.faster.title": "速度快进到 %sx",
"enclosure_media_controls.speed.reset": "重置",
@@ -56,130 +55,135 @@
"需要 %d 分钟阅读"
],
"entry.external_link.label": "外部链接",
"entry.save.completed": "完成",
"entry.save.completed": "完成",
"entry.save.label": "保存",
"entry.save.title": "保存这篇文章",
"entry.save.toast.completed": "已保存文章",
"entry.scraper.completed": "抓取完成",
"entry.scraper.label": "抓取全文",
"entry.scraper.title": "抓取全文内容",
"entry.save.title": "保存此条目",
"entry.save.toast.completed": "条目已保存",
"entry.scraper.completed": "完成",
"entry.scraper.label": "下载",
"entry.scraper.title": "获取原始内容",
"entry.share.label": "分享",
"entry.share.title": "分享这篇文章",
"entry.share.title": "分享此条目",
"entry.shared_entry.label": "分享",
"entry.shared_entry.title": "打开公链接",
"entry.state.loading": "载中…",
"entry.shared_entry.title": "打开公链接",
"entry.state.loading": "载中…",
"entry.state.saving": "保存中…",
"entry.status.mark_as_read": "标为已读",
"entry.status.mark_as_unread": "标为未读",
"entry.status.title": "更改状态",
"entry.status.title": "更改条目状态",
"entry.status.toast.read": "已标为已读",
"entry.status.toast.unread": "已标为未读",
"entry.tags.label": "标签:",
"entry.tags.more_tags_label": [
"显示 %d 个更多标签"
],
"entry.unshare.label": "取消分享",
"error.api_key_already_exists": "此 API 密钥已存在。",
"error.bad_credentials": "用户名或密码无效",
"error.category_already_exists": "分类已存在",
"error.category_not_found": "分类不存在或不属于用户。",
"error.bad_credentials": "用户名或密码无效",
"error.category_already_exists": "分类已存在",
"error.category_not_found": "分类不存在或不属于用户。",
"error.database_error": "数据库错误: %v。",
"error.different_passwords": "两次输入的密码不同",
"error.duplicate_fever_username": "Fever 用户名已被占用",
"error.duplicate_googlereader_username": "Google Reader 用户名已被占用",
"error.duplicate_linked_account": "该 Provider 已被关联!",
"error.duplicated_feed": "订阅源已经存在。",
"error.empty_file": "文件为空",
"error.entries_per_page_invalid": "每页的文章数无效。",
"error.feed_already_exists": "此源已存在。",
"error.feed_category_not_found": "此类不存在或不属于用户。",
"error.feed_format_not_detected": "无法解析订阅源格式: %v。",
"error.different_passwords": "密码不一致。",
"error.duplicate_fever_username": "已存在其他用户使用相同的 Fever 用户名!",
"error.duplicate_googlereader_username": "已存在其他用户使用相同的 Google Reader 用户名!",
"error.duplicate_linked_account": "已有人与该提供商关联!",
"error.duplicated_feed": "订阅源已经存在。",
"error.empty_file": "文件为空",
"error.entries_per_page_invalid": "每页的条目数无效。",
"error.feed_already_exists": "此订阅源已存在。",
"error.feed_category_not_found": "此类不存在或不属于用户。",
"error.feed_format_not_detected": "无法解析订阅源格式%v。",
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
"error.feed_mandatory_fields": "必须填写网址和分类",
"error.feed_not_found": "订阅源不存在或不属于用户。",
"error.feed_mandatory_fields": "必须填写 URL 和分类",
"error.feed_not_found": "订阅源不存在或不属于用户。",
"error.feed_title_not_empty": "订阅源的标题不能为空。",
"error.feed_url_not_empty": "订阅源的网址不能为空。",
"error.fields_mandatory": "必须填写全部信息",
"error.http_bad_gateway": "当前由于错误的网关导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
"error.http_body_read": "无法读取HTTP主体: %v。",
"error.http_client_error": "HTTP 客户端错误r: %v。",
"error.http_empty_response": "HTTP 响应内容为空,该网站可能正在使用机器人保护机制。",
"error.http_empty_response_body": "HTTP 响应主体为空。",
"error.http_forbidden": "该网站被禁止访问网站可能有机器人保护机制?",
"error.http_gateway_timeout": "当前由于网关超时导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
"error.http_internal_server_error": "当前由于服务器错误导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
"error.http_not_authorized": "该网站访问未授权,可能用户名密码错误。",
"error.http_resource_not_found": "请求资源无法找到,请检查 URL。",
"error.http_response_too_large": "HTTP 响应内容过大您可以在全局设置中增加 HTTP 响应大小限制(需服务器重新启动)。",
"error.http_service_unavailable": "当前由于服务器内部错误导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
"error.http_too_many_requests": "Miniflux 对该网站请求过多次数,请稍后重试或改应用配置。",
"error.http_unexpected_status_code": "当前由于意外的 HTTP 状态码%d 导致该网站无法访问,问题不在 Miniflux,请稍后重试。",
"error.invalid_default_home_page": "无效的默认主页!",
"error.feed_url_not_empty": "订阅源的 URL 不能为空。",
"error.fields_mandatory": "必须填写全部信息",
"error.http_bad_gateway": "由于网关错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_body_read": "无法读取 HTTP 正文:%v。",
"error.http_client_error": "HTTP 客户端错误%v。",
"error.http_empty_response": "HTTP 响应为空,该网站可能使用了反爬虫机制。",
"error.http_empty_response_body": "HTTP 响应正文为空。",
"error.http_forbidden": "禁止访问网站可能该网站使用了反爬虫机制?",
"error.http_gateway_timeout": "由于网关超时,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_internal_server_error": "由于服务器错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_not_authorized": "未经授权访问此网站。可能用户名密码错误。",
"error.http_resource_not_found": "未找到请求资源请检查 URL。",
"error.http_response_too_large": "HTTP 响应过大您可以在全局设置中增加 HTTP 响应大小限制(需重启服务器)。",
"error.http_service_unavailable": "由于内部服务器错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.http_too_many_requests": "Miniflux 向此网站生成了过多请求。请稍后重试或改应用程序配置。",
"error.http_unexpected_status_code": "由于意外的 HTTP 状态码 %d,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
"error.invalid_categories_sorting_order": "无效的分类排序顺序。",
"error.invalid_default_home_page": "无效的默认主页!",
"error.invalid_display_mode": "无效的网页应用显示模式。",
"error.invalid_entry_direction": "无效的输入方向。",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_feed_url": "订阅源的网址无效。",
"error.invalid_gesture_nav": "手势导航无效。",
"error.invalid_entry_direction": "无效的条目方向。",
"error.invalid_entry_order": "无效的条目排序。",
"error.invalid_feed_proxy_url": "无效的代理 URL。",
"error.invalid_feed_url": "无效的订阅源 URL。",
"error.invalid_gesture_nav": "无效的手势导航。",
"error.invalid_language": "无效的语言。",
"error.invalid_site_url": "源网站的网址无效。",
"error.invalid_site_url": "无效的网站 URL。",
"error.invalid_theme": "无效的主题。",
"error.invalid_timezone": "无效的时区。",
"error.network_operation": "Miniflux 无法访问网站由于网络错误: %v。",
"error.network_timeout": "该网站响应过慢,请求超时: %v",
"error.password_min_length": "请至少输入 6 个字符",
"error.pocket_access_token": "无法从 Pocket 获取访问令牌!",
"error.pocket_request_token": "无法从 Pocket 获取请求令牌!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.settings_block_rule_fieldname_invalid": "无效的阻止规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
"error.settings_block_rule_invalid_regex": "无效的阻止规则: 规则 #%d 的模式字符不是合法的正则表达式。",
"error.settings_block_rule_regex_required": "无效的阻止规则: 规则 #%d 的模式字符没有提供。",
"error.settings_block_rule_separator_required": "无效的阻止规则: 规则 #%d 的模式字符必须用‘=’分开。",
"error.settings_invalid_domain_list": "域名列表无效。请提供一个用空格分隔的域名列表。",
"error.settings_keep_rule_fieldname_invalid": "无效的保留规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
"error.settings_keep_rule_invalid_regex": "无效的保留规则: 规则 #%d 的模式字符不是合法的正则表达式。",
"error.settings_keep_rule_regex_required": "无效的保留规则: 规则 #%d 的模式字符没有提供。",
"error.settings_keep_rule_separator_required": "无效的保留规则: 规则 #%d 的模式字符必须用‘=’分开。",
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
"error.network_operation": "由于网络错误,Miniflux 无法访问网站%v。",
"error.network_timeout": "该网站响应过慢,请求超时%v",
"error.password_min_length": "密码长度至少为 6 个字符",
"error.proxy_url_not_empty": "代理 URL 不能为空。",
"error.settings_block_rule_fieldname_invalid": "无效的阻止规则:规则 #%d 缺少合法的字段名(可选:%s)",
"error.settings_block_rule_invalid_regex": "无效的阻止规则:规则 #%d 的模式字符不是合法的正则表达式",
"error.settings_block_rule_regex_required": "无效的阻止规则规则 #%d 的模式字符没有提供",
"error.settings_block_rule_separator_required": "无效的阻止规则规则 #%d 的模式字符必须用‘=’分开",
"error.settings_invalid_domain_list": "无效的域名列表。请提供以空格分隔的域名列表。",
"error.settings_keep_rule_fieldname_invalid": "无效的保留规则:规则 #%d 缺少合法的字段名(可选:%s)",
"error.settings_keep_rule_invalid_regex": "无效的保留规则:规则 #%d 的模式字符不是合法的正则表达式",
"error.settings_keep_rule_regex_required": "无效的保留规则规则 #%d 的模式字符没有提供",
"error.settings_keep_rule_separator_required": "无效的保留规则规则 #%d 的模式字符必须用‘=’分开",
"error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区。",
"error.settings_media_playback_rate_range": "播放速度超出范围",
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
"error.site_url_not_empty": "源网站的网址不能为空。",
"error.subscription_not_found": "找不到任何源",
"error.title_required": "必须填写标题",
"error.site_url_not_empty": "站点 URL 不能为空。",
"error.subscription_not_found": "无法找到任何订阅源。",
"error.title_required": "必须填写标题",
"error.tls_error": "TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭 TLS 验证。",
"error.unable_to_create_api_key": "无法创建此 API 密钥。",
"error.unable_to_create_category": "无法建立这个分类",
"error.unable_to_create_user": "无法创建此用户",
"error.unable_to_detect_rssbridge": "无法使用 RSS-Bridge 检测订阅源: %v。",
"error.unable_to_parse_feed": "无法解析订阅源: %v。",
"error.unable_to_update_category": "无法更新分类",
"error.unable_to_update_feed": "无法更新此",
"error.unable_to_update_user": "无法更新此用户",
"error.unable_to_create_category": "无法创建此分类",
"error.unable_to_create_user": "无法创建此用户",
"error.unable_to_detect_rssbridge": "无法使用 RSS-Bridge 检测订阅源%v。",
"error.unable_to_parse_feed": "无法解析订阅源%v。",
"error.unable_to_update_category": "无法更新分类",
"error.unable_to_update_feed": "无法更新此订阅源。",
"error.unable_to_update_user": "无法更新此用户",
"error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
"error.user_already_exists": "用户已存在",
"error.user_mandatory_fields": "必须填写用户名",
"error.user_already_exists": "用户已存在",
"error.user_mandatory_fields": "必须填写用户名",
"form.api_key.label.description": "API 密钥标签",
"form.category.hide_globally": "隐藏全局未读列表中的文章",
"form.category.hide_globally": "全局未读列表中隐藏条目",
"form.category.label.title": "标题",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.general": "常规",
"form.feed.fieldset.integration": "第三方服务",
"form.feed.fieldset.network_settings": "网络设置",
"form.feed.fieldset.rules": "规则",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.feed.label.blocklist_rules": "阻止规则",
"form.feed.label.category": "类别",
"form.feed.label.cookie": "设置 Cookies",
"form.feed.label.crawler": "抓取全文内容",
"form.feed.label.block_filter_entry_rules": "条目屏蔽规则",
"form.feed.label.blocklist_rules": "基于正则表达式的屏蔽过滤器",
"form.feed.label.category": "分类",
"form.feed.label.cookie": "设置 Cookie",
"form.feed.label.crawler": "获取原始内容",
"form.feed.label.description": "描述",
"form.feed.label.disable_http2": "关闭 HTTP/2 避免记录指纹",
"form.feed.label.disabled": "请勿刷新此",
"form.feed.label.feed_password": "源密码",
"form.feed.label.disable_http2": "禁用 HTTP/2 避免指纹识别",
"form.feed.label.disabled": "刷新此订阅",
"form.feed.label.feed_password": "订阅源密码",
"form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.feed_username": "源用户名",
"form.feed.label.feed_username": "订阅源用户名",
"form.feed.label.fetch_via_proxy": "使用在应用程序级别配置的代理",
"form.feed.label.hide_globally": "隐藏全局未读列表中的文章",
"form.feed.label.hide_globally": "全局未读列表中隐藏条目",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.keeplist_rules": "保留规则",
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
"form.feed.label.ntfy_activate": "推送条目到ntfy",
"form.feed.label.keep_filter_entry_rules": "条目允许规则",
"form.feed.label.keeplist_rules": "基于正则表达式的保留过滤器",
"form.feed.label.no_media_player": "无媒体播放器(音频/视频)",
"form.feed.label.ntfy_activate": "推送条目到 Ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy 默认优先级",
"form.feed.label.ntfy_high_priority": "Ntfy 高优先级",
"form.feed.label.ntfy_low_priority": "Ntfy 低优先级",
@@ -187,34 +191,34 @@
"form.feed.label.ntfy_min_priority": "Ntfy 最低优先级",
"form.feed.label.ntfy_priority": "Ntfy 优先级",
"form.feed.label.ntfy_topic": "Ntfy 主题(可选)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.pushover_activate": "将条目推送至 pushover.net",
"form.feed.label.proxy_url": "代理 URL",
"form.feed.label.pushover_activate": "推送条目到 Pushover",
"form.feed.label.pushover_default_priority": "Pushover 默认优先级",
"form.feed.label.pushover_high_priority": "Pushover 高优先级",
"form.feed.label.pushover_low_priority": "Pushover 低优先级",
"form.feed.label.pushover_max_priority": "Pushover 最高优先级",
"form.feed.label.pushover_min_priority": "Pushover 最低优先级",
"form.feed.label.pushover_priority": "Pushover 消息优先级",
"form.feed.label.rewrite_rules": "重写规则",
"form.feed.label.rewrite_rules": "内容重写规则",
"form.feed.label.scraper_rules": "抓取规则",
"form.feed.label.site_url": "源网站 URL",
"form.feed.label.site_url": "站 URL",
"form.feed.label.title": "标题",
"form.feed.label.urlrewrite_rules": "URL 重写规则",
"form.feed.label.user_agent": "覆盖默认的用户代理",
"form.feed.label.webhook_url": "覆盖 webhook url",
"form.feed.label.webhook_url": "覆盖 Webhook URL",
"form.import.label.file": "OPML 文件",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "将新文章推送到 Apprise",
"form.integration.apprise_activate": "将新条目推送到 Apprise",
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.betula_activate": "保存文章到 Betula",
"form.integration.betula_activate": "保存条目到 Betula",
"form.integration.betula_token": "Betula 令牌",
"form.integration.betula_url": "Betula 服务地址",
"form.integration.cubox_activate": "保存文章到 Cubox",
"form.integration.betula_url": "Betula 服务端 URL",
"form.integration.cubox_activate": "保存条目到 Cubox",
"form.integration.cubox_api_link": "Cubox API 链接",
"form.integration.discord_activate": "将新文章推送到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook link",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.discord_activate": "推送条目到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook 链接",
"form.integration.espial_activate": "保存条目到 Espial",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_tags": "Espial 标签",
@@ -223,294 +227,297 @@
"form.integration.fever_password": "Fever 密码",
"form.integration.fever_username": "Fever 用户名",
"form.integration.googlereader_activate": "启用 Google Reader API",
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
"form.integration.googlereader_endpoint": "Google Reader API 端点",
"form.integration.googlereader_password": "Google Reader 密码",
"form.integration.googlereader_username": "Google Reader 用户名",
"form.integration.instapaper_activate": "保存文章到 Instapaper",
"form.integration.instapaper_activate": "保存条目到 Instapaper",
"form.integration.instapaper_password": "Instapaper 密码",
"form.integration.instapaper_username": "Instapaper 用户名",
"form.integration.linkace_activate": "保存文章到 LinkAce",
"form.integration.karakeep_activate": "保存条目到 Karakeep",
"form.integration.karakeep_api_key": "Karakeep API 密钥",
"form.integration.karakeep_url": "Karakeep API 端点",
"form.integration.linkace_activate": "保存条目到 LinkAce",
"form.integration.linkace_api_key": "LinkAce API 密钥",
"form.integration.linkace_check_disabled": "关闭链接检查",
"form.integration.linkace_endpoint": "LinkAce API URL",
"form.integration.linkace_check_disabled": "禁用链接检查",
"form.integration.linkace_endpoint": "LinkAce API 端点",
"form.integration.linkace_is_private": "将链接标记为私有",
"form.integration.linkace_tags": "LinkAce 标签",
"form.integration.linkding_activate": "保存文章到 Linkding",
"form.integration.linkding_activate": "保存条目到 Linkding",
"form.integration.linkding_api_key": "Linkding API 密钥",
"form.integration.linkding_bookmark": "标记为未读",
"form.integration.linkding_bookmark": "将书签标记为未读",
"form.integration.linkding_endpoint": "Linkding API 端点",
"form.integration.linkding_tags": "Linkding 默认标签",
"form.integration.linkwarden_activate": "保存文章到 Linkwarden",
"form.integration.linkding_tags": "Linkding 标签",
"form.integration.linkwarden_activate": "保存条目到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 密钥",
"form.integration.linkwarden_endpoint": "Linkwarden API 端点",
"form.integration.matrix_bot_activate": "将新文章推送到 Matrix",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
"form.integration.matrix_bot_activate": "推送新条目到 Matrix",
"form.integration.matrix_bot_chat_id": "Matrix 房间 ID",
"form.integration.matrix_bot_password": "Matrix 用户密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_user": "Matrix Bot 用户名",
"form.integration.notion_activate": "保存文章到 Notion",
"form.integration.notion_page_id": "Notion 页面ID",
"form.integration.notion_token": "Notion 令牌",
"form.integration.ntfy_activate": "推送条目到 ntfy",
"form.integration.ntfy_api_token": "Ntfy API令牌(可选)",
"form.integration.ntfy_icon_url": "Ntfy 图标 URL (可选)",
"form.integration.matrix_bot_user": "Matrix 用户名",
"form.integration.notion_activate": "保存条目到 Notion",
"form.integration.notion_page_id": "Notion 页面 ID",
"form.integration.notion_token": "Notion 密钥令牌",
"form.integration.ntfy_activate": "推送条目到 Ntfy",
"form.integration.ntfy_api_token": "Ntfy API 令牌(可选)",
"form.integration.ntfy_icon_url": "Ntfy 图标 URL(可选)",
"form.integration.ntfy_internal_links": "点击时使用内部链接(可选)",
"form.integration.ntfy_password": "Ntfy 密码(可选)",
"form.integration.ntfy_topic": "Ntfy 主题(預設,如果未在此源中定義",
"form.integration.ntfy_topic": "Ntfy 主题(如果订阅源中未设置则使用默认值",
"form.integration.ntfy_url": "Ntfy URL(可选,默认为 ntfy.sh",
"form.integration.ntfy_username": "Ntfy 用户名(可选)",
"form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
"form.integration.nunux_keeper_activate": "保存条目到 Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端点",
"form.integration.omnivore_activate": "保存文章到 Omnivore",
"form.integration.omnivore_activate": "保存条目到 Omnivore",
"form.integration.omnivore_api_key": "Omnivore API 密钥",
"form.integration.omnivore_url": "Omnivore API 端点",
"form.integration.pinboard_activate": "保存文章到 Pinboard",
"form.integration.pinboard_bookmark": "标记为未读",
"form.integration.pinboard_activate": "保存条目到 Pinboard",
"form.integration.pinboard_bookmark": "将书签标记为未读",
"form.integration.pinboard_tags": "Pinboard 标签",
"form.integration.pinboard_token": "Pinboard API 令牌",
"form.integration.pocket_access_token": "Pocket 访问令牌",
"form.integration.pocket_activate": "将文章保存到 Pocket",
"form.integration.pocket_connect_link": "连接您的 Pocket 帐户",
"form.integration.pocket_consumer_key": "Pocket 用户密钥",
"form.integration.pushover_activate": "将条目推送至 Pushover",
"form.integration.pushover_device": "Pushover 装置(可选)",
"form.integration.pushover_activate": "推送条目到 Pushover",
"form.integration.pushover_device": "Pushover 设备(可选)",
"form.integration.pushover_prefix": "Pushover URL 前缀(可选)",
"form.integration.pushover_token": "Pushover 应用程序 API 令牌",
"form.integration.pushover_token": "Pushover 应用 API 令牌",
"form.integration.pushover_user": "Pushover 用户密钥",
"form.integration.raindrop_activate": "保存文章到 Raindrop",
"form.integration.raindrop_activate": "保存条目到 Raindrop",
"form.integration.raindrop_collection_id": "集合 ID",
"form.integration.raindrop_tags": "Tags (逗号分隔)",
"form.integration.raindrop_token": "(Test) 令牌",
"form.integration.readeck_activate": "保存文章到 Readeck",
"form.integration.raindrop_tags": "标签(逗号分隔)",
"form.integration.raindrop_token": "(测试)令牌",
"form.integration.readeck_activate": "保存条目到 Readeck",
"form.integration.readeck_api_key": "Readeck API 密钥",
"form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_labels": "Readeck 默认标签",
"form.integration.readeck_only_url": "仅发送 URL(而不是完整内容)",
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
"form.integration.readeck_labels": "Readeck 标签",
"form.integration.readeck_only_url": "仅发送 URL(而完整内容)",
"form.integration.readwise_activate": "保存条目到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader 访问令牌",
"form.integration.readwise_api_key_link": "获取你的 Readwise 访问令牌",
"form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge",
"form.integration.rssbridge_token": "RSS-Bridge 认证令牌",
"form.integration.rssbridge_url": "RSS-Bridge 服务器 URL",
"form.integration.shaarli_activate": "保存文章到 Shaarli",
"form.integration.shaarli_activate": "保存条目到 Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API 密钥",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shiori_activate": "保存文章到 Shiori",
"form.integration.shiori_activate": "保存条目到 Shiori",
"form.integration.shiori_endpoint": "Shiori API 端点",
"form.integration.shiori_password": "Shiori 密码",
"form.integration.shiori_username": "Shiori 用户名",
"form.integration.slack_activate": "将新文章推送到 Slack",
"form.integration.slack_webhook_link": "Slack Webhook link",
"form.integration.telegram_bot_activate": "将新文章推送到 Telegram",
"form.integration.telegram_bot_disable_buttons": "不展示按钮",
"form.integration.slack_activate": "推送条目到 Slack",
"form.integration.slack_webhook_link": "Slack Webhook 链接",
"form.integration.telegram_bot_activate": "推送新条目到 Telegram 聊天",
"form.integration.telegram_bot_disable_buttons": "禁用按钮",
"form.integration.telegram_bot_disable_notification": "禁用通知",
"form.integration.telegram_bot_disable_web_page_preview": "禁用网页预览",
"form.integration.telegram_bot_token": "机器人令牌",
"form.integration.telegram_chat_id": "聊天 ID",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.wallabag_activate": "保存文章到 Wallabag",
"form.integration.telegram_topic_id": "主题 ID",
"form.integration.wallabag_activate": "保存条目到 Wallabag",
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥",
"form.integration.wallabag_endpoint": "Wallabag 基 URL",
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
"form.integration.wallabag_client_secret": "Wallabag 客户端密钥",
"form.integration.wallabag_endpoint": "Wallabag 基 URL",
"form.integration.wallabag_only_url": "仅发送 URL(而完整内容)",
"form.integration.wallabag_password": "Wallabag 密码",
"form.integration.wallabag_username": "Wallabag 用户名",
"form.integration.webhook_activate": "启用 Webhooks",
"form.integration.webhook_secret": "Webhooks 密钥",
"form.integration.webhook_url": "Default Webhook URL",
"form.integration.webhook_url": "默认 Webhook URL",
"form.prefs.fieldset.application_settings": "应用设置",
"form.prefs.fieldset.authentication_settings": "用户认证设置",
"form.prefs.fieldset.authentication_settings": "认证设置",
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
"form.prefs.fieldset.reader_settings": "阅读器设置",
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
"form.prefs.label.always_open_external_links": "打开外部链接阅读条目",
"form.prefs.label.categories_sorting_order": "分类排序",
"form.prefs.label.cjk_reading_speed": "中文、韩文和日文的阅读速度(每分钟字符数)",
"form.prefs.label.custom_css": "自定义 CSS",
"form.prefs.label.custom_js": "自定义 JavaScript",
"form.prefs.label.default_home_page": "默认主页",
"form.prefs.label.default_reading_speed": "其他语言的阅读速度(每分钟字数)",
"form.prefs.label.display_mode": "渐进式网络应用程序 (PWA) 显示模式",
"form.prefs.label.entries_per_page": "每页文章数",
"form.prefs.label.entry_order": "文章排序依据",
"form.prefs.label.entry_sorting": "文章排序",
"form.prefs.label.entry_swipe": "在触摸屏上启用输入滑动",
"form.prefs.label.external_font_hosts": "外部字体托管",
"form.prefs.label.gesture_nav": "在条目间导航的手势",
"form.prefs.label.display_mode": "渐进式网络应用程序(PWA)显示模式",
"form.prefs.label.entries_per_page": "每页条目数",
"form.prefs.label.entry_order": "条目排序字段",
"form.prefs.label.entry_sorting": "条目排序",
"form.prefs.label.entry_swipe": "在触摸屏上启用条目滑动",
"form.prefs.label.external_font_hosts": "外部字体主机",
"form.prefs.label.gesture_nav": "在条目间导航的手势",
"form.prefs.label.keyboard_shortcuts": "启用键盘快捷键",
"form.prefs.label.language": "语言",
"form.prefs.label.mark_read_manually": "手动标记条目为已读",
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成90%%时标记为已读",
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成 90%% 时标记为已读",
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成90%%时标记为已读",
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成 90%% 时标记为已读",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
"form.prefs.label.open_external_links_in_new_tab": "在新标签页中打开外部链接(为链接添加 target=\"_blank\"",
"form.prefs.label.show_reading_time": "显示条目的预计阅读时间",
"form.prefs.label.theme": "主题",
"form.prefs.label.timezone": "时区",
"form.prefs.select.alphabetical": "字母顺序",
"form.prefs.select.alphabetical": "字母顺序",
"form.prefs.select.browser": "浏览器",
"form.prefs.select.created_time": "文章创建时间",
"form.prefs.select.created_time": "条目创建时间",
"form.prefs.select.fullscreen": "全屏",
"form.prefs.select.minimal_ui": "最小",
"form.prefs.select.none": "没有任何",
"form.prefs.select.older_first": "旧->新",
"form.prefs.select.publish_time": "文章发布时间",
"form.prefs.select.publish_time": "条目发布时间",
"form.prefs.select.recent_first": "新->旧",
"form.prefs.select.standalone": "独立",
"form.prefs.select.swipe": "滑动",
"form.prefs.select.tap": "双击",
"form.prefs.select.unread_count": "未读计数",
"form.submit.loading": "载中…",
"form.submit.loading": "载中…",
"form.submit.saving": "保存中…",
"form.user.label.admin": "管理员",
"form.user.label.confirmation": "再次输入密码",
"form.user.label.confirmation": "确认密码",
"form.user.label.password": "密码",
"form.user.label.username": "用户名",
"menu.about": "关于",
"menu.add_feed": "新增源",
"menu.add_user": "新建用户",
"menu.add_feed": "添加订阅源",
"menu.add_user": "添加用户",
"menu.api_keys": "API 密钥",
"menu.categories": "分类",
"menu.create_api_key": "创建一个新的 API 密钥",
"menu.create_category": "建分类",
"menu.create_api_key": "创建 API 密钥",
"menu.create_category": "建分类",
"menu.edit_category": "编辑",
"menu.edit_feed": "编辑",
"menu.export": "导出",
"menu.feed_entries": "文章",
"menu.feeds": "源",
"menu.flush_history": "清历史",
"menu.history": "历史",
"menu.home_page": "页",
"menu.feed_entries": "条目",
"menu.feeds": "订阅源",
"menu.flush_history": "清历史记录",
"menu.history": "历史记录",
"menu.home_page": "页",
"menu.import": "导入",
"menu.integrations": "集成",
"menu.logout": "登出",
"menu.mark_all_as_read": "全部标为已读",
"menu.mark_page_as_read": "标为已读",
"menu.preferences": "设置",
"menu.refresh_all_feeds": "后台更新全部源",
"menu.refresh_feed": "新",
"menu.mark_page_as_read": "将此页标为已读",
"menu.preferences": "偏好设置",
"menu.refresh_all_feeds": "后台刷新所有订阅源",
"menu.refresh_feed": "新",
"menu.search": "搜索",
"menu.sessions": "会话",
"menu.settings": "设置",
"menu.shared_entries": "已享的文章",
"menu.show_all_entries": "显示所有文章",
"menu.show_only_starred_entries": "仅显示已收藏文章",
"menu.show_only_unread_entries": "仅显示未读文章",
"menu.shared_entries": "已享的条目",
"menu.show_all_entries": "显示所有条目",
"menu.show_only_starred_entries": "仅显示已收藏条目",
"menu.show_only_unread_entries": "仅显示未读条目",
"menu.starred": "收藏",
"menu.title": "菜单",
"menu.unread": "未读",
"menu.users": "用户",
"page.about.author": "作者:",
"page.about.build_date": "构建日期:",
"page.about.credits": "版权",
"page.about.db_usage": "数据库容量",
"page.about.credits": "鸣谢",
"page.about.db_usage": "数据库大小:",
"page.about.git_commit": "Git 提交:",
"page.about.global_config_options": "全局配置选项",
"page.about.go_version": "Go 版本",
"page.about.license": "协议",
"page.about.postgres_version": "Postgres 版本",
"page.about.go_version": "Go 版本:",
"page.about.license": "许可证",
"page.about.postgres_version": "Postgres 版本:",
"page.about.title": "关于",
"page.about.version": "版本",
"page.add_feed.choose_feed": "选择一个源",
"page.add_feed.label.url": "网址",
"page.about.version": "版本:",
"page.add_feed.choose_feed": "选择订阅源",
"page.add_feed.label.url": "URL",
"page.add_feed.legend.advanced_options": "高级选项",
"page.add_feed.no_category": "没有类别,至少需要有一个类别",
"page.add_feed.submit": "查找源",
"page.add_feed.title": "新源",
"page.api_keys.never_used": "没用过",
"page.add_feed.no_category": "没有分类。您必须至少有一个分类。",
"page.add_feed.submit": "查找订阅源",
"page.add_feed.title": "新建订阅源",
"page.api_keys.never_used": "从未使用",
"page.api_keys.table.actions": "操作",
"page.api_keys.table.created_at": "创建日期",
"page.api_keys.table.description": "描述",
"page.api_keys.table.last_used_at": "最后使用",
"page.api_keys.table.token": "令牌",
"page.api_keys.title": "API 密钥",
"page.categories_count": [
"%d 分类"
],
"page.categories.entries": "查看内容",
"page.categories.entries": "条目",
"page.categories.feed_count": [
"有 %d 个源"
"有 %d 个订阅源"
],
"page.categories.feeds": "查看源",
"page.categories.no_feed": "没有源",
"page.categories.feeds": "订阅源",
"page.categories.no_feed": "无订阅源。",
"page.categories.title": "分类",
"page.categories_count": [
"%d 个分类"
],
"page.category_label": "分类: %s",
"page.edit_category.title": "编辑分类 : %s",
"page.edit_category.title": "编辑分类%s",
"page.edit_feed.etag_header": "ETag 标题:",
"page.edit_feed.last_check": "最后检查时间:",
"page.edit_feed.last_modified_header": "最后修改的 Header",
"page.edit_feed.last_parsing_error": "最后一次解析错误",
"page.edit_feed.no_header": "无 Header",
"page.edit_feed.title": "编辑源 : %s",
"page.edit_user.title": "编辑用户 : %s",
"page.edit_feed.title": "编辑订阅源: %s",
"page.edit_user.title": "编辑用户: %s",
"page.entry.attachments": "附件",
"page.feeds.error_count": [
"%d 错误"
],
"page.feeds.last_check": "最后检查时间",
"page.feeds.next_check": "下次检查时间",
"page.feeds.read_counter": "已读文章数",
"page.feeds.title": "源",
"page.history.title": "历史",
"page.feeds.last_check": "最后检查:",
"page.feeds.next_check": "下次检查:",
"page.feeds.read_counter": "已读条目数",
"page.feeds.title": "订阅源",
"page.history.title": "历史记录",
"page.import.title": "导入",
"page.integration.bookmarklet": "书签小应用",
"page.integration.bookmarklet.help": "你可以打开这个特殊的书签直接收藏网站",
"page.integration.bookmarklet.instructions": "拖动这个链接到浏览器书签栏",
"page.integration.bookmarklet.name": "收藏 Miniflux",
"page.integration.bookmarklet.help": "此链接允许您通过浏览器书签直接订阅网站",
"page.integration.bookmarklet.instructions": "将此链接拖动到您的书签栏",
"page.integration.bookmarklet.name": "添加到 Miniflux",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API 端点",
"page.integration.miniflux_api_password": "密码",
"page.integration.miniflux_api_password_value": "您账的密码",
"page.integration.miniflux_api_password_value": "您账的密码",
"page.integration.miniflux_api_username": "用户名",
"page.integrations.title": "集成",
"page.keyboard_shortcuts.close_modal": "关闭对话窗口",
"page.keyboard_shortcuts.download_content": "抓取全文内容",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_categories": "打开分类页面",
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
"page.keyboard_shortcuts.go_to_feeds": "打开源页面",
"page.keyboard_shortcuts.go_to_history": "打开历史页面",
"page.keyboard_shortcuts.go_to_next_item": "下一文章",
"page.keyboard_shortcuts.go_to_next_page": "下一页",
"page.keyboard_shortcuts.go_to_previous_item": "上一文章",
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
"page.keyboard_shortcuts.go_to_search": "将焦点放在搜索表单上",
"page.keyboard_shortcuts.go_to_settings": "打开设置页面",
"page.keyboard_shortcuts.go_to_starred": "打开收藏页面",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.go_to_unread": "打开未读页面",
"page.keyboard_shortcuts.mark_page_as_read": "标记当前页已读",
"page.keyboard_shortcuts.download_content": "下载原始内容",
"page.keyboard_shortcuts.go_to_bottom_item": "转到最后一条",
"page.keyboard_shortcuts.go_to_categories": "转到分类",
"page.keyboard_shortcuts.go_to_feed": "转到订阅源",
"page.keyboard_shortcuts.go_to_feeds": "转到订阅源列表",
"page.keyboard_shortcuts.go_to_history": "转到历史记录",
"page.keyboard_shortcuts.go_to_next_item": "转到下一条目",
"page.keyboard_shortcuts.go_to_next_page": "转到下一页",
"page.keyboard_shortcuts.go_to_previous_item": "转到上一条目",
"page.keyboard_shortcuts.go_to_previous_page": "转到上一页",
"page.keyboard_shortcuts.go_to_search": "聚焦到搜索框",
"page.keyboard_shortcuts.go_to_settings": "转到设置",
"page.keyboard_shortcuts.go_to_starred": "转到收藏",
"page.keyboard_shortcuts.go_to_top_item": "转到第一条",
"page.keyboard_shortcuts.go_to_unread": "转到未读",
"page.keyboard_shortcuts.mark_page_as_read": "标记当前页已读",
"page.keyboard_shortcuts.open_comments": "打开评论链接",
"page.keyboard_shortcuts.open_comments_same_window": "在当前标签页中打开评论链接",
"page.keyboard_shortcuts.open_item": "打开选定的文章",
"page.keyboard_shortcuts.open_item": "打开选定的条目",
"page.keyboard_shortcuts.open_original": "打开原始链接",
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
"page.keyboard_shortcuts.refresh_all_feeds": "在后台新全部源",
"page.keyboard_shortcuts.remove_feed": "除此源",
"page.keyboard_shortcuts.save_article": "保存文章",
"page.keyboard_shortcuts.refresh_all_feeds": "在后台新全部订阅源",
"page.keyboard_shortcuts.remove_feed": "除此订阅源",
"page.keyboard_shortcuts.save_article": "保存条目",
"page.keyboard_shortcuts.scroll_item_to_top": "滚动到顶部",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "显示快捷键帮助",
"page.keyboard_shortcuts.subtitle.actions": "操作",
"page.keyboard_shortcuts.subtitle.items": "文章导航",
"page.keyboard_shortcuts.subtitle.items": "条目导航",
"page.keyboard_shortcuts.subtitle.pages": "页面导航",
"page.keyboard_shortcuts.subtitle.sections": "区导航",
"page.keyboard_shortcuts.title": "快捷键",
"page.keyboard_shortcuts.subtitle.sections": "区导航",
"page.keyboard_shortcuts.title": "键盘快捷键",
"page.keyboard_shortcuts.toggle_bookmark_status": "切换收藏状态",
"page.keyboard_shortcuts.toggle_entry_attachments": "展开/折叠文章附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态, 关注下一",
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态, 关注前一个",
"page.keyboard_shortcuts.toggle_entry_attachments": "切换展开/折叠条目附件",
"page.keyboard_shortcuts.toggle_read_status_next": "切换已读/未读状态,并切换到下一",
"page.keyboard_shortcuts.toggle_read_status_prev": "切换已读/未读状态,并切换到上一项",
"page.login.google_signin": "使用 Google 登录",
"page.login.oidc_signin": "使用 %s 登录",
"page.login.title": "登录",
"page.login.webauthn_login": "使用密码登录",
"page.login.webauthn_login.error": "无法使用密码登录",
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入用户名。",
"page.login.webauthn_login": "使用通行密钥登录",
"page.login.webauthn_login.error": "无法使用通行密钥登录",
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入。",
"page.new_api_key.title": "新的 API 密钥",
"page.new_category.title": "新分类",
"page.new_user.title": "新用户",
"page.new_category.title": "新分类",
"page.new_user.title": "新用户",
"page.offline.message": "您已离线",
"page.offline.refresh_page": "尝试刷新页面",
"page.offline.title": "离线模式",
"page.read_entry_count": [
"%d 阅读文章"
"%d 个已读条目"
],
"page.search.title": "搜索结果",
"page.sessions.table.actions": "操作",
@@ -519,52 +526,52 @@
"page.sessions.table.ip": "IP 地址",
"page.sessions.table.user_agent": "用户代理",
"page.sessions.title": "会话",
"page.settings.link_google_account": "关联我的 Google 账",
"page.settings.link_oidc_account": "关联我的 %s 账",
"page.settings.link_google_account": "关联我的 Google 账",
"page.settings.link_oidc_account": "关联我的 %s 账",
"page.settings.title": "设置",
"page.settings.unlink_google_account": "解除 Google 账号关联",
"page.settings.unlink_oidc_account": "解除 %s 账号关联",
"page.settings.webauthn.actions": "操作",
"page.settings.webauthn.added_on": "添加时间",
"page.settings.webauthn.added_on": "添加",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey"
"删除 %d 个通行密钥"
],
"page.settings.webauthn.last_seen_on": "最后使用时间",
"page.settings.webauthn.passkey_name": "Passkey 名称",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "注册 Passkey",
"page.settings.webauthn.register.error": "无法注册 Passkey",
"page.settings.webauthn.last_seen_on": "最后使用",
"page.settings.webauthn.passkey_name": "通行密钥名称",
"page.settings.webauthn.passkeys": "通行密钥",
"page.settings.webauthn.register": "注册通行密钥",
"page.settings.webauthn.register.error": "无法注册通行密钥",
"page.shared_entries.title": "已共享的条目",
"page.shared_entries_count": [
"%d 已分享的文章"
],
"page.shared_entries.title": "已分享的文章",
"page.starred_entry_count": [
"%d 收藏的文章"
"%d 个共享条目"
],
"page.starred.title": "收藏",
"page.total_entry_count": [
"%d 文章总数"
"page.starred_entry_count": [
"%d 个收藏条目"
],
"page.unread_entry_count": [
"%d 未读的文章"
"page.total_entry_count": [
"%d 个条目"
],
"page.unread.title": "未读",
"page.unread_entry_count": [
"%d 个未读条目"
],
"page.users.actions": "操作",
"page.users.admin.no": "否",
"page.users.admin.yes": "是",
"page.users.is_admin": "管理员",
"page.users.last_login": "最后登录时间",
"page.users.never_logged": "从未登录",
"page.users.last_login": "最后登录",
"page.users.never_logged": "从未",
"page.users.title": "用户",
"page.users.username": "用户名",
"page.webauthn_rename.title": "重命名 Passkey",
"page.webauthn_rename.title": "重命名通行密钥",
"pagination.first": "第一页",
"pagination.last": "最后一页",
"pagination.next": "下一页",
"pagination.previous": "上一页",
"search.label": "搜索",
"search.placeholder": "搜索…",
"search.submit": "查找",
"search.submit": "搜索",
"skip_to_content": "跳转至内容",
"time_elapsed.days": [
"%d 天前"
@@ -587,6 +594,6 @@
"%d 年前"
],
"time_elapsed.yesterday": "昨天",
"tooltip.keyboard_shortcuts": "快捷键: %s",
"tooltip.logged_user": "当前登录 %s"
}
"tooltip.keyboard_shortcuts": "键盘快捷键%s",
"tooltip.logged_user": "登录用户:%s"
}
+30 -23
View File
@@ -27,7 +27,6 @@
"alert.no_tag_entry": "沒有與此標籤相符的文章。",
"alert.no_unread_entry": "目前沒有未讀文章",
"alert.no_user": "您是唯一的使用者",
"alert.pocket_linked": "您的 Pocket 帳戶已關聯",
"alert.prefs_saved": "設定已儲存!",
"alert.too_many_feeds_refresh": [
"您已觸發過太多次 Feed 更新,請等待 %d 分鐘後再嘗試。"
@@ -75,6 +74,9 @@
"entry.status.toast.read": "已標記為已讀",
"entry.status.toast.unread": "已標記為未讀",
"entry.tags.label": "標籤:",
"entry.tags.more_tags_label": [
"還有 %d 個標籤"
],
"entry.unshare.label": "取消分享",
"error.api_key_already_exists": "此 API 金鑰已存在。",
"error.bad_credentials": "使用者名稱或密碼無效",
@@ -112,10 +114,12 @@
"error.http_service_unavailable": "此網站目前因內部問題無法使用,問題不在 Miniflux,請稍後重試。",
"error.http_too_many_requests": "Miniflux 對此網站的請求過多,請稍後重試或調整程式設定。",
"error.http_unexpected_status_code": "此網站回應了意外的 HTTP 狀態碼:%d,請稍後重試。",
"error.invalid_categories_sorting_order": "無效的分類排序",
"error.invalid_default_home_page": "預設主頁無效!",
"error.invalid_display_mode": "無效的顯示模式。",
"error.invalid_entry_direction": "無效的輸入方向。",
"error.invalid_feed_proxy_url": "Invalid proxy URL.",
"error.invalid_entry_order": "無效的文章排序依據。",
"error.invalid_feed_proxy_url": "代理伺服器網址無效。",
"error.invalid_feed_url": "訂閱網址無效。",
"error.invalid_gesture_nav": "手勢導覽無效。",
"error.invalid_language": "無效的語言。",
@@ -125,9 +129,7 @@
"error.network_operation": "Miniflux 無法連線到該網站,可能是網路問題:%v。",
"error.network_timeout": "該網站回應過慢,請求逾時:%v。",
"error.password_min_length": "請至少輸入 6 個字元",
"error.pocket_access_token": "無法從 Pocket 取得存取金鑰!",
"error.pocket_request_token": "無法從 Pocket 取得請求金鑰!",
"error.proxy_url_not_empty": "The proxy URL cannot be empty.",
"error.proxy_url_not_empty": "代理伺服器網址不能為空。",
"error.settings_block_rule_fieldname_invalid": "無效的封鎖規則:規則 #%d 缺少有效的欄位名稱 (可用選項:%s)",
"error.settings_block_rule_invalid_regex": "無效的封鎖規則:規則 #%d 的模式不是合法的正規表示式",
"error.settings_block_rule_regex_required": "無效的封鎖規則:規則 #%d 沒有提供正規表示式",
@@ -164,7 +166,8 @@
"form.feed.fieldset.rules": "規則",
"form.feed.label.allow_self_signed_certificates": "允許自簽或無效的憑證",
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務網址列表",
"form.feed.label.blocklist_rules": "過濾規則",
"form.feed.label.block_filter_entry_rules": "條目封鎖規則",
"form.feed.label.blocklist_rules": "基於正則表達式的封鎖過濾器",
"form.feed.label.category": "類別",
"form.feed.label.cookie": "設定 Cookies",
"form.feed.label.crawler": "下載原文內容",
@@ -177,7 +180,8 @@
"form.feed.label.fetch_via_proxy": "使用應用程式層級設定的代理",
"form.feed.label.hide_globally": "在全域未讀列表中隱藏文章",
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
"form.feed.label.keeplist_rules": "保留規則",
"form.feed.label.keep_filter_entry_rules": "條目允許規則",
"form.feed.label.keeplist_rules": "基於正則表達式的保留過濾器",
"form.feed.label.no_media_player": "無媒體播放器 (音訊/視訊)",
"form.feed.label.ntfy_activate": "推送文章到 ntfy",
"form.feed.label.ntfy_default_priority": "Ntfy 預設優先順序",
@@ -187,28 +191,28 @@
"form.feed.label.ntfy_min_priority": "Ntfy 最低優先順序",
"form.feed.label.ntfy_priority": "Ntfy 優先順序",
"form.feed.label.ntfy_topic": "Ntfy topic (選填)",
"form.feed.label.proxy_url": "Proxy URL",
"form.feed.label.proxy_url": "代理URL",
"form.feed.label.pushover_activate": "Push entries to pushover.net",
"form.feed.label.pushover_default_priority": "Pushover default priority",
"form.feed.label.pushover_high_priority": "Pushover high priority",
"form.feed.label.pushover_low_priority": "Pushover low priority",
"form.feed.label.pushover_max_priority": "Pushover max priority",
"form.feed.label.pushover_min_priority": "Pushover min priority",
"form.feed.label.pushover_priority": "Pushover message priority",
"form.feed.label.rewrite_rules": "重寫規則",
"form.feed.label.pushover_priority": "Pushover消息優先級",
"form.feed.label.rewrite_rules": "內容重寫規則",
"form.feed.label.scraper_rules": "抓取規則",
"form.feed.label.site_url": "網站網址",
"form.feed.label.title": "標題",
"form.feed.label.urlrewrite_rules": "網址重寫規則",
"form.feed.label.user_agent": "覆蓋預設的使用者代理",
"form.feed.label.webhook_url": "Override webhook url",
"form.feed.label.webhook_url": "覆蓋webhook URL",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.apprise_activate": "推送文章到 Apprise",
"form.integration.apprise_services_url": "使用逗號分隔的 Apprise 服務網址列表",
"form.integration.apprise_url": "Apprise API 網址",
"form.integration.betula_activate": "儲存文章到 Betula",
"form.integration.betula_token": "Betula Token",
"form.integration.betula_token": "Betula令牌",
"form.integration.betula_url": "Betula 伺服器網址",
"form.integration.cubox_activate": "儲存文章到 Cubox",
"form.integration.cubox_api_link": "Cubox API 連結",
@@ -229,6 +233,9 @@
"form.integration.instapaper_activate": "儲存文章到 Instapaper",
"form.integration.instapaper_password": "Instapaper 密碼",
"form.integration.instapaper_username": "Instapaper 使用者名稱",
"form.integration.karakeep_activate": "儲存文章到 Karakeep",
"form.integration.karakeep_api_key": "Karakeep API 金鑰",
"form.integration.karakeep_url": "Karakeep API 端點",
"form.integration.linkace_activate": "儲存文章到 LinkAce",
"form.integration.linkace_api_key": "LinkAce API 金鑰",
"form.integration.linkace_check_disabled": "停用連結檢查",
@@ -242,7 +249,7 @@
"form.integration.linkding_tags": "Linkding 標籤",
"form.integration.linkwarden_activate": "儲存文章到 Linkwarden",
"form.integration.linkwarden_api_key": "Linkwarden API 金鑰",
"form.integration.linkwarden_endpoint": "Linkwarden API 端點",
"form.integration.linkwarden_endpoint": "Linkwarden 基本 URL",
"form.integration.matrix_bot_activate": "推送文章到 Matrix",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.matrix_bot_password": "Matrix 密碼",
@@ -269,10 +276,6 @@
"form.integration.pinboard_bookmark": "標記為未讀",
"form.integration.pinboard_tags": "Pinboard 標籤",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pocket_access_token": "Pocket 存取金鑰",
"form.integration.pocket_activate": "儲存文章到 Pocket",
"form.integration.pocket_connect_link": "連線您的 Pocket 帳戶",
"form.integration.pocket_consumer_key": "Pocket 使用者金鑰",
"form.integration.pushover_activate": "Push entries to Pushover",
"form.integration.pushover_device": "Pushover device (optional)",
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
@@ -291,6 +294,7 @@
"form.integration.readwise_api_key": "Readwise Reader 存取金鑰",
"form.integration.readwise_api_key_link": "取得您的 Readwise 存取金鑰",
"form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge",
"form.integration.rssbridge_token": "RSS-Bridge authentication token",
"form.integration.rssbridge_url": "RSS-Bridge 伺服器的網址",
"form.integration.shaarli_activate": "儲存文章到 Shaarli",
"form.integration.shaarli_api_secret": "Shaarli API 金鑰",
@@ -323,6 +327,7 @@
"form.prefs.fieldset.global_feed_settings": "全域 Feed 設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.prefs.help.external_font_hosts": "以空白分隔允許的外部字型來源。例如:「fonts.gstatic.com fonts.googleapis.com」。",
"form.prefs.label.always_open_external_links": "Read articles by opening external links",
"form.prefs.label.categories_sorting_order": "分類排序",
"form.prefs.label.cjk_reading_speed": "中文、韓文和日文的閱讀速度(每分鐘字元數)",
"form.prefs.label.custom_css": "自訂 CSS",
@@ -343,6 +348,7 @@
"form.prefs.label.mark_read_on_view": "檢視時自動將文章標記為已讀",
"form.prefs.label.mark_read_on_view_or_media_completion": "檢視文章即標記為已讀;若是音訊/視訊則在 90% 播放完成時標記",
"form.prefs.label.media_playback_rate": "音訊/視訊播放速度",
"form.prefs.label.open_external_links_in_new_tab": "在新分頁中開啟外部連結(為連結加上 target=\"_blank\"",
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.theme": "主題",
"form.prefs.label.timezone": "時區",
@@ -403,6 +409,7 @@
"page.about.build_date": "建構日期:",
"page.about.credits": "版權",
"page.about.db_usage": "Database size:",
"page.about.git_commit": "Git Commit:",
"page.about.global_config_options": "全域設定選項",
"page.about.go_version": "Go 版本:",
"page.about.license": "授權:",
@@ -422,9 +429,6 @@
"page.api_keys.table.last_used_at": "最後使用",
"page.api_keys.table.token": "金鑰",
"page.api_keys.title": "API 金鑰",
"page.categories_count": [
"%d 個分類"
],
"page.categories.entries": "檢視內容",
"page.categories.feed_count": [
"有 %d 個 Feed"
@@ -432,6 +436,9 @@
"page.categories.feeds": "檢視 Feeds",
"page.categories.no_feed": "沒有 Feed",
"page.categories.title": "分類",
"page.categories_count": [
"%d 個分類"
],
"page.category_label": "分類:%s",
"page.edit_category.title": "編輯分類 : %s",
"page.edit_feed.etag_header": "ETag 標頭:",
@@ -534,21 +541,21 @@
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [
"已分享 %d 篇文章"
],
"page.shared_entries.title": "已分享的文章",
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d 篇收藏文章"
],
"page.starred.title": "收藏",
"page.total_entry_count": [
"總共 %d 篇文章"
],
"page.unread.title": "未讀",
"page.unread_entry_count": [
"%d 篇未讀文章"
],
"page.unread.title": "未讀",
"page.users.actions": "操作",
"page.users.admin.no": "否",
"page.users.admin.yes": "是",
+196 -54
View File
@@ -14,9 +14,9 @@ import (
func TestProxyFilterWithHttpDefault(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "http-only")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -39,8 +39,8 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
func TestProxyFilterWithHttpsDefault(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "http-only")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("MEDIA_PROXY_MODE", "http-only")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
@@ -63,7 +63,7 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
func TestProxyFilterWithHttpNever(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "none")
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewParser()
@@ -86,7 +86,7 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
func TestProxyFilterWithHttpsNever(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "none")
os.Setenv("MEDIA_PROXY_MODE", "none")
var err error
parser := config.NewParser()
@@ -109,9 +109,9 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
func TestProxyFilterWithHttpAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -134,9 +134,9 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
func TestProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -159,9 +159,9 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -221,9 +221,9 @@ func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "audio")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "audio")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -246,9 +246,9 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_URL", "https://proxy-example/proxy")
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()
@@ -271,9 +271,9 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
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")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://:8080example.com")
var err error
parser := config.NewParser()
@@ -321,8 +321,8 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
func TestProxyFilterWithHttpInvalid(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "invalid")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -345,8 +345,8 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
func TestProxyFilterWithHttpsInvalid(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "invalid")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "invalid")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -369,9 +369,9 @@ func TestProxyFilterWithHttpsInvalid(t *testing.T) {
func TestProxyFilterWithSrcset(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -394,9 +394,9 @@ func TestProxyFilterWithSrcset(t *testing.T) {
func TestProxyFilterWithEmptySrcset(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -419,9 +419,9 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
func TestProxyFilterWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -444,9 +444,9 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "https")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "https")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -469,8 +469,8 @@ func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
func TestProxyWithImageDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
@@ -493,8 +493,8 @@ func TestProxyWithImageDataURL(t *testing.T) {
func TestProxyWithImageSourceDataURL(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
var err error
parser := config.NewParser()
@@ -517,9 +517,9 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
func TestProxyFilterWithVideo(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "video")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "video")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -542,9 +542,9 @@ func TestProxyFilterWithVideo(t *testing.T) {
func TestProxyFilterVideoPoster(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -567,9 +567,9 @@ func TestProxyFilterVideoPoster(t *testing.T) {
func TestProxyFilterVideoPosterOnce(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image,video")
os.Setenv("PROXY_PRIVATE_KEY", "test")
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,video")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
@@ -589,3 +589,145 @@ func TestProxyFilterVideoPosterOnce(t *testing.T) {
t.Errorf(`Not expected output: got %s`, output)
}
}
func TestShouldProxifyURLWithMimeType(t *testing.T) {
testCases := []struct {
name string
mediaURL string
mediaMimeType string
mediaProxyOption string
mediaProxyResourceTypes []string
expected bool
}{
{
name: "Empty URL should not be proxified",
mediaURL: "",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "Data URL should not be proxified",
mediaURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
mediaMimeType: "image/png",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "HTTP URL with all mode and matching MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTPS URL with all mode and matching MIME type should be proxified",
mediaURL: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTP URL with http-only mode and matching MIME type should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "HTTPS URL with http-only mode should not be proxified",
mediaURL: "https://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with none mode should not be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with matching MIME type should be proxified",
mediaURL: "http://example.com/video.mp4",
mediaMimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"video"},
expected: true,
},
{
name: "URL with non-matching MIME type should not be proxified",
mediaURL: "http://example.com/video.mp4",
mediaMimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: false,
},
{
name: "URL with multiple resource types and matching MIME type should be proxified",
mediaURL: "http://example.com/audio.mp3",
mediaMimeType: "audio/mp3",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image", "audio", "video"},
expected: true,
},
{
name: "URL with multiple resource types but non-matching MIME type should not be proxified",
mediaURL: "http://example.com/document.pdf",
mediaMimeType: "application/pdf",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image", "audio", "video"},
expected: false,
},
{
name: "URL with empty resource types should not be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{},
expected: false,
},
{
name: "URL with partial MIME type match should be proxified",
mediaURL: "http://example.com/image.jpg",
mediaMimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expected: true,
},
{
name: "URL with audio MIME type and audio resource type should be proxified",
mediaURL: "http://example.com/song.ogg",
mediaMimeType: "audio/ogg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio"},
expected: true,
},
{
name: "URL with video MIME type and video resource type should be proxified",
mediaURL: "http://example.com/movie.webm",
mediaMimeType: "video/webm",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"video"},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ShouldProxifyURLWithMimeType(tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
if result != tc.expected {
t.Errorf("Expected %v, got %v for URL: %s, MIME type: %s, proxy option: %s, resource types: %v",
tc.expected, result, tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
}
})
}
}
+35 -9
View File
@@ -41,7 +41,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
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) {
if shouldProxifyURL(srcAttrValue, proxyOption) {
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
@@ -54,7 +54,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") {
doc.Find("video").Each(func(i int, video *goquery.Selection) {
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxy(posterAttrValue, proxyOption) {
if shouldProxifyURL(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
@@ -64,7 +64,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
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) {
if shouldProxifyURL(srcAttrValue, proxyOption) {
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
@@ -73,13 +73,13 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
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) {
if shouldProxifyURL(srcAttrValue, proxyOption) {
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
}
}
if posterAttrValue, ok := video.Attr("poster"); ok {
if shouldProxy(posterAttrValue, proxyOption) {
if shouldProxifyURL(posterAttrValue, proxyOption) {
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
}
@@ -99,7 +99,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates {
if shouldProxy(imageCandidate.ImageURL, proxyOption) {
if shouldProxifyURL(imageCandidate.ImageURL, proxyOption) {
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
}
}
@@ -107,7 +107,33 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
element.SetAttr("srcset", imageCandidates.String())
}
func shouldProxy(attrValue, proxyOption string) bool {
return !strings.HasPrefix(attrValue, "data:") &&
(proxyOption == "all" || !urllib.IsHTTPS(attrValue))
// shouldProxifyURL checks if the media URL should be proxified based on the media proxy option and URL scheme.
func shouldProxifyURL(mediaURL, mediaProxyOption string) bool {
switch {
case mediaURL == "":
return false
case strings.HasPrefix(mediaURL, "data:"):
return false
case mediaProxyOption == "all":
return true
case mediaProxyOption != "none" && !urllib.IsHTTPS(mediaURL):
return true
default:
return false
}
}
// ShouldProxifyURLWithMimeType checks if the media URL should be proxified based on the media proxy option, URL scheme, and MIME type.
func ShouldProxifyURLWithMimeType(mediaURL, mediaMimeType, mediaProxyOption string, mediaProxyResourceTypes []string) bool {
if !shouldProxifyURL(mediaURL, mediaProxyOption) {
return false
}
for _, mediaType := range mediaProxyResourceTypes {
if strings.HasPrefix(mediaMimeType, mediaType+"/") {
return true
}
}
return false
}
+11 -17
View File
@@ -5,28 +5,22 @@ package model // import "miniflux.app/v2/internal/model"
import (
"time"
"miniflux.app/v2/internal/crypto"
)
// APIKey represents an application API key.
type APIKey struct {
ID int64
UserID int64
Token string
Description string
LastUsedAt *time.Time
CreatedAt time.Time
}
// NewAPIKey initializes a new APIKey.
func NewAPIKey(userID int64, description string) *APIKey {
return &APIKey{
UserID: userID,
Token: crypto.GenerateRandomString(32),
Description: description,
}
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// APIKeys represents a collection of API Key.
type APIKeys []*APIKey
// APIKeyCreationRequest represents the request to create a new API Key.
type APIKeyCreationRequest struct {
Description string `json:"description"`
}
+1 -3
View File
@@ -19,13 +19,12 @@ type SessionData struct {
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
Theme string `json:"theme"`
PocketRequestToken string `json:"pocket_request_token"`
LastForceRefresh string `json:"last_force_refresh"`
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
}
func (s *SessionData) String() string {
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, PocketTkn=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
s.CSRF,
s.OAuth2State,
s.OAuth2CodeVerifier,
@@ -33,7 +32,6 @@ func (s *SessionData) String() string {
s.FlashErrorMessage,
s.Language,
s.Theme,
s.PocketRequestToken,
s.LastForceRefresh,
s.WebAuthnSessionData,
)
+20 -33
View File
@@ -7,9 +7,8 @@ import (
"strings"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/urllib"
)
// Enclosure represents an attachment.
@@ -45,8 +44,18 @@ func (e *Enclosure) IsVideo() bool {
func (e *Enclosure) IsImage() bool {
mimeType := strings.ToLower(e.MimeType)
if strings.HasPrefix(mimeType, "image/") {
return true
}
mediaURL := strings.ToLower(e.URL)
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
return strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
}
// ProxifyEnclosureURL modifies the enclosure URL to use the media proxy if necessary.
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) {
if mediaproxy.ShouldProxifyURLWithMimeType(e.URL, e.MimeType, mediaProxyOption, mediaProxyResourceTypes) {
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
}
}
// EnclosureList represents a list of attachments.
@@ -55,8 +64,10 @@ type EnclosureList []*Enclosure
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
for _, enclosure := range el {
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
return enclosure
if enclosure.URL != "" {
if enclosure.IsAudio() || enclosure.IsVideo() {
return enclosure
}
}
}
@@ -65,39 +76,15 @@ func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
func (el EnclosureList) ContainsAudioOrVideo() bool {
for _, enclosure := range el {
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
if enclosure.IsAudio() || enclosure.IsVideo() {
return true
}
}
return false
}
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption != "none" {
for i := range el {
if urllib.IsHTTPS(el[i].URL) {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
if strings.HasPrefix(el[i].MimeType, mediaType+"/") {
el[i].URL = mediaproxy.ProxifyAbsoluteURL(router, el[i].URL)
break
}
}
}
}
}
}
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router) {
proxyOption := config.Opts.MediaProxyMode()
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(e.URL) {
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
if strings.HasPrefix(e.MimeType, mediaType+"/") {
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
break
}
}
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) {
for _, enclosure := range el {
enclosure.ProxifyEnclosureURL(router, mediaProxyOption, mediaProxyResourceTypes)
}
}
+558 -1
View File
@@ -4,7 +4,12 @@
package model
import (
"net/http"
"os"
"testing"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {
@@ -26,8 +31,560 @@ func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *tes
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
// https://www.florenceporcel.com/podcast/lfhdu.xml
t.Fatalf(
"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in brownser. Got '%s'",
"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'",
enclosure.Html5MimeType(),
)
}
}
func TestEnclosure_IsAudio(t *testing.T) {
testCases := []struct {
name string
mimeType string
expected bool
}{
{"MP3 audio", "audio/mpeg", true},
{"WAV audio", "audio/wav", true},
{"OGG audio", "audio/ogg", true},
{"Mixed case audio", "Audio/MP3", true},
{"Video file", "video/mp4", false},
{"Image file", "image/jpeg", false},
{"Text file", "text/plain", false},
{"Empty mime type", "", false},
{"Audio with extra info", "audio/mpeg; charset=utf-8", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType}
if got := enclosure.IsAudio(); got != tc.expected {
t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
}
})
}
}
func TestEnclosure_IsVideo(t *testing.T) {
testCases := []struct {
name string
mimeType string
expected bool
}{
{"MP4 video", "video/mp4", true},
{"AVI video", "video/avi", true},
{"WebM video", "video/webm", true},
{"M4V video", "video/m4v", true},
{"Mixed case video", "Video/MP4", true},
{"Audio file", "audio/mpeg", false},
{"Image file", "image/jpeg", false},
{"Text file", "text/plain", false},
{"Empty mime type", "", false},
{"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType}
if got := enclosure.IsVideo(); got != tc.expected {
t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
}
})
}
}
func TestEnclosure_IsImage(t *testing.T) {
testCases := []struct {
name string
mimeType string
url string
expected bool
}{
{"JPEG image by mime", "image/jpeg", "http://example.com/file", true},
{"PNG image by mime", "image/png", "http://example.com/file", true},
{"GIF image by mime", "image/gif", "http://example.com/file", true},
{"Mixed case image mime", "Image/JPEG", "http://example.com/file", true},
{"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true},
{"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true},
{"PNG file extension", "unknown/type", "http://example.com/photo.png", true},
{"GIF file extension", "binary/data", "http://example.com/photo.gif", true},
{"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true},
{"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true},
{"Video file", "video/mp4", "http://example.com/video.mp4", false},
{"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false},
{"Text file", "text/plain", "http://example.com/file.txt", false},
{"No extension", "text/plain", "http://example.com/file", false},
{"Other extension", "text/plain", "http://example.com/file.pdf", false},
{"Empty values", "", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url}
if got := enclosure.IsImage(); got != tc.expected {
t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url)
}
})
}
}
func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) {
testCases := []struct {
name string
enclosures EnclosureList
expectedNil bool
}{
{
name: "Returns first audio enclosure",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
expectedNil: false,
},
{
name: "Returns first video enclosure",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Skips image enclosure and returns audio",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Skips enclosure with empty URL",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
},
expectedNil: false,
},
{
name: "Returns nil for no media enclosures",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
},
expectedNil: true,
},
{
name: "Returns nil for empty list",
enclosures: EnclosureList{},
expectedNil: true,
},
{
name: "Returns nil for all empty URLs",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "", MimeType: "video/mp4"},
},
expectedNil: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.enclosures.FindMediaPlayerEnclosure()
if tc.expectedNil {
if result != nil {
t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result)
}
} else {
if result == nil {
t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil")
} else if !result.IsAudio() && !result.IsVideo() {
t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType)
}
}
})
}
}
func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) {
testCases := []struct {
name string
enclosures EnclosureList
expected bool
}{
{
name: "Contains audio",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/mpeg"},
&Enclosure{MimeType: "image/jpeg"},
},
expected: true,
},
{
name: "Contains video",
enclosures: EnclosureList{
&Enclosure{MimeType: "image/jpeg"},
&Enclosure{MimeType: "video/mp4"},
},
expected: true,
},
{
name: "Contains both audio and video",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/mpeg"},
&Enclosure{MimeType: "video/mp4"},
},
expected: true,
},
{
name: "Contains only images",
enclosures: EnclosureList{
&Enclosure{MimeType: "image/jpeg"},
&Enclosure{MimeType: "image/png"},
},
expected: false,
},
{
name: "Contains only documents",
enclosures: EnclosureList{
&Enclosure{MimeType: "application/pdf"},
&Enclosure{MimeType: "text/plain"},
},
expected: false,
},
{
name: "Empty list",
enclosures: EnclosureList{},
expected: false,
},
{
name: "Single audio enclosure",
enclosures: EnclosureList{
&Enclosure{MimeType: "audio/wav"},
},
expected: true,
},
{
name: "Single video enclosure",
enclosures: EnclosureList{
&Enclosure{MimeType: "video/webm"},
},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.enclosures.ContainsAudioOrVideo()
if result != tc.expected {
t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected)
}
})
}
}
func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
router := mux.NewRouter()
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
testCases := []struct {
name string
url string
mimeType string
mediaProxyOption string
mediaProxyResourceTypes []string
expectedURLChanged bool
}{
{
name: "HTTP URL with audio type - proxy mode all",
url: "http://example.com/audio.mp3",
mimeType: "audio/mpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTPS URL with video type - proxy mode all",
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTP URL with video type - proxy mode http-only",
url: "http://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: true,
},
{
name: "HTTPS URL with video type - proxy mode http-only",
url: "https://example.com/video.mp4",
mimeType: "video/mp4",
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "HTTP URL with image type - not in resource types",
url: "http://example.com/image.jpg",
mimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "HTTP URL with image type - in resource types",
url: "http://example.com/image.jpg",
mimeType: "image/jpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video", "image"},
expectedURLChanged: true,
},
{
name: "HTTP URL - proxy mode none",
url: "http://example.com/audio.mp3",
mimeType: "audio/mpeg",
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "Empty URL",
url: "",
mimeType: "audio/mpeg",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
{
name: "Non-media MIME type",
url: "http://example.com/doc.pdf",
mimeType: "application/pdf",
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedURLChanged: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
enclosure := &Enclosure{
URL: tc.url,
MimeType: tc.mimeType,
}
originalURL := enclosure.URL
// Call the method
enclosure.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
// Check if URL changed as expected
urlChanged := enclosure.URL != originalURL
if urlChanged != tc.expectedURLChanged {
t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s",
urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL)
}
// If URL should have changed, verify it's not empty
if tc.expectedURLChanged && enclosure.URL == "" {
t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected")
}
// If URL shouldn't have changed, verify it's identical
if !tc.expectedURLChanged && enclosure.URL != originalURL {
t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL)
}
})
}
}
func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
router := mux.NewRouter()
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
testCases := []struct {
name string
enclosures EnclosureList
mediaProxyOption string
mediaProxyResourceTypes []string
expectedChangedCount int
}{
{
name: "Mixed enclosures with all proxy mode",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
&Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 2, // audio and video should be proxified
},
{
name: "Mixed enclosures with http-only proxy mode",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
&Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "http-only",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 2, // only HTTP URLs should be proxified
},
{
name: "No media types in resource list",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"image"},
expectedChangedCount: 0, // no matching resource types
},
{
name: "Proxy mode none",
enclosures: EnclosureList{
&Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "none",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 0,
},
{
name: "Empty enclosure list",
enclosures: EnclosureList{},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 0,
},
{
name: "Enclosures with empty URLs",
enclosures: EnclosureList{
&Enclosure{URL: "", MimeType: "audio/mpeg"},
&Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
},
mediaProxyOption: "all",
mediaProxyResourceTypes: []string{"audio", "video"},
expectedChangedCount: 1, // only the non-empty URL should be processed
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Store original URLs
originalURLs := make([]string, len(tc.enclosures))
for i, enclosure := range tc.enclosures {
originalURLs[i] = enclosure.URL
}
// Call the method
tc.enclosures.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes)
// Count how many URLs actually changed
changedCount := 0
for i, enclosure := range tc.enclosures {
if enclosure.URL != originalURLs[i] {
changedCount++
// Verify that changed URLs are not empty (unless they were empty originally)
if originalURLs[i] != "" && enclosure.URL == "" {
t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i)
}
}
}
if changedCount != tc.expectedChangedCount {
t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount)
}
})
}
}
func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
// Initialize config for testing
os.Clearenv()
os.Setenv("BASE_URL", "http://localhost")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Config parsing failure: %v`, err)
}
router := mux.NewRouter()
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
t.Run("Empty resource types slice", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL(router, "all", []string{})
// With empty resource types, URL should not change
if enclosure.URL != originalURL {
t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
t.Run("Nil resource types slice", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL(router, "all", nil)
// With nil resource types, URL should not change
if enclosure.URL != originalURL {
t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
t.Run("Invalid proxy mode", func(t *testing.T) {
enclosure := &Enclosure{
URL: "http://example.com/audio.mp3",
MimeType: "audio/mpeg",
}
originalURL := enclosure.URL
enclosure.ProxifyEnclosureURL(router, "invalid-mode", []string{"audio"})
// With invalid proxy mode, the function still proxifies non-HTTPS URLs
// because shouldProxifyURL defaults to checking URL scheme
if enclosure.URL == originalURL {
t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL)
}
})
}
+24 -10
View File
@@ -37,9 +37,10 @@ type Feed struct {
ParsingErrorCount int `json:"parsing_error_count"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
Crawler bool `json:"crawler"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
@@ -52,12 +53,13 @@ type Feed struct {
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
PushoverEnabled bool `json:"pushover_enabled"`
NtfyEnabled bool `json:"ntfy_enabled"`
Crawler bool `json:"crawler"`
AppriseServiceURLs string `json:"apprise_service_urls"`
WebhookURL string `json:"webhook_url"`
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
NtfyTopic string `json:"ntfy_topic"`
PushoverEnabled bool `json:"pushover_enabled"`
PushoverPriority int `json:"pushover_priority"`
ProxyURL string `json:"proxy_url"`
@@ -162,13 +164,15 @@ type FeedCreationRequest struct {
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
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"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
@@ -189,8 +193,10 @@ type FeedModificationRequest struct {
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
UrlRewriteRules *string `json:"urlrewrite_rules"`
KeeplistRules *string `json:"keeplist_rules"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
@@ -233,18 +239,26 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.RewriteRules = *f.RewriteRules
}
if f.KeeplistRules != nil {
feed.KeeplistRules = *f.KeeplistRules
}
if f.UrlRewriteRules != nil {
feed.UrlRewriteRules = *f.UrlRewriteRules
}
if f.KeeplistRules != nil {
feed.KeeplistRules = *f.KeeplistRules
}
if f.BlocklistRules != nil {
feed.BlocklistRules = *f.BlocklistRules
}
if f.BlockFilterEntryRules != nil {
feed.BlockFilterEntryRules = *f.BlockFilterEntryRules
}
if f.KeepFilterEntryRules != nil {
feed.KeepFilterEntryRules = *f.KeepFilterEntryRules
}
if f.Crawler != nil {
feed.Crawler = *f.Crawler
}
+4 -3
View File
@@ -41,9 +41,6 @@ type Integration struct {
EspialTags string
ReadwiseEnabled bool
ReadwiseAPIKey string
PocketEnabled bool
PocketAccessToken string
PocketConsumerKey string
TelegramBotEnabled bool
TelegramBotToken string
TelegramBotChatID string
@@ -90,9 +87,13 @@ type Integration struct {
WebhookSecret string
RSSBridgeEnabled bool
RSSBridgeURL string
RSSBridgeToken string
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
KarakeepEnabled bool
KarakeepAPIKey string
KarakeepURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
+12
View File
@@ -41,6 +41,8 @@ type User struct {
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab bool `json:"open_external_links_in_new_tab"`
}
// UserCreationRequest represents the request to create a user.
@@ -82,6 +84,8 @@ type UserModificationRequest struct {
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
AlwaysOpenExternalLinks *bool `json:"always_open_external_links"`
OpenExternalLinksInNewTab *bool `json:"open_external_links_in_new_tab"`
}
// Patch updates the User object with the modification request.
@@ -197,6 +201,14 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.KeepFilterEntryRules != nil {
user.KeepFilterEntryRules = *u.KeepFilterEntryRules
}
if u.AlwaysOpenExternalLinks != nil {
user.AlwaysOpenExternalLinks = *u.AlwaysOpenExternalLinks
}
if u.OpenExternalLinksInNewTab != nil {
user.OpenExternalLinksInNewTab = *u.OpenExternalLinksInNewTab
}
}
// UseTimezone converts last login date to the given timezone.
+3 -1
View File
@@ -75,7 +75,9 @@ func (o *oidcProvider) GetProfile(ctx context.Context, code, codeVerifier string
return nil, fmt.Errorf(`oidc: failed to parse user claims: %w`, err)
}
for _, value := range []string{userClaims.Email, userClaims.PreferredUsername, userClaims.Name, userClaims.Profile} {
// Use the first non-empty value from the claims to set the username.
// The order of preference is: preferred_username, email, name, profile.
for _, value := range []string{userClaims.PreferredUsername, userClaims.Email, userClaims.Name, userClaims.Profile} {
if value != "" {
profile.Username = value
break
+2 -6
View File
@@ -38,23 +38,19 @@ func NewProxyRotator(proxyURLs []string) (*ProxyRotator, error) {
// GetNextProxy returns the next proxy in the rotation.
func (pr *ProxyRotator) GetNextProxy() *url.URL {
pr.mutex.Lock()
defer pr.mutex.Unlock()
if len(pr.proxies) == 0 {
return nil
}
pr.mutex.Lock()
proxy := pr.proxies[pr.currentIndex]
pr.currentIndex = (pr.currentIndex + 1) % len(pr.proxies)
pr.mutex.Unlock()
return proxy
}
// HasProxies checks if there are any proxies available in the rotator.
func (pr *ProxyRotator) HasProxies() bool {
pr.mutex.Lock()
defer pr.mutex.Unlock()
return len(pr.proxies) > 0
}
+1 -1
View File
@@ -103,7 +103,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
entry.Hash = crypto.SHA256(value)
break
}
}
+4
View File
@@ -39,6 +39,10 @@ type Atom10Feed struct {
// atom:feed elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:subtitle" element is a Text construct that
// contains a human-readable description or subtitle for the feed.
Subtitle Atom10Text `xml:"http://www.w3.org/2005/Atom subtitle"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
+6 -1
View File
@@ -55,6 +55,9 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed.Title = feed.SiteURL
}
// Populate the feed description.
feed.Description = a.atomFeed.Subtitle.Body()
// Populate the feed icon.
if a.atomFeed.Icon != "" {
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
@@ -134,6 +137,8 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
if len(categories) == 0 {
categories = a.atomFeed.Categories.CategoryNames()
}
// Sort and deduplicate categories.
sort.Strings(categories)
entry.Tags = slices.Compact(categories)
@@ -149,7 +154,7 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
entry.Hash = crypto.SHA256(value)
break
}
}

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