Compare commits

..

208 Commits

Author SHA1 Message Date
Frédéric Guillot 459b1bf1ee test(icon): add tests for finding icon URLs in HTML documents 2025-08-19 20:14:34 -07:00
Frédéric Guillot fdc12bf18c fix(icon): improve logging messages in resizeIcon function 2025-08-19 20:14:34 -07:00
Frédéric Guillot 1b1ceaf8b4 test(icon): add test case to verify SVG minification with error 2025-08-19 20:14:34 -07:00
jvoisin cad40cc158 feat(icon): minify svg favicons 2025-08-19 20:14:34 -07:00
Frédéric Guillot d0e43f8682 docs: fix typos in the man page 2025-08-19 19:26:50 -07:00
Frédéric Guillot c105b14f58 test(api): add integration test for fetching categories with counters 2025-08-18 20:08:31 -07:00
Frédéric Guillot 49575c8902 test(version): add a test to enforce the version format 2025-08-18 19:51:09 -07:00
Frédéric Guillot b25f9651fe test(fetcher): add unit tests for RequestBuilder 2025-08-18 19:38:14 -07:00
Frédéric Guillot 953ea885e0 fix(mediaproxy): always forward the browser Accept header
Tumblr CDN is blocking the Accept header used to fetch feeds
2025-08-18 18:08:21 -07:00
Frédéric Guillot 9536ce7dbc fix(version): change development version naming to avoid breaking some clients
Some Miniflux clients expect a specific version format.
For example, Flux News converts the string version to an integer.

Using `Development Version` will break some clients.
2025-08-18 17:41:17 -07:00
Frédéric Guillot f6532e16f2 docs: update incorrect default value for DATABASE_MIN_CONNS in the man page 2025-08-18 17:21:16 -07:00
Frédéric Guillot 84ae1d5dc0 fix(storage): index only the first 500K characters of the article contents to avoid tsvector limits
The length of a tsvector (lexemes + positions) must be less than 1 megabyte.

We don't need to index the entire content, and we need to keep a buffer for the positions.
2025-08-17 19:32:56 -07:00
Frédéric Guillot 5403ca09f6 feat(storage): add limit parameter to ClearRemovedEntriesContent
Without the limit, this query is going to hangs forever on large
databases with millions of entries.
2025-08-17 17:39:04 -07:00
gudvinr 905d652511 refactor(ui): use request builder in media proxy handler
builder is used seemingly everywhere but media proxy uses manual
transport construction
2025-08-17 13:09:32 -07:00
Frédéric Guillot a16ac51326 refactor(storage): split DeleteContentRemovedEntries into 2 functions and fix condition
- Add `content IS NOT NULL` to avoid clearing the same entries over and over
- Create `DeleteRemovedEntriesEnclosures` and `ClearRemovedEntriesContent` that report individual counts
2025-08-16 21:19:28 -07:00
jvoisin 5c26e06780 feat(entry): keep only metadata for removed entries
This should significantly shrink the space taken by miniflux' database:

```sql
miniflux=#
SELECT
  relname, pg_size_pretty(pg_total_relation_size(relname::regclass))
FROM
  pg_catalog.pg_statio_user_tables
ORDER BY
  pg_total_relation_size(relname::regclass)
DESC;

       relname        | pg_size_pretty
----------------------+----------------
 entries              | 158 MB
 icons                | 3312 kB
 enclosures           | 1568 kB
 sessions             | 1048 kB
 feeds                | 288 kB
 feed_icons           | 72 kB
 users                | 64 kB
 user_sessions        | 64 kB
 categories           | 48 kB
 integrations         | 32 kB
 api_keys             | 32 kB
 webauthn_credentials | 24 kB
 schema_version       | 16 kB
 acme_cache           | 16 kB
(14 rows)

miniflux=#
```

This should close #3524
2025-08-16 20:57:59 -07:00
Frédéric Guillot 9e722839b5 test: skip building a temporary binary for integration tests 2025-08-16 20:56:36 -07:00
Frédéric Guillot 9e4248c7c1 fix(templates): remove non-breaking space in about page 2025-08-16 16:51:39 -07:00
Frédéric Guillot a654a5f710 feat(template): show GitHub links in about page only when tag and commit are available 2025-08-16 13:08:38 -07:00
Frédéric Guillot 6bf3b3c464 fix(version): allow build info to be set with LDFLAGS and fallback to VCS metadata when available 2025-08-16 12:41:45 -07:00
Frédéric Guillot a010544200 fix(version): be explicit when VCS info is unavailable 2025-08-16 12:21:40 -07:00
Frédéric Guillot c1af510ead feat(version): use Golang's builtin vcs feature to get commit and build date 2025-08-16 12:05:53 -07:00
Frédéric Guillot 88d9682f5f ci: update Go version to 1.25 in workflows and Dockerfile 2025-08-16 12:05:07 -07:00
gudvinr ce6cadc176 refactor(mediaproxy): use *url.URL for MEDIA_PROXY_CUSTOM_URL
Same behaviour as for HTTP_CLIENT_PROXY.
2025-08-15 18:12:44 -07:00
Julien Voisin 40fa77851c refactor(locale): avoid code duplication in Printer.Printf() function 2025-08-15 18:02:25 -07:00
jvoisin dd8011a4aa refactor(template): remove some useless attributes
When `target="_blank"` is used, it has the same effect than rel="noopener",
so we can remove the latter. Moreover, since we're already setting `<meta
name="referrer" content="no-referrer" />` in the `<head>`, there is no need to
set it on every single link in the HTML, as we're rendering everything in the
same origin.

Note that we need to keep adding those in the sanitizer, the entry content HTML
can be consumed by third-party clients via the Miniflux/GoogleReader/Fever API.

See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/noopener
and https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#integration_with_html
2025-08-15 17:55:49 -07:00
Julien Voisin 3955df43f5 perf(js): don't load webauthn,js when disabled
This saves around 1kB of data, yay.
2025-08-15 17:11:12 -07:00
Julien Voisin 6f2ec8b3e9 refactor(ui): rename the small favicons to icons
There is no need to distinguish between "Android icons" and favicons.
2025-08-15 16:37:39 -07:00
jvoisin 2fa813fe01 refactor(fetcher): use errors.New when possible instead of fmt.Errorf 2025-08-15 16:25:58 -07:00
gudvinr 7c1602c2c0 refactor(internal): fix doc comments 2025-08-15 16:24:48 -07:00
gudvinr 5e3bba9ae1 refactor(request): fix typo in big corp name 2025-08-15 16:24:48 -07:00
gudvinr ab26a4e20f refactor(config): fix typos in test 2025-08-15 16:24:48 -07:00
Julien Voisin 2c4f4e2ae6 perf(templates): removes superfluous whitespaces in two templates 2025-08-14 19:40:30 -07:00
Julien Voisin 261b72f149 feat(icon): add resizing support for webp images 2025-08-14 19:36:11 -07:00
Julien Voisin 6c60d61f36 fix(icon): use rel=apple-touch-icon instead of rel=apple-touch-icon-precomposed.png
https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html
2025-08-14 19:32:50 -07:00
Julien Voisin fa85a0eff4 feat(ui): disable OAuth routes if OAuth is disabled 2025-08-13 21:15:26 -07:00
jvoisin eb6f7f30bb feat(storage): make removed entries' status immutable
This is a first step towards "keeping only metadata for removed entries" #3524.
2025-08-12 20:17:34 -07:00
jvoisin 93fc206f42 refactor(opml): reduce indirections
Don't use a slice of pointers to opml items, when we can simply use a slice of
items instead. This should reduce the amount of memory allocations and the
number of indirections the GC has to process, speedup up the import process.

Note that this doesn't introduce any additional copies, as the only time a
slice of subscription is created, the items are created and inserted inline.
2025-08-12 19:47:47 -07:00
Julien Voisin 8bca777a6d refactor(model): remove some indirection
For small fixed-size structures, it's better to use a slice of values, instead
of a slice of pointers to values: they're stored contiguously and thus can be
iterated on quickly by the CPU, and it does remove an indirection per object
every time the GC kicks in.
2025-08-12 19:46:14 -07:00
dependabot[bot] 1e6d227e40 build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 16:49:06 -07:00
Frédéric Guillot 37d539eb42 fix(template): webauthn error alert was broken on the settings page 2025-08-11 20:01:53 -07:00
jvoisin f5188b1edc refactor(ui): don't expose webauthn routes if webauthn is disabled 2025-08-11 19:53:23 -07:00
Julien Voisin 5d9d0b2652 refactor(ui): standardize user variable naming and avoid a SQL query when only userID is used
- Use `user` everywhere, instead of sometimes `loggedUser`
- Delay the instantiation of some variables: no need to perform SQL queries for
  nothing.
- Remove a SQL query getting the whole user struct when only user.ID is used.
2025-08-11 19:48:36 -07:00
jvoisin 50c5996280 refactor: remove some fmt.Sprintf calls
fmt.Sprintf is slow, so let's get rid of it in trivial cases that are in (at
least) moderately hot paths.
2025-08-11 19:27:34 -07:00
jvoisin 884521a7dd refactor(template): use modern svg directive
https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/use
https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/xlink:href

> Warning: Since SVG 2, the xlink:href attribute is deprecated in favor of
href. See xlink:href page for more information.
2025-08-11 18:31:16 -07:00
jvoisin 3a01f8a691 perf(misc): use arrays instead of slices where possible
Arrays have a fixed size in go, while slices don't, making the former way
faster than the latter: https://go-benchmarks.com/array-vs-slice
2025-08-11 18:26:58 -07:00
jvoisin 68984da332 perf(static): minimize the SVG
Since tdewolff/minify supports SVG minimization, let's make use of it. As we
need to keep the license in the SVG because we're nice netizens, we can at
least use SPDX identifiers instead of using it verbatim.

This does save a couple of kB.
2025-08-09 15:38:43 -07:00
jvoisin 485baf9654 refactor(misc): fix a handful of TODO 2025-08-09 15:22:02 -07:00
Julien Voisin 06cbf1b3b3 fix(icon): update incorrect log messages 2025-08-09 15:20:33 -07:00
Frédéric Guillot b20a8c97b0 fix(ui): invert toast notification icons 2025-08-09 14:23:20 -07:00
Frédéric Guillot e0ca92fca4 fix(storage): revert DISTINCT in FetchJobs query 2025-08-08 16:53:52 -07:00
Frédéric Guillot 598d4d4f51 feat(storage): improve BatchBuilder logging 2025-08-08 16:34:36 -07:00
Frédéric Guillot a2229198ae feat(api): log request URI in authentication handlers 2025-08-08 14:27:23 -07:00
Frédéric Guillot 34499b887b feat: add POLLING_LIMIT_PER_HOST to limit concurrent requests per host
Each batch of feeds sent to the worker pool is now guaranteed to contain unique feed URLs.

When `POLLING_LIMIT_PER_HOST` is set, an additional limit is applied to the number of concurrent requests per hostname, helping to prevent overloading a single server.

Note: Additional requests may still be made during feed refresh. For example, to fetch feed icons or when the web scraper is enabled for a particular feed.
2025-08-08 12:33:46 -07:00
Tim Douglas a4f672b589 fix: URL detection incorrectly capturing newlines in media descriptions 2025-08-08 10:42:09 -07:00
Julien Voisin 98da7b3f22 feat(template): provide a link for the Apache 2.0 license in the about page
- Provide a link for the Apache 2.0 license
- Factorise the checks for IsAdmin
- Fix some indentation issues
2025-08-08 10:31:21 -07:00
jvoisin b4c82a4c53 perf(static): minimize images 2025-08-08 10:16:32 -07:00
Frédéric Guillot 4d7c601f6d feat(ui): add PWA app shortcuts
References:

- https://web.dev/articles/app-shortcuts
- https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts
2025-08-07 20:47:08 -07:00
Frédéric Guillot f7e672452b feat(js): force page reload to prevent stale data from bfcache
The unread page may show outdated entries when navigating back from an article, due to Chrome's back/forward cache (bfcache) restoring the page from memory.

Reference: https://web.dev/articles/bfcache
2025-08-07 19:35:16 -07:00
Frédéric Guillot 6532435db9 fix(css): align icon labels vertically in entry actions 2025-08-07 17:50:17 -07:00
jvoisin 14cd5e9c0d refactor(template): reduce the amount of code in common templates
There is no need to have templates used only used in a single file be part of
every single other ones. This should reduce a bit the resident memory
consumption of miniflux.
2025-08-07 17:36:41 -07:00
Julien Voisin 566670cc06 refactor: unexport symbols 2025-08-07 17:27:04 -07:00
dependabot[bot] a4d51b5586 build(deps): bump golang.org/x/net from 0.42.0 to 0.43.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/net/compare/v0.42.0...v0.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:25:08 -07:00
dependabot[bot] e6d01896e0 build(deps): bump github.com/tdewolff/minify/v2 from 2.23.10 to 2.23.11
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.23.10 to 2.23.11.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.23.10...v2.23.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:15:46 -07:00
dependabot[bot] b4a814a5b2 build(deps): bump golang.org/x/term from 0.33.0 to 0.34.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/term/compare/v0.33.0...v0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:14:36 -07:00
dependabot[bot] 940d94e793 build(deps): bump golang.org/x/image from 0.29.0 to 0.30.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/image/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-07 17:04:43 -07:00
Frédéric Guillot 80f48c88c7 refactor: replace interface{} with any 2025-08-05 20:26:07 -07:00
Frédéric Guillot 045f2f1747 chore(view): remove unused webauthn_js_checksum key 2025-08-05 20:20:33 -07:00
Julien Voisin 2565ff930c refactor(template): make use of template.ParseFS 2025-08-05 20:07:49 -07:00
Julien Voisin b3fce752d2 refactor: replace Sprintf with string concatenation
There is no need to have go parse the whole format string then introspect the
arguments when we an simply use string concatenation.
2025-08-05 19:47:26 -07:00
Julien Voisin 798bc4cd2d refactor(static): use a simple struct instead of two slices to store assets data and checksums
- Use a simple struct instead of two slices to store the data and the checksums
  of resources
- Remove a superfluous call to Sprintf
- Factorise presence check and data retrieval in some maps
- Size the maps when possible
2025-08-05 19:35:27 -07:00
Julien Voisin b1cbaae71c refactor(storage): simplify feed.go by using min(), inline errors, and use idiomatic conditions
- Use `min` instead of doing the comparison by hand.
- Inline error handling where it makes sense
- Invert a condition to make it more idiomatic
2025-08-05 19:25:50 -07:00
Julien Voisin 924293ee5c perf(storage): avoid heavy-weight SQL when marking feed as read
There is no need to perform a heavy-weight SQL query gathering all the
information available on a feed when we're only interested in its last check
timestamp.
2025-08-05 18:01:58 -07:00
Julien Voisin 826977bc8c perf(api): speed up markFeedAsRead by avoiding a JOIN operation 2025-08-05 17:55:15 -07:00
Julien Voisin ed0e4a667d refactor(template): reindent and merge tags in feed_list.html
- Fix some indentation
- Factorize/merge some <span>
- Remove superfluous spaces
- Reindent some nested tags
2025-08-05 17:50:09 -07:00
jvoisin 5affd78f4f refactor(reader): move the fetcher outside of a loop
There is no need to rebuilt the fetcher for every item, creating it once is
enough.
2025-08-05 17:39:23 -07:00
Frédéric Guillot d3ad460c9d Revert "feat(cookie): use SameSiteStrictMode when not using OAuth2/OIDC"
This reverts commit 135ce1d546.

People using Miniflux as PWA on Android are constantly being logged out.
2025-08-05 17:32:20 -07:00
Frédéric Guillot 0f3c04a98a test(rewrite): fix flaky test case by sorting query string keys 2025-08-05 17:31:43 -07:00
gudvinr e8e3a600b4 refactor(googlereader): remove intermediate variable 2025-08-03 13:21:40 -07:00
Julien Voisin a6ce5c92dc perf(storage): minor optimization for FetchJobs
- Replace a call to fmt.Sprintf with a concatenation
- Explicit declaration of return values in FetchJobs
- Initialize the size of FetchJobs return value to b.limit: when b.limit is
  used, which is most of the time, this avoid resizing the slice, and when it
  isn't, the size of the map is set to 0, which is equivalent to the previous
  situation anyway.
- Move a call to `request.UserID(r)` to a lower scope.
2025-08-03 13:19:14 -07:00
gudvinr 76ef8f3579 refactor(googlereader): replace Sprintf("%d") with FormatInt
see https://medium.com/swlh/bad-go-frivolous-sprintf-2ad28fedf1a0
2025-08-03 13:15:49 -07:00
Julien Voisin a43d150a27 refactor(parser): centralize seek logic and provide a hint for the compiler to eliminate a useless bound check
- Move the seeking inside of DetectFeedFormat instead of having it everywhere
  in ParseFeed
- Provide a hint for the compiler to eliminate a useless bound check in
  DetectJSONFormat, otherwise it'll check that buffer[i] is valid on every
  iteration of the loop. This shouldn't make a big difference, but oh well.
2025-08-03 12:53:10 -07:00
Frédéric Guillot 3bb965913d refactor(js): create utility functions to manage buttons state 2025-08-02 21:37:16 -07:00
Frédéric Guillot b505a63f3b refactor(js): rewrite toast notification implementation 2025-08-02 21:37:16 -07:00
Frédéric Guillot e9d9256ae2 refactor(js): rename functions to include action suffix 2025-08-02 18:44:12 -07:00
Frédéric Guillot 391792a424 refactor(js): combine handleBookmark and toggleBookmark functions 2025-08-02 18:44:12 -07:00
Frédéric Guillot d2cfca589b refactor(js): combine handleSaveEntry() and saveEntry() functions 2025-08-02 18:44:12 -07:00
Frédéric Guillot f2e34cf07f refactor(js): split openOriginalLink() into smaller functions 2025-08-02 18:44:12 -07:00
Frédéric Guillot 5c3be3e74f refactor(js): combine handleShare() and triggerWebShare() functions 2025-08-02 18:44:12 -07:00
jvoisin 546fbcff8f perf(storage): pair all SELECT true with LIMIT 1
Apparently, postgresql isn't smart enough to realize that once a true value
value is found as part of a `SELECT true`, there is no need to scan the rest of
the table, so we have to make this explicit. We could also have used the
`SELECT EXISTS(…)` construct, but it's more verbose and I don't like it.
2025-08-02 16:13:55 -07:00
Frédéric Guillot 2e28bf78bd refactor(js): improve item navigation logic in goToListItem function 2025-08-02 16:12:53 -07:00
Frédéric Guillot 52c1386450 refactor(js): enable touch handlers only on touch devices and fix various issues in WebAuthnHandler 2025-08-02 15:39:01 -07:00
Frédéric Guillot 4910f1f0f4 refactor(js): remove RequestBuilder 2025-08-02 15:14:35 -07:00
Frédéric Guillot bbe3c2ea71 refactor(js): simplify some functions using modern JavaScript 2025-08-02 14:06:18 -07:00
Frédéric Guillot b116da85a9 refactor(js): remove bootstrap.js 2025-08-02 13:41:40 -07:00
Frédéric Guillot 07246e2b59 refactor(js): improve menu handlers 2025-08-02 13:09:57 -07:00
Frédéric Guillot 62410659d5 refactor(js): code cleanup and add jshint comments 2025-08-02 12:38:29 -07:00
Frédéric Guillot 3e1a7e411c feat(js): register the service worker as JavaScript module 2025-08-02 11:26:43 -07:00
Frédéric Guillot bfbc1c88c3 feat(js): load app.js using JavaScript module
- The JS bundle has its own isolated scope
- There is no need to use IIFEs anymore (Immediately Invoked Function Expressions)
- Modules are executed after the HTML document is fully parsed, similar to `defer` attribute
- There is no need to use `DOMContentLoaded` anymore
- Module scripts inherently run in strict mode (no need to define `use strict` anymore)
2025-08-02 11:07:27 -07:00
Frédéric Guillot 50197c2be3 refactor(js): reorder functions and add comments 2025-08-01 21:56:25 -07:00
Frédéric Guillot 7a25cf5037 fix(js): handle multiple buttons in a single form when showing loading state 2025-08-01 20:53:59 -07:00
Frédéric Guillot 1ec90e34f5 refactor(js): simplify CSRF token retrieval from the document 2025-08-01 20:44:40 -07:00
Frédéric Guillot 5e07278e87 feat(ui): refresh the page when marking as read the last visible entry 2025-08-01 20:16:45 -07:00
Julien Voisin cce0e7bd29 refactor(rewrite): replaced regex-based YouTube and Invidious video ID extraction with URL parsing 2025-08-01 17:44:12 -07:00
Frédéric Guillot 1f7843e313 feat(integration): prioritize feed-level webhook URL when available when saving entries 2025-07-31 19:56:44 -07:00
Julien Voisin 181e1341e1 refactor(locale): introspect the translation files at load time
Since Go doesn't support unions, and because the translation format is a bit
wacky with the same field having multiple types, we must resort to
introspection to switch between single-item translation (for singular), and
multi-items ones (for plurals).

Previously, introspection was done at runtime, which is not only slow, but will
also only catch typing errors while trying to use the translations. The current
approach is to use a struct with a different field per possible type, and
implement a custom unmarshaller to dispatch the translations to the right one.
This should marginally reduce the memory consumption since interface-boxing
doesn't happen anymore, speed up the translations matching, and enforce proper
typing earlier. This also allows us to remove a bunch of now-useless tests.
2025-07-31 19:10:14 -07:00
jvoisin f3052eb8ed refactor(misc): make use of type constraints where possible 2025-07-31 18:59:55 -07:00
Julien Voisin 078eb39db9 refactor(config): don't check random.Read's return value
As stated in the documentation:

> Read calls io.ReadFull on Reader and crashes the program irrecoverably if an
error is returned. The default Reader uses operating system APIs that are
documented to never return an error on all but legacy Linux systems.
2025-07-31 18:03:53 -07:00
dependabot[bot] 6aeb565edc build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.23.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.22.0...v1.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-31 16:51:21 -07:00
Frédéric Guillot 0c3e251884 refactor(filter): parse and merge filters only once per refresh 2025-07-30 21:34:03 -07:00
Frédéric Guillot bfd8cb3d22 feat(ui): add icons to main menu 2025-07-30 19:12:50 -07:00
jvoisin 9eea9873b5 feat(rewrite): add a rule to remove useless heading images on phoronix 2025-07-30 18:53:04 -07:00
dependabot[bot] 240ec8bd0a build(deps): bump github.com/coreos/go-oidc/v3 from 3.14.1 to 3.15.0
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.14.1 to 3.15.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.14.1...v3.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 18:05:45 -07:00
Frédéric Guillot f3ac4dacf6 test(rewrite): add unit tests for addYoutubeVideoFromId and addInvidiousVideo functions 2025-07-29 21:51:15 -07:00
Frédéric Guillot 13986e9cc0 docs: remove ChangeLog file
Maintaining a separate ChangeLog file is redundant and error-prone,
as it largely duplicated the Git commit history without adding meaningful context.

Release notes are still available on GitHub Releases and the Miniflux website.
2025-07-26 15:24:00 -07:00
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
247 changed files with 10408 additions and 6055 deletions
+2 -2
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
go-version: stable
check-latest: true
- name: Compile binaries
env:
+2 -2
View File
@@ -33,11 +33,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
go-version: stable
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up QEMU
@@ -38,7 +38,7 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up QEMU
@@ -64,7 +64,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up QEMU
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install linters
run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0
@@ -25,10 +25,10 @@ jobs:
name: Golang Linters
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
go-version: stable
- uses: golangci/golangci-lint-action@v8
with:
args: >
@@ -43,7 +43,7 @@ jobs:
name: Commit Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Build RPM Package
@@ -29,7 +29,7 @@ jobs:
name: Build Packages Manually
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Build RPM Package
@@ -46,7 +46,7 @@ jobs:
name: Publish Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Build RPM Package
+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!")
+4 -6
View File
@@ -15,14 +15,13 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.24.x"]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
go-version: stable
- name: Run unit tests with coverage and race conditions checking
if: matrix.os == 'ubuntu-latest'
run: make test
@@ -45,16 +44,15 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
go-version: stable
- name: Install Postgres client
run: sudo apt update && sudo apt install -y postgresql-client
- name: Run integration tests
run: make integration-test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PGHOST: 127.0.0.1
PGPASSWORD: postgres
-2072
View File
File diff suppressed because it is too large Load Diff
+16 -49
View File
@@ -1,9 +1,7 @@
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
VERSION := $(shell git describe --tags --exact-match 2>/dev/null)
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
@@ -22,16 +20,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 \
@@ -44,71 +38,48 @@ export PGPASSWORD := postgres
debian-packages
miniflux:
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP)
miniflux-no-pie:
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
@ go build -ldflags=$(LD_FLAGS) -o $(APP)
linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-arm64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv7:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv6:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
freebsd-amd64:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@
@ 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 +87,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 +95,6 @@ add-string:
mv tmp "$$file"; \
done
test:
go test -cover -race -count=1 ./...
@@ -137,7 +106,6 @@ lint:
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
psql -U postgres -c 'create database miniflux_test;'
go build -o miniflux-test main.go
DATABASE_URL=$(DB_URL) \
ADMIN_USERNAME=admin \
@@ -145,7 +113,7 @@ integration-test:
CREATE_ADMIN=1 \
RUN_MIGRATIONS=1 \
LOG_LEVEL=debug \
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done
@@ -157,7 +125,6 @@ integration-test:
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ rm miniflux-test
@ psql -U postgres -c 'drop database if exists miniflux_test;'
docker-image:
+16
View File
@@ -276,6 +276,22 @@ func (c *Client) Categories() (Categories, error) {
return categories, nil
}
// CategoriesWithCounters fetches the categories with their respective feed and unread counts.
func (c *Client) CategoriesWithCounters() (Categories, error) {
body, err := c.request.Get("/v1/categories?counts=true")
if err != nil {
return nil, err
}
defer body.Close()
var categories Categories
if err := json.NewDecoder(body).Decode(&categories); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}
return categories, nil
}
// CreateCategory creates a new category.
func (c *Client) CreateCategory(title string) (*Category, error) {
body, err := c.request.Post("/v1/categories", &CategoryCreationRequest{
+4 -4
View File
@@ -45,7 +45,7 @@ func (r *request) Get(path string) (io.ReadCloser, error) {
return r.execute(http.MethodGet, path, nil)
}
func (r *request) Post(path string, data interface{}) (io.ReadCloser, error) {
func (r *request) Post(path string, data any) (io.ReadCloser, error) {
return r.execute(http.MethodPost, path, data)
}
@@ -53,7 +53,7 @@ func (r *request) PostFile(path string, f io.ReadCloser) (io.ReadCloser, error)
return r.execute(http.MethodPost, path, f)
}
func (r *request) Put(path string, data interface{}) (io.ReadCloser, error) {
func (r *request) Put(path string, data any) (io.ReadCloser, error) {
return r.execute(http.MethodPut, path, data)
}
@@ -62,7 +62,7 @@ func (r *request) Delete(path string) error {
return err
}
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
func (r *request) execute(method, path string, data any) (io.ReadCloser, error) {
if r.endpoint == "" {
return nil, ErrEmptyEndpoint
}
@@ -160,7 +160,7 @@ func (r *request) buildHeaders() http.Header {
return headers
}
func (r *request) toJSON(v interface{}) []byte {
func (r *request) toJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
log.Println("Unable to convert interface to JSON:", err)
+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
+19 -20
View File
@@ -4,24 +4,23 @@ module miniflux.app/v2
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.14.1
github.com/go-webauthn/webauthn v0.13.0
github.com/andybalholm/brotli v1.2.0
github.com/coreos/go-oidc/v3 v3.15.0
github.com/go-webauthn/webauthn v0.13.4
github.com/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.8
golang.org/x/crypto v0.39.0
golang.org/x/image v0.28.0
golang.org/x/net v0.41.0
github.com/prometheus/client_golang v1.23.0
github.com/tdewolff/minify/v2 v2.23.11
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.30.0
golang.org/x/term v0.32.0
golang.org/x/term v0.34.0
)
require (
github.com/go-webauthn/x v0.1.21 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // 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
)
@@ -29,19 +28,19 @@ 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
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
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.8.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)
go 1.24.0
+40 -40
View File
@@ -1,27 +1,27 @@
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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/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.13.0 h1:cJIL1/1l+22UekVhipziAaSgESJxokYkowUqAIsWs0Y=
github.com/go-webauthn/webauthn v0.13.0/go.mod h1:Oy9o2o79dbLKRPZWWgRIOdtBGAhKnDIaBp2PFkICRHs=
github.com/go-webauthn/x v0.1.21 h1:nFbckQxudvHEJn2uy1VEi713MeSpApoAv9eRqsb9AdQ=
github.com/go-webauthn/x v0.1.21/go.mod h1:sEYohtg1zL4An1TXIUIQ5csdmoO+WO0R4R2pGKaHYKA=
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=
@@ -37,28 +37,26 @@ 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=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.23.8 h1:tvjHzRer46kwOfpdCBCWsDblCw3QtnLJRd61pTVkyZ8=
github.com/tdewolff/minify/v2 v2.23.8/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/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE=
github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q=
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE=
github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -66,16 +64,18 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/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,8 +90,8 @@ 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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -112,8 +112,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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.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 +123,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.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/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 +134,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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -143,7 +143,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+100
View File
@@ -1262,6 +1262,14 @@ func TestGetCategoriesEndpoint(t *testing.T) {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "All")
}
if categories[0].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[0].FeedCount)
}
if categories[0].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[0].TotalUnread)
}
if categories[1].ID != category.ID {
t.Fatalf(`Invalid categoryID, got %d`, categories[0].ID)
}
@@ -1273,6 +1281,40 @@ func TestGetCategoriesEndpoint(t *testing.T) {
if categories[1].Title != "My category" {
t.Fatalf(`Invalid title, got %q instead of %q`, categories[0].Title, "My category")
}
if categories[1].FeedCount != nil {
t.Errorf(`Expected FeedCount to be nil, got %d`, *categories[1].FeedCount)
}
if categories[1].TotalUnread != nil {
t.Errorf(`Expected TotalUnread to be nil, got %d`, *categories[1].TotalUnread)
}
categories, err = regularUserClient.CategoriesWithCounters()
if err != nil {
t.Fatal(err)
}
if len(categories) != 2 {
t.Fatalf(`Invalid number of categories, got %d instead of %d`, len(categories), 1)
}
if categories[1].FeedCount == nil {
t.Fatalf(`Expected FeedCount to be not nil`)
}
if categories[1].TotalUnread == nil {
t.Fatalf(`Expected TotalUnread to be not nil`)
}
expectedCounterValue := 0
if *categories[1].FeedCount != expectedCounterValue {
t.Errorf(`Expected FeedCount to be %d, got %d`, expectedCounterValue, *categories[1].FeedCount)
}
if *categories[1].TotalUnread != expectedCounterValue {
t.Errorf(`Expected TotalUnread to be %d, got %d`, expectedCounterValue, *categories[1].TotalUnread)
}
}
func TestMarkCategoryAsReadEndpoint(t *testing.T) {
@@ -2589,6 +2631,64 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
}
}
func TestUpdateEntryRemovedStatusEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
result, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatalf(`Failed to get entries: %v`, err)
}
// First we set the entry as "removed"
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
t.Fatal(err)
}
entry, err := regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
if entry.Status != miniflux.EntryStatusRemoved {
t.Fatalf(`Invalid status, got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
}
// Then we try to set it to "unread"
if err := regularUserClient.UpdateEntries([]int64{result.Entries[0].ID}, miniflux.EntryStatusUnread); err != nil {
t.Fatal(err)
}
entry, err = regularUserClient.Entry(result.Entries[0].ID)
if err != nil {
t.Fatal(err)
}
// It should stay set to "removed"
if entry.Status != miniflux.EntryStatusRemoved {
t.Fatalf(`Modified immutable status: got %q instead of %q`, entry.Status, miniflux.EntryStatusRemoved)
}
}
func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+1
View File
@@ -143,6 +143,7 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) {
batchBuilder.WithUserID(userID)
batchBuilder.WithCategoryID(categoryID)
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
+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)
}
+3 -3
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)
}
@@ -338,7 +338,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
return
}
json.OK(w, r, map[string]interface{}{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
json.OK(w, r, map[string]any{"content": mediaproxy.RewriteDocumentWithRelativeProxyURL(h.router, entry.Content), "reading_time": entry.ReadingTime})
return
}
+2 -7
View File
@@ -76,6 +76,7 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithUserID(userID)
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
@@ -140,13 +141,7 @@ func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
feed, err := h.store.FeedByID(userID, feedID)
if err != nil {
json.NotFound(w, r)
return
}
if feed == nil {
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
+8
View File
@@ -43,6 +43,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.Debug("[API] Skipped API token authentication because no API Key has been provided",
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
next.ServeHTTP(w, r)
return
@@ -59,6 +60,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -69,6 +71,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", user.Username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
@@ -100,6 +103,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -110,6 +114,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.Bool("authentication_failed", true),
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -121,6 +126,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -138,6 +144,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
json.Unauthorized(w, r)
return
@@ -148,6 +155,7 @@ func (m *middleware) basicAuth(next http.Handler) http.Handler {
slog.String("client_ip", clientIP),
slog.String("user_agent", r.UserAgent()),
slog.String("username", username),
slog.String("request_uri", r.RequestURI),
)
m.store.SetLastLogin(user.ID)
-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
+14
View File
@@ -46,4 +46,18 @@ func runCleanupTasks(store *storage.Storage) {
metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
}
}
if enclosuresAffected, err := store.DeleteRemovedEntriesEnclosures(); err != nil {
slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
} else {
slog.Info("Deleting enclosures from removed entries completed",
slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
}
if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
} else {
slog.Info("Clearing content from removed entries completed",
slog.Int64("removed_entries_content_cleared", contentAffected))
}
}
+5 -5
View File
@@ -156,16 +156,16 @@ func Parse() {
slog.Info("The default value for DATABASE_URL is used")
}
if err := static.CalculateBinaryFileChecksums(); err != nil {
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
if err := static.GenerateBinaryBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate binary files bundle: %v", err))
}
if err := static.GenerateStylesheetsBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundles: %v", err))
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
}
if err := static.GenerateJavascriptBundles(); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundles: %v", err))
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
}
db, err := database.NewConnectionPool(
+3 -6
View File
@@ -25,6 +25,7 @@ func refreshFeeds(store *storage.Storage) {
batchBuilder.WithErrorLimit(config.Opts.PollingParsingErrorLimit())
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(config.Opts.PollingLimitPerHost())
jobs, err := batchBuilder.FetchJobs()
if err != nil {
@@ -32,13 +33,9 @@ func refreshFeeds(store *storage.Storage) {
return
}
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
nbJobs := len(jobs)
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", nbJobs),
slog.Int("batch_size", config.Opts.BatchSize()),
)
var jobQueue = make(chan model.Job, nbJobs)
slog.Info("Starting a pool of workers",
+4 -4
View File
@@ -21,6 +21,7 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
config.Opts.PollingFrequency(),
config.Opts.BatchSize(),
config.Opts.PollingParsingErrorLimit(),
config.Opts.PollingLimitPerHost(),
)
go cleanupScheduler(
@@ -29,7 +30,7 @@ func runScheduler(store *storage.Storage, pool *worker.Pool) {
)
}
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit int) {
func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSize, errorLimit, limitPerHost int) {
for range time.Tick(time.Duration(frequency) * time.Minute) {
// Generate a batch of feeds for any user that has feeds to refresh.
batchBuilder := store.NewBatchBuilder()
@@ -37,13 +38,12 @@ func feedScheduler(store *storage.Storage, pool *worker.Pool, frequency, batchSi
batchBuilder.WithErrorLimit(errorLimit)
batchBuilder.WithoutDisabledFeeds()
batchBuilder.WithNextCheckExpired()
batchBuilder.WithLimitPerHost(limitPerHost)
if jobs, err := batchBuilder.FetchJobs(); err != nil {
slog.Error("Unable to fetch jobs from database", slog.Any("error", err))
} else if len(jobs) > 0 {
slog.Info("Created a batch of feeds",
slog.Int("nb_jobs", len(jobs)),
)
slog.Debug("Feed URLs in this batch", slog.Any("feed_urls", jobs.FeedURLs()))
pool.Push(jobs)
}
}
-7
View File
@@ -1,7 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package config // import "miniflux.app/v2/internal/config"
// Opts holds parsed configuration options.
var Opts *Options
+41 -7
View File
@@ -720,7 +720,7 @@ func TestWorkerPoolSize(t *testing.T) {
}
}
func TestDefautPollingFrequencyValue(t *testing.T) {
func TestDefaultPollingFrequencyValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@@ -755,7 +755,7 @@ func TestPollingFrequency(t *testing.T) {
}
}
func TestDefautForceRefreshInterval(t *testing.T) {
func TestDefaultForceRefreshInterval(t *testing.T) {
os.Clearenv()
parser := NewParser()
@@ -825,7 +825,7 @@ func TestBatchSize(t *testing.T) {
}
}
func TestDefautPollingSchedulerValue(t *testing.T) {
func TestDefaultPollingSchedulerValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@@ -860,7 +860,7 @@ func TestPollingScheduler(t *testing.T) {
}
}
func TestDefautSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
func TestDefaultSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@@ -895,7 +895,7 @@ func TestSchedulerEntryFrequencyMaxInterval(t *testing.T) {
}
}
func TestDefautSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
func TestDefaultSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@@ -930,7 +930,7 @@ func TestSchedulerEntryFrequencyMinInterval(t *testing.T) {
}
}
func TestDefautSchedulerEntryFrequencyFactorValue(t *testing.T) {
func TestDefaultSchedulerEntryFrequencyFactorValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
@@ -1601,7 +1601,8 @@ func TestMediaProxyCustomURL(t *testing.T) {
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
if result == nil || result.String() != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
}
}
@@ -2104,3 +2105,36 @@ func TestInvalidHTTPClientProxy(t *testing.T) {
t.Fatalf(`Expected error for invalid HTTP_CLIENT_PROXY value, but got none`)
}
}
func TestDefaultPollingLimitPerHost(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 0
result := opts.PollingLimitPerHost()
if result != expected {
t.Fatalf(`Unexpected default PollingLimitPerHost value, got %v instead of %v`, result, expected)
}
}
func TestCustomPollingLimitPerHost(t *testing.T) {
os.Clearenv()
os.Setenv("POLLING_LIMIT_PER_HOST", "10")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 10
result := opts.PollingLimitPerHost()
if result != expected {
t.Fatalf(`Unexpected custom PollingLimitPerHost value, got %v instead of %v`, result, expected)
}
}
+121 -116
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 = ""
@@ -95,14 +94,17 @@ 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 {
// Opts holds parsed configuration options.
var Opts *options
// options contains configuration options.
type options struct {
HTTPS bool
logFile string
logDateTime bool
@@ -128,16 +130,17 @@ type Options struct {
cleanupArchiveUnreadDays int
cleanupArchiveBatchSize int
cleanupRemoveSessionsDays int
pollingFrequency int
forceRefreshInterval int
batchSize int
pollingScheduler string
schedulerEntryFrequencyMinInterval int
schedulerEntryFrequencyMaxInterval int
schedulerEntryFrequencyFactor int
schedulerRoundRobinMinInterval int
schedulerRoundRobinMaxInterval int
pollingFrequency int
pollingLimitPerHost int
pollingParsingErrorLimit int
pollingScheduler string
workerPoolSize int
createAdmin bool
adminUsername string
@@ -145,7 +148,7 @@ type Options struct {
mediaProxyHTTPClientTimeout int
mediaProxyMode string
mediaProxyResourceTypes []string
mediaProxyCustomURL string
mediaProxyCustomURL *url.URL
fetchBilibiliWatchTime bool
fetchNebulaWatchTime bool
fetchOdyseeWatchTime bool
@@ -184,8 +187,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,
@@ -226,7 +229,7 @@ func NewOptions() *Options {
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout,
mediaProxyMode: defaultMediaProxyMode,
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
mediaProxyCustomURL: defaultMediaProxyURL,
mediaProxyCustomURL: nil,
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
fetchBilibiliWatchTime: defaultFetchBilibiliWatchTime,
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
@@ -264,261 +267,267 @@ 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
}
// 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 {
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
}
// PollingFrequency returns the interval to refresh feeds in the background.
func (o *options) PollingFrequency() int {
return o.pollingFrequency
}
// PollingLimitPerHost returns the limit of concurrent requests per host.
// Set to zero to disable.
func (o *options) PollingLimitPerHost() int {
return o.pollingLimitPerHost
}
// PollingParsingErrorLimit returns the limit of errors when to stop polling.
func (o *options) PollingParsingErrorLimit() int {
return o.pollingParsingErrorLimit
}
// 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 {
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 the YouTube embed URL override if defined.
func (o *Options) YouTubeEmbedUrlOverride() string {
func (o *options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// YouTubeEmbedDomain returns the domain used for YouTube embeds.
func (o *Options) YouTubeEmbedDomain() string {
func (o *options) YouTubeEmbedDomain() string {
if o.youTubeEmbedDomain != "" {
return o.youTubeEmbedDomain
}
@@ -527,154 +536,154 @@ func (o *Options) YouTubeEmbedDomain() string {
// 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() *url.URL {
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
}
// 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 {
@@ -702,7 +711,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
mediaProxyPrivateKeyValue = "<binary-data>"
}
var keyValues = map[string]interface{}{
var keyValues = map[string]any{
"ADMIN_PASSWORD": redactSecretValue(o.adminPassword, redactSecret),
"ADMIN_USERNAME": o.adminUsername,
"AUTH_PROXY_HEADER": o.authProxyHeader,
@@ -760,8 +769,9 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
"POLLING_FREQUENCY": o.pollingFrequency,
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
"POLLING_FREQUENCY": o.pollingFrequency,
"POLLING_LIMIT_PER_HOST": o.pollingLimitPerHost,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
"POLLING_SCHEDULER": o.pollingScheduler,
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
@@ -784,20 +794,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) {
+34 -36
View File
@@ -16,20 +16,20 @@ import (
"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
@@ -38,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
@@ -52,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())
@@ -63,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":
@@ -135,12 +137,16 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
case "WORKER_POOL_SIZE":
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "FORCE_REFRESH_INTERVAL":
p.opts.forceRefreshInterval = parseInt(value, defaultForceRefreshInterval)
case "BATCH_SIZE":
p.opts.batchSize = parseInt(value, defaultBatchSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "POLLING_LIMIT_PER_HOST":
p.opts.pollingLimitPerHost = parseInt(value, 0)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
case "POLLING_SCHEDULER":
p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
@@ -153,8 +159,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
p.opts.schedulerRoundRobinMaxInterval = parseInt(value, defaultSchedulerRoundRobinMaxInterval)
case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_MODE":
@@ -163,12 +167,13 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
if _, err := rand.Read(randomKey); err != nil {
return fmt.Errorf("config: unable to generate random key: %w", err)
}
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_CUSTOM_URL":
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
if err != nil {
return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
}
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME":
@@ -275,9 +280,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 {
@@ -333,19 +336,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 itemValue == "" {
continue
}
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)
}
}
}
+1 -3
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,7 +27,7 @@ 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)
}
+143 -150
View File
@@ -12,8 +12,8 @@ import (
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = []func(tx *sql.Tx, driver string) error{
func(tx *sql.Tx, _ string) (err error) {
var migrations = []func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE schema_version (
version text not null
@@ -122,19 +122,16 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, driver string) (err error) {
if driver == "postgresql" {
sql := `
func(tx *sql.Tx) (err error) {
sql := `
CREATE EXTENSION IF NOT EXISTS hstore;
ALTER TABLE users ADD COLUMN extra hstore;
CREATE INDEX users_extra_idx ON users using gin(extra);
`
_, err = tx.Exec(sql)
return err
}
return nil
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE tokens (
id text not null,
@@ -146,7 +143,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
@@ -154,7 +151,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE integrations (
user_id int not null,
@@ -175,27 +172,27 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE sessions rename to user_sessions`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
DROP TABLE tokens;
@@ -209,7 +206,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN wallabag_enabled bool default 'f',
@@ -222,12 +219,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
@@ -235,7 +232,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN nunux_keeper_enabled bool default 'f',
@@ -245,17 +242,17 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN pocket_enabled bool default 'f',
@@ -265,14 +262,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN username text default '',
@@ -281,7 +278,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
@@ -290,12 +287,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
UPDATE
entries
@@ -305,17 +302,17 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
@@ -325,7 +322,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
UPDATE entries SET changed_at = published_at;
@@ -334,7 +331,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE TABLE api_keys (
id serial not null,
@@ -350,7 +347,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN share_code text not null default '';
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
@@ -358,12 +355,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
@@ -371,52 +368,52 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN blocklist_rules text not null default '',
@@ -425,12 +422,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
UPDATE entries SET created_at = published_at;
@@ -438,7 +435,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, driver string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users
ADD column stylesheet text not null default '',
@@ -449,8 +446,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
return err
}
if driver == "postgresql" {
_, err = tx.Exec(`
_, err = tx.Exec(`
DECLARE my_cursor CURSOR FOR
SELECT
id,
@@ -460,28 +456,28 @@ var migrations = []func(tx *sql.Tx, driver string) error{
FROM users
FOR UPDATE
`)
if err != nil {
if err != nil {
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
defer tx.Exec("CLOSE my_cursor")
for {
var (
userID int64
customStylesheet string
googleID string
oidcID string
)
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
if err == sql.ErrNoRows {
break
}
return err
}
_, err := tx.Exec(
`UPDATE
_, err := tx.Exec(
`UPDATE
users
SET
stylesheet=$2,
@@ -490,20 +486,17 @@ var migrations = []func(tx *sql.Tx, driver string) error{
WHERE
id=$1
`,
userID, customStylesheet, googleID, oidcID)
if err != nil {
return err
}
userID, customStylesheet, googleID, oidcID)
if err != nil {
return err
}
}
return err
},
func(tx *sql.Tx, driver string) (err error) {
if driver == "postgresql" {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
return err
}
func(tx *sql.Tx) (err error) {
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
return err
}
_, err = tx.Exec(`
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
@@ -511,7 +504,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url) WHERE length(url) < 2000;
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
@@ -519,7 +512,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE acme_cache (
key varchar(400) not null primary key,
@@ -529,13 +522,13 @@ var migrations = []func(tx *sql.Tx, driver string) error{
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
@@ -543,24 +536,24 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN telegram_bot_enabled bool default 'f',
@@ -570,7 +563,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
@@ -578,7 +571,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN googlereader_enabled bool default 'f',
@@ -588,7 +581,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN espial_enabled bool default 'f',
@@ -599,7 +592,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkding_enabled bool default 'f',
@@ -609,13 +602,13 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users
ADD COLUMN default_reading_speed int default 265,
@@ -623,25 +616,25 @@ var migrations = []func(tx *sql.Tx, driver string) error{
`)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
`)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
`)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
`)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN matrix_bot_enabled bool default 'f',
@@ -653,18 +646,18 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
`)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users RENAME double_tap TO gesture_nav;
ALTER TABLE users
@@ -674,14 +667,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
@@ -689,14 +682,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
// Delete duplicated rows
sql := `
DELETE FROM enclosures a USING enclosures b
@@ -724,12 +717,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
return nil
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN notion_enabled bool default 'f',
@@ -739,7 +732,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN readwise_enabled bool default 'f',
@@ -748,7 +741,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN apprise_enabled bool default 'f',
@@ -758,7 +751,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN shiori_enabled bool default 'f',
@@ -769,7 +762,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN shaarli_enabled bool default 'f',
@@ -779,13 +772,13 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN webhook_enabled bool default 'f',
@@ -795,7 +788,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN telegram_bot_topic_id int,
@@ -805,14 +798,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
-- Speed up has_enclosure
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
@@ -828,7 +821,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN rssbridge_enabled bool default 'f',
@@ -837,7 +830,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
CREATE TABLE webauthn_credentials (
handle bytea primary key,
@@ -855,7 +848,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
`)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN omnivore_enabled bool default 'f',
@@ -865,7 +858,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkace_enabled bool default 'f',
@@ -878,7 +871,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN linkwarden_enabled bool default 'f',
@@ -888,7 +881,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN readeck_enabled bool default 'f',
@@ -900,29 +893,29 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
// the WHERE part speed-up the request a lot
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN raindrop_enabled bool default 'f',
@@ -933,12 +926,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE users
ADD COLUMN block_filter_entry_rules text not null default '',
@@ -947,7 +940,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN betula_url text default '',
@@ -957,7 +950,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN ntfy_enabled bool default 'f',
@@ -975,22 +968,22 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN cubox_enabled bool default 'f',
@@ -999,7 +992,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN discord_enabled bool default 'f',
@@ -1008,12 +1001,12 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN slack_enabled bool default 'f',
@@ -1022,11 +1015,11 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN pushover_enabled bool default 'f',
@@ -1042,14 +1035,14 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds ADD COLUMN ntfy_topic text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE icons ADD COLUMN external_id text default '';
CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
@@ -1058,7 +1051,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
DECLARE id_cursor CURSOR FOR
SELECT
@@ -1094,22 +1087,22 @@ var migrations = []func(tx *sql.Tx, driver string) error{
return nil
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN proxy_url text default ''`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN rssbridge_token text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN always_open_external_links bool default 'f'`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
ADD COLUMN karakeep_enabled bool default 'f',
@@ -1119,11 +1112,11 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`ALTER TABLE users ADD COLUMN open_external_links_in_new_tab bool default 't'`)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations
DROP COLUMN pocket_enabled,
@@ -1133,7 +1126,7 @@ var migrations = []func(tx *sql.Tx, driver string) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx, _ string) (err error) {
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE feeds
ADD COLUMN block_filter_entry_rules text not null default '',
-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"
}
+56 -58
View File
@@ -71,12 +71,12 @@ func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType
switch s.Type {
case ReadStream:
if _, ok := tags[KeptUnreadStream]; ok {
return nil, fmt.Errorf("googlereader: %s and %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 and %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:
@@ -91,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 and %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 and %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:
@@ -200,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
@@ -230,7 +230,7 @@ func (h *handler) tokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
token := request.GoolgeReaderToken(r)
token := request.GoogleReaderToken(r)
if token == "" {
slog.Warn("[GoogleReader] User does not have token",
slog.String("client_ip", clientIP),
@@ -269,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
@@ -387,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) {
@@ -407,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
@@ -456,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,
})
}
@@ -609,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":
@@ -649,7 +649,7 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
}
}
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
@@ -669,7 +669,7 @@ func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request
return
}
OK(w, r)
sendOkayResponse(w)
}
func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Request) {
@@ -701,9 +701,9 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
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 := parseItemIDsFromRequest(r)
if err != nil {
@@ -731,19 +731,18 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
return
}
result := streamContentItems{
result := streamContentItemsResponse{
Direction: "ltr",
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"),
},
},
Self: []contentHREF{{
HREF: config.Opts.RootURL() + route.Path(h.router, "StreamItemsContents"),
}},
Author: userName,
Items: make([]contentItem, len(entries)),
}
contentItems := make([]contentItem, len(entries))
for i, entry := range entries {
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
for _, enclosure := range entry.Enclosures {
@@ -752,7 +751,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)
@@ -763,15 +762,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
}
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes())
entry.Enclosures.ProxifyEnclosureURL(h.router)
contentItems[i] = contentItem{
result.Items[i] = contentItem{
ID: convertEntryIDToLongFormItemID(entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
TimestampUsec: strconv.FormatInt(entry.Date.UnixMicro(), 10),
CrawlTimeMsec: strconv.FormatInt(entry.CreatedAt.UnixMilli(), 10),
Published: entry.Date.Unix(),
Updated: entry.ChangedAt.Unix(),
Categories: categories,
@@ -802,7 +800,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
Enclosure: enclosures,
}
}
result.Items = contentItems
json.OK(w, r, result)
}
@@ -823,9 +821,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
}
@@ -844,7 +842,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) {
@@ -863,15 +861,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
}
@@ -911,7 +909,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) {
@@ -935,13 +933,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",
})
@@ -971,13 +969,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),
})
@@ -1016,7 +1014,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)
}
@@ -1092,7 +1090,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)),
)
}
}
@@ -1279,14 +1277,14 @@ func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
return
}
stream, err := getStream(r.Form.Get(ParamStreamID), userID)
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 != "" {
if timestampParamValue := r.Form.Get(paramTimestamp); timestampParamValue != "" {
timestampParsedValue, err := strconv.ParseInt(timestampParamValue, 10, 64)
if err != nil {
json.BadRequest(w, r, err)
@@ -1340,5 +1338,5 @@ func (h *handler) markAllAsReadHandler(w http.ResponseWriter, r *http.Request) {
}
}
OK(w, r)
sendOkayResponse(w)
}
+1 -1
View File
@@ -56,7 +56,7 @@ func parseItemID(itemIDValue string) (int64, error) {
}
func parseItemIDsFromRequest(r *http.Request) ([]int64, error) {
items := r.Form[ParamItemIDs]
items := r.Form[paramItemIDs]
if len(items) == 0 {
return nil, fmt.Errorf("googlereader: no items requested")
}
+13 -13
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,7 +169,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
}
@@ -181,7 +181,7 @@ func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
ctx = context.WithValue(ctx, request.GoogleReaderToken, token)
ctx = context.WithValue(ctx, request.GoogleReaderTokenKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
+32 -32
View File
@@ -4,36 +4,36 @@
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"
// ParamStreamType - name of the parameter for unix timestamp
ParamTimestamp = "ts"
// 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"
)
+24 -24
View File
@@ -4,28 +4,28 @@
package googlereader // import "miniflux.app/v2/internal/googlereader"
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"
// 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"
)
+8 -8
View File
@@ -64,28 +64,28 @@ func parseStreamFilterFromRequest(r *http.Request) (RequestModifiers, error) {
UserID: userID,
}
streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
streamOrder := request.QueryStringParam(r, paramStreamOrder, "d")
if streamOrder == "o" {
result.SortDirection = "asc"
}
var err error
result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
result.Streams, err = getStreams(request.QueryStringParamList(r, paramStreamID), userID)
if err != nil {
return RequestModifiers{}, err
}
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, paramStreamExcludes), userID)
if err != nil {
return RequestModifiers{}, err
}
result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
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))
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
}
+26 -33
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,10 +63,10 @@ type streamIDResponse struct {
}
type tagsResponse struct {
Tags []subscriptionCategory `json:"tags"`
Tags []subscriptionCategoryResponse `json:"tags"`
}
type streamContentItems struct {
type streamContentItemsResponse struct {
Direction string `json:"direction"`
ID string `json:"id"`
Title string `json:"title"`
@@ -118,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"))
}
+16 -16
View File
@@ -72,32 +72,32 @@ func (st StreamType) String() 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)
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:
case readStreamSuffix:
return Stream{ReadStream, ""}, nil
case Starred:
case starredStreamSuffix:
return Stream{StarredStream, ""}, nil
case ReadingList:
case readingListStreamSuffix:
return Stream{ReadingListStream, ""}, nil
case KeptUnread:
case keptUnreadStreamSuffix:
return Stream{KeptUnreadStream, ""}, nil
case Broadcast:
case broadcastStreamSuffix:
return Stream{BroadcastStream, ""}, nil
case BroadcastFriends:
case broadcastFriendsStreamSuffix:
return Stream{BroadcastFriendsStream, ""}, nil
case Like:
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)
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
@@ -107,7 +107,7 @@ func getStream(streamID string, userID int64) (Stream, error) {
}
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
streams := make([]Stream, 0)
streams := make([]Stream, 0, len(streamIDs))
for _, streamID := range streamIDs {
stream, err := getStream(streamID, userID)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// FindClientIP returns the client real IP address based on trusted Reverse-Proxy HTTP headers.
func FindClientIP(r *http.Request) string {
headers := []string{"X-Forwarded-For", "X-Real-Ip"}
headers := [...]string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
+4 -4
View File
@@ -31,7 +31,7 @@ const (
FlashErrorMessageContextKey
LastForceRefreshContextKey
ClientIPContextKey
GoogleReaderToken
GoogleReaderTokenKey
WebAuthnDataContextKey
)
@@ -44,9 +44,9 @@ func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
return nil
}
// GoolgeReaderToken returns the google reader token if it exists.
func GoolgeReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderToken)
// GoogleReaderToken returns the google reader token if it exists.
func GoogleReaderToken(r *http.Request) string {
return getContextStringValue(r, GoogleReaderTokenKey)
}
// IsAdminUser checks if the logged user is administrator.
+1 -2
View File
@@ -6,7 +6,6 @@ package response // import "miniflux.app/v2/internal/http/response"
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"log/slog"
"net/http"
@@ -48,7 +47,7 @@ func (b *Builder) WithBody(body any) *Builder {
// WithAttachment forces the document to be downloaded by the web browser.
func (b *Builder) WithAttachment(filename string) *Builder {
b.headers["Content-Disposition"] = fmt.Sprintf("attachment; filename=%s", filename)
b.headers["Content-Disposition"] = "attachment; filename=" + filename
return b
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
)
// OK creates a new HTML response with a 200 status code.
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
+2 -2
View File
@@ -10,7 +10,7 @@ import (
)
// OK writes a standard XML response with a status 200 OK.
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
func OK[T []byte | string](w http.ResponseWriter, r *http.Request, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithBody(body)
@@ -18,7 +18,7 @@ func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
}
// Attachment forces the XML document to be downloaded by the web browser.
func Attachment(w http.ResponseWriter, r *http.Request, filename string, body interface{}) {
func Attachment[T []byte | string](w http.ResponseWriter, r *http.Request, filename string, body T) {
builder := response.New(w, r)
builder.WithHeader("Content-Type", "text/xml; charset=utf-8")
builder.WithAttachment(filename)
+22 -3
View File
@@ -139,14 +139,33 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
}
go func() {
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)
certFile := config.Opts.CertFile()
keyFile := config.Opts.CertKeyFile()
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)
}
}
}()
}
func startAutoCertTLSServer(server *http.Server, autoTLSConfig *tls.Config) {
if server.TLSConfig == nil {
server.TLSConfig = &tls.Config{}
}
server.TLSConfig.GetCertificate = autoTLSConfig.GetCertificate
server.TLSConfig.NextProtos = autoTLSConfig.NextProtos
+10 -3
View File
@@ -372,20 +372,27 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
if userIntegrations.WebhookEnabled {
var webhookURL string
if entry.Feed != nil && entry.Feed.WebhookURL != "" {
webhookURL = entry.Feed.WebhookURL
} else {
webhookURL = userIntegrations.WebhookURL
}
slog.Debug("Sending entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.String("webhook_url", webhookURL),
)
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil {
slog.Error("Unable to send entry to Webhook",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("webhook_url", userIntegrations.WebhookURL),
slog.String("webhook_url", webhookURL),
slog.Any("error", err),
)
}
+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"`
}
+3 -3
View File
@@ -74,10 +74,10 @@ func NewClient(apiToken string, apiEndpoint string) Client {
}
func (c *client) SaveUrl(url string) error {
var payload = map[string]interface{}{
var payload = map[string]any{
"query": mutation,
"variables": map[string]interface{}{
"input": map[string]interface{}{
"variables": map[string]any{
"input": map[string]any{
"clientRequestId": crypto.GenerateUUID(),
"source": "api",
"url": url,
+44 -20
View File
@@ -9,7 +9,10 @@ import (
"fmt"
)
type translationDict map[string]interface{}
type translationDict struct {
singulars map[string]string
plurals map[string][]string
}
type catalog map[string]translationDict
var defaultCatalog = make(catalog, len(AvailableLanguages))
@@ -17,47 +20,68 @@ 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 {
return nil, err
return translationDict{}, err
}
}
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
return translationDict{}, err
}
translationMessages, err := parseTranslationMessages(translationFileData)
if err != nil {
return nil, err
return translationDict{}, err
}
return translationMessages, nil
}
func (t *translationDict) UnmarshalJSON(data []byte) error {
var tmpMap map[string]any
err := json.Unmarshal(data, &tmpMap)
if err != nil {
return err
}
m := translationDict{
singulars: make(map[string]string),
plurals: make(map[string][]string),
}
for key, value := range tmpMap {
switch vtype := value.(type) {
case string:
m.singulars[key] = vtype
case []any:
for _, translation := range vtype {
if translationStr, ok := translation.(string); ok {
m.plurals[key] = append(m.plurals[key], translationStr)
} else {
return fmt.Errorf("invalid type for translation in an array: %v", translation)
}
}
default:
return fmt.Errorf("invalid type (%T) for translation: %v", vtype, value)
}
}
*t = m
return nil
}
func parseTranslationMessages(data []byte) (translationDict, error) {
var translationMessages translationDict
if err := json.Unmarshal(data, &translationMessages); err != nil {
return nil, fmt.Errorf(`invalid translation file: %w`, err)
return translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)
}
return translationMessages, nil
}
+36 -30
View File
@@ -3,7 +3,9 @@
package locale // import "miniflux.app/v2/internal/locale"
import "testing"
import (
"testing"
)
func TestParserWithInvalidData(t *testing.T) {
_, err := parseTranslationMessages([]byte(`{`))
@@ -18,23 +20,22 @@ func TestParser(t *testing.T) {
t.Fatalf(`Unexpected parsing error: %v`, err)
}
if translations == nil {
t.Fatal(`Translations should not be nil`)
}
value, found := translations["k"]
value, found := translations.singulars["k"]
if !found {
t.Fatal(`The translation should contains the defined key`)
t.Fatalf(`The translation %v should contains the defined key`, translations.singulars)
}
if value.(string) != "v" {
if value != "v" {
t.Fatal(`The translation key should contains the defined value`)
}
}
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)
}
}
}
@@ -45,20 +46,22 @@ func TestAllKeysHaveValue(t *testing.T) {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
if len(messages) == 0 {
t.Fatalf(`The language %q doesn't have any messages`, language)
if len(messages.singulars) == 0 {
t.Fatalf(`The language %q doesn't have any messages for singulars`, language)
}
for k, v := range messages {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
}
case []any:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
}
if len(messages.plurals) == 0 {
t.Fatalf(`The language %q doesn't have any messages for plurals`, language)
}
for k, v := range messages.singulars {
if len(v) == 0 {
t.Errorf(`The key %q for singulars for the language %q has an empty list as value`, k, language)
}
}
for k, v := range messages.plurals {
if len(v) == 0 {
t.Errorf(`The key %q for plurals for the language %q has an empty list as value`, k, language)
}
}
}
@@ -81,9 +84,14 @@ func TestMissingTranslations(t *testing.T) {
t.Fatalf(`Parsing error for language %q`, language)
}
for key := range references {
if _, found := messages[key]; !found {
t.Errorf(`Translation key %q not found in language %q`, key, language)
for key := range references.singulars {
if _, found := messages.singulars[key]; !found {
t.Errorf(`Translation key %q not found in language %q singulars`, key, language)
}
}
for key := range references.plurals {
if _, found := messages.plurals[key]; !found {
t.Errorf(`Translation key %q not found in language %q plurals`, key, language)
}
}
}
@@ -118,11 +126,9 @@ func TestTranslationFilePluralForms(t *testing.T) {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
for k, v := range messages {
if value, ok := v.([]any); ok {
if len(value) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
}
for k, v := range messages.plurals {
if len(v) != numberOfPluralFormsPerLanguage[language] {
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(v), numberOfPluralFormsPerLanguage[language])
}
}
}
+299
View File
@@ -0,0 +1,299 @@
// 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{
singulars: map[string]string{
"error.test_key": "Error: %s (code: %d)",
},
},
"fr_FR": translationDict{
singulars: map[string]string{
"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{
singulars: map[string]string{
"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{
singulars: map[string]string{
"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{
singulars: map[string]string{
"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{
singulars: map[string]string{
"error.permission": "Permission denied for %s",
},
},
"es_ES": translationDict{
singulars: map[string]string{
"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{
singulars: map[string]string{
"error.generic": "An error occurred",
},
},
"de_DE": translationDict{
singulars: map[string]string{
"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{
singulars: map[string]string{
"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)
}
+16 -49
View File
@@ -10,71 +10,38 @@ 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 str, ok := dict[key]; ok {
if translation, ok := str.(string); ok {
return translation
}
if dict, err := getTranslationDict(p.language); err == nil {
if str, ok := dict.singulars[key]; ok {
return str
}
}
return key
}
// Printf is like fmt.Printf, but using language-specific formatting.
func (p *Printer) Printf(key string, args ...interface{}) 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 {
translation = key
}
}
}
return fmt.Sprintf(translation, args...)
func (p *Printer) Printf(key string, args ...any) string {
return fmt.Sprintf(p.Print(key), args...)
}
// 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)
func (p *Printer) Plural(key string, n int, args ...any) string {
dict, err := getTranslationDict(p.language)
if err != nil {
return key
}
if choices, found := dict[key]; found {
var plurals []string
switch v := choices.(type) {
case []interface{}:
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)
if len(plurals) > index {
return fmt.Sprintf(plurals[index], args...)
if choices, found := dict.plurals[key]; found {
index := getPluralForm(p.language, n)
if len(choices) > index {
return fmt.Sprintf(choices[index], args...)
}
}
return key
}
// NewPrinter creates a new Printer.
func NewPrinter(language string) *Printer {
return &Printer{language}
}
+223 -41
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,10 +14,12 @@ func TestTranslateWithMissingLanguage(t *testing.T) {
}
}
func TestTranslateWithMissingKey(t *testing.T) {
func TestPrintfWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"k": "v",
singulars: map[string]string{
"k": "v",
},
},
}
@@ -27,10 +29,12 @@ func TestTranslateWithMissingKey(t *testing.T) {
}
}
func TestTranslateWithExistingKey(t *testing.T) {
func TestPrintfWithExistingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
singulars: map[string]string{
"auth.username": "Login",
},
},
}
@@ -40,13 +44,17 @@ func TestTranslateWithExistingKey(t *testing.T) {
}
}
func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
func TestPrintfWithExistingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"key": "Test: %s",
singulars: map[string]string{
"key": "Test: %s",
},
},
"fr_FR": translationDict{
"key": "Test : %s",
singulars: map[string]string{
"key": "Test : %s",
},
},
}
@@ -56,13 +64,17 @@ func TestTranslateWithExistingKeyAndPlaceholder(t *testing.T) {
}
}
func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
func TestPrintfWithMissingKeyAndPlaceholder(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
singulars: map[string]string{
"auth.username": "Login",
},
},
"fr_FR": translationDict{
"auth.username": "Identifiant",
singulars: map[string]string{
"auth.username": "Identifiant",
},
},
}
@@ -72,29 +84,122 @@ func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
}
}
func TestTranslateWithInvalidValue(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"auth.username": "Login",
},
"fr_FR": translationDict{
"auth.username": true,
},
}
func TestPrintWithMissingLanguage(t *testing.T) {
defaultCatalog = catalog{}
translation := NewPrinter("invalid").Print("missing.key")
translation := NewPrinter("fr_FR").Printf("auth.username")
if translation != "auth.username" {
if translation != "missing.key" {
t.Errorf(`Wrong translation, got %q`, translation)
}
}
func TestTranslatePluralWithDefaultRule(t *testing.T) {
func TestPrintWithMissingKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
singulars: map[string]string{
"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{
singulars: map[string]string{
"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{
singulars: map[string]string{
"greeting": "Hello",
},
},
"fr_FR": translationDict{
"number_of_users": []string{"%d utilisateur (%s)", "%d utilisateurs (%s)"},
singulars: map[string]string{
"greeting": "Bonjour",
},
},
"es_ES": translationDict{
singulars: map[string]string{
"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 TestPrintWithEmptyKey(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
singulars: map[string]string{
"": "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{
singulars: map[string]string{
"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{
plurals: map[string][]string{
"number_of_users": {"%d user (%s)", "%d users (%s)"},
},
},
"fr_FR": translationDict{
plurals: map[string][]string{
"number_of_users": {"%d utilisateur (%s)", "%d utilisateurs (%s)"},
},
},
}
@@ -112,13 +217,17 @@ 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"},
plurals: map[string][]string{
"time_elapsed.years": {"%d year", "%d years"},
},
},
"ru_RU": translationDict{
"time_elapsed.years": []string{"%d год назад", "%d года назад", "%d лет назад"},
plurals: map[string][]string{
"time_elapsed.years": {"%d год назад", "%d года назад", "%d лет назад"},
},
},
}
@@ -143,10 +252,12 @@ 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)"},
plurals: map[string][]string{
"number_of_users": {"%d user (%s)", "%d users (%s)"},
},
},
"fr_FR": translationDict{},
}
@@ -157,18 +268,89 @@ func TestTranslatePluralWithMissingTranslation(t *testing.T) {
}
}
func TestTranslatePluralWithInvalidValues(t *testing.T) {
defaultCatalog = catalog{
"en_US": translationDict{
"number_of_users": []string{"%d user (%s)", "%d users (%s)"},
},
"fr_FR": translationDict{
"number_of_users": "must be a slice",
},
}
translation := NewPrinter("fr_FR").Plural("number_of_users", 2)
expected := "number_of_users"
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 TestPluralWithIndexOutOfBounds(t *testing.T) {
defaultCatalog = catalog{
"test_lang": translationDict{
plurals: map[string][]string{
"limited.key": {"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{
plurals: map[string][]string{
"limited.key": {"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{
plurals: map[string][]string{
"items": {"no items", "one item", "two items", "few items", "many items", "other items"},
},
},
"pl_PL": translationDict{
plurals: map[string][]string{
"files": {"one file", "few files", "many files"},
},
},
"ja_JP": translationDict{
plurals: map[string][]string{
"photos": {"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)
}
}
}
+5 -5
View File
@@ -252,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",
@@ -508,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",
@@ -530,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",
@@ -614,4 +614,4 @@
"time_elapsed.yesterday": "gestern",
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
"tooltip.logged_user": "Angemeldet als %s"
}
}
+1 -1
View File
@@ -252,7 +252,7 @@
"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",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -252,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": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
+1 -1
View File
@@ -249,7 +249,7 @@
"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",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -249,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ユーザ用パスワード",
@@ -249,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é",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -255,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",
+1 -1
View File
@@ -252,7 +252,7 @@
"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",
+1 -1
View File
@@ -255,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",
+1 -1
View File
@@ -255,7 +255,7 @@
"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": "ID комнаты Matrix",
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
+1 -1
View File
@@ -252,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",
+1 -1
View File
@@ -255,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",
+270 -270
View File
@@ -6,39 +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.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": "重置",
@@ -55,112 +55,112 @@
"需要 %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)"
"显示 %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": "订阅源的 URL 不能为空。",
"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_categories_sorting_order": "无效的分类排序",
"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_entry_direction": "无效的条目方向。",
"error.invalid_entry_order": "无效的条目排序。",
"error.invalid_feed_proxy_url": "无效的代理 URL。",
"error.invalid_feed_url": "订阅源 URL 无效。",
"error.invalid_gesture_nav": "手势导航无效。",
"error.invalid_feed_url": "无效的订阅源 URL。",
"error.invalid_gesture_nav": "无效的手势导航。",
"error.invalid_language": "无效的语言。",
"error.invalid_site_url": "网站 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.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_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.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": "规则",
@@ -168,22 +168,22 @@
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"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": "抓取全文内容",
"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.keep_filter_entry_rules": "条目允许规则",
"form.feed.label.keeplist_rules": "基于正则表达式的保留过滤器",
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
"form.feed.label.ntfy_activate": "推送条目到ntfy",
"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 低优先级",
@@ -192,7 +192,7 @@
"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_activate": "推送条目到 Pushover",
"form.feed.label.pushover_default_priority": "Pushover 默认优先级",
"form.feed.label.pushover_high_priority": "Pushover 高优先级",
"form.feed.label.pushover_low_priority": "Pushover 低优先级",
@@ -201,24 +201,24 @@
"form.feed.label.pushover_priority": "Pushover 消息优先级",
"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.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 服务端 URL",
"form.integration.cubox_activate": "保存文章到 Cubox",
"form.integration.cubox_activate": "保存条目到 Cubox",
"form.integration.cubox_api_link": "Cubox API 链接",
"form.integration.discord_activate": "将新文章推送到 Discord",
"form.integration.discord_activate": "推送条目到 Discord",
"form.integration.discord_webhook_link": "Discord Webhook 链接",
"form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_activate": "保存条目到 Espial",
"form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_tags": "Espial 标签",
@@ -227,297 +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.karakeep_activate": "保存文章到 Karakeep",
"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_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.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_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_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": "主题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": "默认 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.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.open_external_links_in_new_tab": "在新标签页中打开外部链接(为链接添加 target=\"_blank\"",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
"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.git_commit": "Git提交:",
"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.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.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 分类"
"%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.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": "操作",
@@ -526,42 +526,42 @@
"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 个通行密钥"
],
"page.settings.webauthn.last_seen_on": "最后使用时间",
"page.settings.webauthn.last_seen_on": "最后使用",
"page.settings.webauthn.passkey_name": "通行密钥名称",
"page.settings.webauthn.passkeys": "通行密钥列表",
"page.settings.webauthn.passkeys": "通行密钥",
"page.settings.webauthn.register": "注册通行密钥",
"page.settings.webauthn.register.error": "无法注册通行密钥",
"page.shared_entries.title": "已享的文章",
"page.shared_entries.title": "已享的条目",
"page.shared_entries_count": [
"%d 已分享的文章"
"%d 个共享条目"
],
"page.starred.title": "收藏",
"page.starred_entry_count": [
"%d 收藏的文章"
"%d 收藏条目"
],
"page.total_entry_count": [
"%d 文章总数"
"%d 个条目"
],
"page.unread.title": "未读",
"page.unread_entry_count": [
"%d 未读的文章"
"%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": "重命名通行密钥",
@@ -571,7 +571,7 @@
"pagination.previous": "上一页",
"search.label": "搜索",
"search.placeholder": "搜索…",
"search.submit": "查找",
"search.submit": "搜索",
"skip_to_content": "跳转至内容",
"time_elapsed.days": [
"%d 天前"
@@ -594,6 +594,6 @@
"%d 年前"
],
"time_elapsed.yesterday": "昨天",
"tooltip.keyboard_shortcuts": "快捷键: %s",
"tooltip.logged_user": "当前登录 %s"
}
"tooltip.keyboard_shortcuts": "键盘快捷键%s",
"tooltip.logged_user": "登录用户:%s"
}
+1 -1
View File
@@ -249,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 密碼",
+144 -13
View File
@@ -278,19 +278,8 @@ func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
if err == nil {
t.Fatalf(`Incorrect proxy URL silently accepted (MEDIA_PROXY_CUSTOM_URL=%q): %q`, os.Getenv("MEDIA_PROXY_CUSTOM_URL"), config.Opts.MediaCustomProxyURL())
}
}
@@ -589,3 +578,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
}
+6 -15
View File
@@ -7,7 +7,6 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/url"
"github.com/gorilla/mux"
@@ -21,7 +20,7 @@ func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
@@ -36,7 +35,7 @@ func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != nil {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
@@ -50,19 +49,11 @@ func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
return absoluteURL
}
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
if customProxyURL == "" {
func proxifyURLWithCustomProxy(mediaURL string, customProxyURL *url.URL) string {
if customProxyURL == nil {
return mediaURL
}
absoluteURL, err := url.JoinPath(customProxyURL, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
if err != nil {
slog.Error("Incorrect custom media proxy URL",
slog.String("custom_proxy_url", customProxyURL),
slog.Any("error", err),
)
return mediaURL
}
return absoluteURL
absoluteURL := customProxyURL.JoinPath(base64.URLEncoding.EncodeToString([]byte(mediaURL)))
return absoluteURL.String()
}
+5 -5
View File
@@ -135,14 +135,14 @@ var (
)
)
// Collector represents a metric collector.
type Collector struct {
// collector represents a metric collector.
type collector struct {
store *storage.Storage
refreshInterval int
}
// NewCollector initializes a new metric collector.
func NewCollector(store *storage.Storage, refreshInterval int) *Collector {
func NewCollector(store *storage.Storage, refreshInterval int) *collector {
prometheus.MustRegister(BackgroundFeedRefreshDuration)
prometheus.MustRegister(ScraperRequestDuration)
prometheus.MustRegister(ArchiveEntriesDuration)
@@ -158,11 +158,11 @@ func NewCollector(store *storage.Storage, refreshInterval int) *Collector {
prometheus.MustRegister(dbConnectionsMaxIdleTimeClosedGauge)
prometheus.MustRegister(dbConnectionsMaxLifetimeClosedGauge)
return &Collector{store, refreshInterval}
return &collector{store, refreshInterval}
}
// GatherStorageMetrics polls the database to fetch metrics.
func (c *Collector) GatherStorageMetrics() {
func (c *collector) GatherStorageMetrics() {
for range time.Tick(time.Duration(c.refreshInterval) * time.Second) {
slog.Debug("Collecting metrics from the database")
+3 -1
View File
@@ -8,6 +8,8 @@ import (
)
// APIKey represents an application API key.
// We need to use a pointer for LastUsedAt,
// as the value obtained from the database might sometimes be nil.
type APIKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
@@ -18,7 +20,7 @@ type APIKey struct {
}
// APIKeys represents a collection of API Key.
type APIKeys []*APIKey
type APIKeys []APIKey
// APIKeyCreationRequest represents the request to create a new API Key.
type APIKeyCreationRequest struct {
+1 -1
View File
@@ -44,7 +44,7 @@ func (s *SessionData) Value() (driver.Value, error) {
}
// Scan converts raw JSON data.
func (s *SessionData) Scan(src interface{}) error {
func (s *SessionData) Scan(src any) error {
source, ok := src.([]byte)
if !ok {
return errors.New("session: unable to assert type of src")
+4 -3
View File
@@ -11,8 +11,9 @@ type Category struct {
Title string `json:"title"`
UserID int64 `json:"user_id"`
HideGlobally bool `json:"hide_globally"`
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
// Pointers are needed to avoid breaking /v1/categories?counts=true
FeedCount *int `json:"feed_count,omitempty"`
TotalUnread *int `json:"total_unread,omitempty"`
}
func (c *Category) String() string {
@@ -40,4 +41,4 @@ func (c *CategoryModificationRequest) Patch(category *Category) {
}
// Categories represents a list of categories.
type Categories []*Category
type Categories []Category
+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)
}
})
}
+5 -5
View File
@@ -37,7 +37,6 @@ 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"`
@@ -54,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"`
@@ -164,15 +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"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
ProxyURL string `json:"proxy_url"`
}
+1 -2
View File
@@ -5,7 +5,6 @@ package model // import "miniflux.app/v2/internal/model"
import (
"encoding/base64"
"fmt"
)
// Icon represents a website icon (favicon)
@@ -19,7 +18,7 @@ type Icon struct {
// DataURL returns the data URL of the icon.
func (i *Icon) DataURL() string {
return fmt.Sprintf("%s;base64,%s", i.MimeType, base64.StdEncoding.EncodeToString(i.Content))
return i.MimeType + ";base64," + base64.StdEncoding.EncodeToString(i.Content)
}
// Icons represents a list of icons.
+13 -2
View File
@@ -5,9 +5,20 @@ package model // import "miniflux.app/v2/internal/model"
// Job represents a payload sent to the processing queue.
type Job struct {
UserID int64
FeedID int64
UserID int64
FeedID int64
FeedURL string
}
// JobList represents a list of jobs.
type JobList []Job
// FeedURLs returns a list of feed URLs from the job list.
// This is useful for logging or debugging purposes to see which feeds are being processed.
func (jl *JobList) FeedURLs() []string {
feedURLs := make([]string, len(*jl))
for i, job := range *jl {
feedURLs[i] = job.FeedURL
}
return feedURLs
}
+1 -1
View File
@@ -30,7 +30,7 @@ func (u *UserSession) UseTimezone(tz string) {
}
// UserSessions represents a list of sessions.
type UserSessions []*UserSession
type UserSessions []UserSession
// UseTimezone converts creation date of all sessions to the given timezone.
func (u UserSessions) UseTimezone(tz string) {
+2 -2
View File
@@ -14,7 +14,7 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
)
// handle marshalling / unmarshalling session data
// WebAuthnSession handles marshalling / unmarshalling session data
type WebAuthnSession struct {
*webauthn.SessionData
}
@@ -23,7 +23,7 @@ func (s WebAuthnSession) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *WebAuthnSession) Scan(value interface{}) error {
func (s *WebAuthnSession) Scan(value any) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
+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
}
+11 -11
View File
@@ -10,7 +10,7 @@ import (
)
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
type Atom03Feed struct {
type atom03Feed struct {
Version string `xml:"version,attr"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
@@ -21,14 +21,14 @@ type Atom03Feed struct {
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
// atom:feed elements MUST contain exactly one atom:title element.
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"http://purl.org/atom/ns# title"`
Title atom03Content `xml:"http://purl.org/atom/ns# title"`
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"http://purl.org/atom/ns# link"`
Links atomLinks `xml:"http://purl.org/atom/ns# link"`
// The "atom:author" element is a Person construct that indicates the default author of the feed.
// atom:feed elements MUST contain exactly one atom:author element,
@@ -38,10 +38,10 @@ type Atom03Feed struct {
// The "atom:entry" element's represents an individual entry that is contained by the feed.
// atom:feed elements MAY contain one or more atom:entry elements.
Entries []Atom03Entry `xml:"http://purl.org/atom/ns# entry"`
Entries []atom03Entry `xml:"http://purl.org/atom/ns# entry"`
}
type Atom03Entry struct {
type atom03Entry struct {
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry.
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
@@ -50,7 +50,7 @@ type Atom03Entry struct {
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
// atom:entry elements MUST have exactly one "atom:title" element.
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"title"`
Title atom03Content `xml:"title"`
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
@@ -73,15 +73,15 @@ type Atom03Entry struct {
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"link"`
Links atomLinks `xml:"link"`
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
Summary Atom03Content `xml:"summary"`
Summary atom03Content `xml:"summary"`
// The "atom:content" element is a Content construct that conveys the content of the entry.
// atom:entry elements MAY contain one or more atom:content elements.
Content Atom03Content `xml:"content"`
Content atom03Content `xml:"content"`
// The "atom:author" element is a Person construct that indicates the default author of the entry.
// atom:entry elements MUST contain exactly one atom:author element,
@@ -90,7 +90,7 @@ type Atom03Entry struct {
Author AtomPerson `xml:"author"`
}
type Atom03Content struct {
type atom03Content struct {
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
// When present, this attribute's value MUST be a registered media type [RFC2045].
// If not present, its value MUST be considered to be "text/plain".
@@ -113,7 +113,7 @@ type Atom03Content struct {
InnerXML string `xml:",innerxml"`
}
func (a *Atom03Content) Content() string {
func (a *atom03Content) content() string {
content := ""
switch a.Mode {
+10 -14
View File
@@ -14,15 +14,11 @@ import (
"miniflux.app/v2/internal/urllib"
)
type Atom03Adapter struct {
atomFeed *Atom03Feed
type atom03Adapter struct {
atomFeed *atom03Feed
}
func NewAtom03Adapter(atomFeed *Atom03Feed) *Atom03Adapter {
return &Atom03Adapter{atomFeed}
}
func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
@@ -36,7 +32,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
siteURL := a.atomFeed.Links.originalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
@@ -46,7 +42,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Content()
feed.Title = a.atomFeed.Title.content()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
@@ -55,7 +51,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
entry.URL = atomEntry.Links.originalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
@@ -63,13 +59,13 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
}
// Populate the entry content.
entry.Content = atomEntry.Content.Content()
entry.Content = atomEntry.Content.content()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Content()
entry.Content = atomEntry.Summary.content()
}
// Populate the entry title.
entry.Title = atomEntry.Title.Content()
entry.Title = atomEntry.Title.content()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
@@ -101,7 +97,7 @@ func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
for _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {
if value != "" {
entry.Hash = crypto.SHA256(value)
break
+20 -20
View File
@@ -19,7 +19,7 @@ import (
// Specs:
// https://tools.ietf.org/html/rfc4287
// https://validator.w3.org/feed/docs/atom.html
type Atom10Feed struct {
type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
// The "atom:id" element conveys a permanent, universally unique
@@ -37,11 +37,11 @@ type Atom10Feed struct {
// readable title for an entry or feed.
//
// atom:feed elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
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"`
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.
@@ -49,7 +49,7 @@ type Atom10Feed struct {
// atom:feed elements MUST contain one or more atom:author elements,
// unless all of the atom:feed element's child atom:entry elements
// contain at least one atom:author element.
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:icon" element's content is an IRI reference [RFC3987] that
// identifies an image that provides iconic visual identification for a
@@ -71,7 +71,7 @@ type Atom10Feed struct {
// atom:feed elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
@@ -79,12 +79,12 @@ type Atom10Feed struct {
//
// atom:feed elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
Entries []Atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
Entries []atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
}
type Atom10Entry struct {
type atom10Entry struct {
// The "atom:id" element conveys a permanent, universally unique
// identifier for an entry or feed.
//
@@ -100,7 +100,7 @@ type Atom10Entry struct {
// readable title for an entry or feed.
//
// atom:entry elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
Title atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:published" element is a Date construct indicating an
// instant in time associated with an event early in the life cycle of
@@ -118,7 +118,7 @@ type Atom10Entry struct {
// atom:entry elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
Links atomLinks `xml:"http://www.w3.org/2005/Atom link"`
// atom:entry elements MUST contain an atom:summary element in either
// of the following cases:
@@ -131,17 +131,17 @@ type Atom10Entry struct {
//
// atom:entry elements MUST NOT contain more than one atom:summary
// element.
Summary Atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
Summary atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
// atom:entry elements MUST NOT contain more than one atom:content
// element.
Content Atom10Text `xml:"http://www.w3.org/2005/Atom content"`
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:entry elements MUST contain one or more atom:author elements
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
Authors atomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
@@ -149,7 +149,7 @@ type Atom10Entry struct {
//
// atom:entry elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
Categories atomCategories `xml:"http://www.w3.org/2005/Atom category"`
media.MediaItemElement
}
@@ -160,14 +160,14 @@ type Atom10Entry struct {
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
type Atom10Text struct {
type atom10Text struct {
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement AtomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
func (a *Atom10Text) Body() string {
func (a *atom10Text) body() string {
var content string
if strings.EqualFold(a.Type, "xhtml") {
@@ -179,7 +179,7 @@ func (a *Atom10Text) Body() string {
return strings.TrimSpace(content)
}
func (a *Atom10Text) Title() string {
func (a *atom10Text) title() string {
var content string
switch {
@@ -194,14 +194,14 @@ func (a *Atom10Text) Title() string {
return strings.TrimSpace(content)
}
func (a *Atom10Text) xhtmlContent() string {
func (a *atom10Text) xhtmlContent() string {
if a.XHTMLRootElement.XMLName.Local == "div" {
return a.XHTMLRootElement.InnerXML
}
return a.InnerXML
}
type AtomXHTMLRootElement struct {
type atomXHTMLRootElement struct {
XMLName xml.Name `xml:"div"`
InnerXML string `xml:",innerxml"`
}
+18 -16
View File
@@ -18,15 +18,15 @@ import (
"miniflux.app/v2/internal/urllib"
)
type Atom10Adapter struct {
atomFeed *Atom10Feed
type atom10Adapter struct {
atomFeed *atom10Feed
}
func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
return &Atom10Adapter{atomFeed}
func NewAtom10Adapter(atomFeed *atom10Feed) *atom10Adapter {
return &atom10Adapter{atomFeed}
}
func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
func (a *atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
@@ -40,7 +40,7 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
siteURL := a.atomFeed.Links.originalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
@@ -50,13 +50,13 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Body()
feed.Title = a.atomFeed.Title.body()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// Populate the feed description.
feed.Description = a.atomFeed.Subtitle.Body()
feed.Description = a.atomFeed.Subtitle.body()
// Populate the feed icon.
if a.atomFeed.Icon != "" {
@@ -72,14 +72,14 @@ func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
return feed
}
func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
entries := make(model.Entries, 0, len(a.atomFeed.Entries))
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
entry.URL = atomEntry.Links.originalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
@@ -87,16 +87,16 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
}
// Populate the entry content.
entry.Content = atomEntry.Content.Body()
entry.Content = atomEntry.Content.body()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Body()
entry.Content = atomEntry.Summary.body()
if entry.Content == "" {
entry.Content = atomEntry.FirstMediaDescription()
}
}
// Populate the entry title.
entry.Title = atomEntry.Title.Title()
entry.Title = atomEntry.Title.title()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
if entry.Title == "" {
@@ -105,9 +105,9 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
}
// Populate the entry author.
authors := atomEntry.Authors.PersonNames()
authors := atomEntry.Authors.personNames()
if len(authors) == 0 {
authors = a.atomFeed.Authors.PersonNames()
authors = a.atomFeed.Authors.personNames()
}
sort.Strings(authors)
authors = slices.Compact(authors)
@@ -137,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)
@@ -150,7 +152,7 @@ func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
for _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {
if value != "" {
entry.Hash = crypto.SHA256(value)
break
+19 -17
View File
@@ -1761,6 +1761,8 @@ func TestParseItemWithCategories(t *testing.T) {
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
<category term='ZZZZ' />
<category term='ZZZZ' />
<category term=" " />
<category term='Technology' label='Science' />
</entry>
</feed>`
@@ -1774,16 +1776,13 @@ func TestParseItemWithCategories(t *testing.T) {
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := "Science"
result := feed.Entries[0].Tags[0]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
}
expected := []string{"Science", "ZZZZ"}
result := feed.Entries[0].Tags
expected = "ZZZZ"
result = feed.Entries[0].Tags[1]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
for i, tag := range result {
if tag != expected[i] {
t.Errorf("Incorrect entry tag, got %q instead of %q", tag, expected[i])
}
}
}
@@ -1792,9 +1791,10 @@ func TestParseFeedWithCategories(t *testing.T) {
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<category term='Test' label='Some Label' />
<category term='Test' label='Some Label' />
<category term='Test' label='Some Label' />
<category term='C term' label='C label' />
<category term='B term' label='B label' />
<category term='B term' label='B label' />
<category term='A term' label='A label' />
<entry>
<link href="http://www.example.org/entries/1" />
<updated>2003-12-13T18:30:02Z</updated>
@@ -1807,14 +1807,16 @@ func TestParseFeedWithCategories(t *testing.T) {
t.Fatal(err)
}
if len(feed.Entries[0].Tags) != 1 {
if len(feed.Entries[0].Tags) != 3 {
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
}
expected := "Some Label"
result := feed.Entries[0].Tags[0]
if result != expected {
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
expected := []string{"A label", "B label", "C label"}
result := feed.Entries[0].Tags
for i, tag := range result {
if tag != expected[i] {
t.Errorf("Incorrect entry tag, got %q instead of %q", tag, expected[i])
}
}
}

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