Compare commits

...

61 Commits
v2.1.2 ... main

Author SHA1 Message Date
dependabot[bot] 839c4ad044 build(deps): bump github.com/yuin/goldmark from 1.7.1 to 1.7.2
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.1...v1.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-14 16:43:20 -07:00
Ztec 9f3a8e7f1b Request builder: Allow the use of insecure TLS ciphers when `Allow self-signed or invalid certificates` is used
Some server on the wild are badly configured. Either by mistake or lack
of maintenance. Safe and unsafe Ciphers change overtime based on new
discoveries.

This proposition will include considered unsafe ciphers when `Allow self-signed or invalid certificates` is used.
It could be put into a separate option but, I felt this could fit in.

fix #2671
2024-06-13 20:23:37 -07:00
Ztec e54825bf02 Improve YouTube page feed detection
In order to be more resilient to YouTube URLs variation and
to address this feature_request: https://github.com/miniflux/v2/issues/2628
I've reworked a bit the way the YouTube feed extraction is done.

I've kept all the `FindSubscriptionsFromYouTube*` in order
to keep all the existing unit tests as-is ensuring little to no
regressions. By doing so, I had to call twice `youtubeURLIDExtractor`.
Small performance penalty for peace of mind in my opinion.

`youtubeURLIDExtractor` is made in a way only one kind
of page can be detected at a time. This mean I can
solve the "video in a playlist" feature_request
by prioritizing the playlist ID over the Video ID

Also, by using `url.Parse()` to get ids, it's safer
to url mangle and variation. The most common variation
being the `t=42` parameters that start the playback
at a given position. Previously, this kind of url
would not be detected as "YouTube URL".

I deliberately ignored the url parsing error
to keep previous behavior (skip the YouTube analysis and follow with the other analysis)

I also tried to keep debug logs the same as before as much as I could.

I manually tested all the YouTube cases (video,channel,playlist)
and they all work as expected except for the video. But this one
does not work either on main. The `meta` html tag that was searched for
does not seem to exist anymore.

fix: #2628
2024-06-13 20:18:47 -07:00
Ztec 07f6d397d4 Fix Playback speed indicator precision
The original idea was to have two digit precision at all time
in order to ensure the length of the string is always the same.
This prevents the UI button to move when pressed.
I completely missed the first press as the precision was not right
upon first click.
2024-06-13 20:13:07 -07:00
Ztec f33e76eb8c Fix Playback speed indicator on shared entries
On shared entries, there is no speed configured as this
is bound to the user. Shared entries are displayed without user config.

I've changed the default view to reflect the
actual default playback speed in this case. 1x.
2024-06-13 20:11:33 -07:00
dependabot[bot] 84e97826d8 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.33 to 2.20.34
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.33 to 2.20.34.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.33...v2.20.34)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 20:24:20 -07:00
x 839fc3843a Add pitchfork.com scraping rule 2024-06-10 21:08:59 -07:00
x 0bab8fac8e Update theverge.com rewrite rule: fix duplicate image
See: https://github.com/miniflux/v2/issues/1979
2024-06-10 21:08:59 -07:00
dependabot[bot] 0cf1a40276 build(deps): bump github.com/tdewolff/minify/v2 from 2.20.32 to 2.20.33
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.32 to 2.20.33.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.32...v2.20.33)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-09 20:38:34 -07:00
dependabot[bot] 91479bc0ee build(deps): bump golang.org/x/net from 0.25.0 to 0.26.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/net/compare/v0.25.0...v0.26.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 20:02:27 -07:00
dependabot[bot] 251821289c build(deps): bump golang.org/x/oauth2 from 0.20.0 to 0.21.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.20.0...v0.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 19:40:09 -07:00
dependabot[bot] cac0bc682f build(deps): bump golang.org/x/crypto from 0.23.0 to 0.24.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/crypto/compare/v0.23.0...v0.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 19:39:49 -07:00
dependabot[bot] a733c14c61 build(deps): bump golang.org/x/term from 0.20.0 to 0.21.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/term/compare/v0.20.0...v0.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-04 19:15:01 -07:00
Ankit Pandey b68b05c64c reader/processor: error out for improper rewrite regexp
It's possible to specify a rewrite regex that validates but doesn't compile such
as:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 17:58:41 -07:00
dependabot[bot] 3388f8e376 Bump github.com/prometheus/client_golang from 1.19.0 to 1.19.1
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.0 to 1.19.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.0...v1.19.1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 17:30:26 -07:00
Evan Elias Young 1b8c45d162 finder: Find feed from YouTube playlist
The feed from a YouTube playlist page is derived in practically the same way as a feed from a YouTube channel page.
2024-04-01 21:16:32 -07:00
jvoisin 19ce519836 reader/rewrite: add a rule for oglaf.com
By default, Oglaf show some disclaimer/warning about its content, and this
doesn't play well with rss readers, so let's rewrite it to show the actual
comic instead of a placeholder.
2024-04-01 21:05:01 -07:00
Thomas J Faughnan Jr 3e0d5de7a3 api tests: use intSize-agnostic random integers
rand.Intn(math.MaxInt64) causes tests to fail on 32-bit architectures.
Use the simpler rand.Int() instead, which still provides plenty of room
for generating pseudo-random test usernames.
2024-04-01 21:02:48 -07:00
86 changed files with 1501 additions and 281 deletions

View File

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

View File

@ -30,7 +30,7 @@ jobs:
with:
go-version: "1.22.x"
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v4
- uses: golangci/golangci-lint-action@v6
with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
- uses: dominikh/staticcheck-action@v1.3.1

View File

@ -1,3 +1,28 @@
Version 2.1.3 (April 27, 2024)
------------------------------
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
* `finder`: find feeds from YouTube playlist
* `http/response`: add brotli compression support
* `integration/matrix`: fix function name in comment
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
* `reader/fetcher`: add brotli content encoding support
* `reader/processor`: minimize feed entries HTML content
* `reader/rewrite`: add a rule for `oglaf.com`
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
* `ui`: add seek and speed controls to media player
* `ui`: add tag entries page
* `ui`: fix JavaScript error when clicking on unread counter
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
Version 2.1.2 (March 30, 2024)
------------------------------

View File

@ -1,12 +1,12 @@
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DEB_IMG_ARCH := amd64
APP := miniflux
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64
export PGPASSWORD := postgres
@ -163,15 +163,15 @@ rpm: clean
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
debian:
@ docker build --load \
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
@ docker buildx build --load \
--platform linux/$(DOCKER_PLATFORM) \
-t miniflux-deb-builder \
-f packaging/debian/Dockerfile \
.
@ docker run --rm \
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
-v ${PWD}:/pkg miniflux-deb-builder
debian-packages: clean
$(MAKE) debian DEB_IMG_ARCH=amd64
$(MAKE) debian DEB_IMG_ARCH=arm64v8
$(MAKE) debian DEB_IMG_ARCH=arm32v7
$(MAKE) debian DOCKER_PLATFORM=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64
$(MAKE) debian DOCKER_PLATFORM=arm/v7

25
go.mod
View File

@ -3,20 +3,21 @@ module miniflux.app/v2
// +heroku goVersion go1.22
require (
github.com/PuerkitoBio/goquery v1.9.1
github.com/PuerkitoBio/goquery v1.9.2
github.com/abadojack/whatlanggo v1.0.1
github.com/andybalholm/brotli v1.1.0
github.com/coreos/go-oidc/v3 v3.10.0
github.com/go-webauthn/webauthn v0.10.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.19.0
github.com/tdewolff/minify/v2 v2.20.19
github.com/yuin/goldmark v1.7.0
golang.org/x/crypto v0.21.0
golang.org/x/net v0.22.0
golang.org/x/oauth2 v0.18.0
golang.org/x/term v0.18.0
golang.org/x/text v0.14.0
github.com/prometheus/client_golang v1.19.1
github.com/tdewolff/minify/v2 v2.20.34
github.com/yuin/goldmark v1.7.2
golang.org/x/crypto v0.24.0
golang.org/x/net v0.26.0
golang.org/x/oauth2 v0.21.0
golang.org/x/term v0.21.0
golang.org/x/text v0.16.0
mvdan.cc/xurls/v2 v2.5.0
)
@ -32,16 +33,14 @@ require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/tdewolff/parse/v2 v2.7.12 // indirect
github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.18.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
golang.org/x/sys v0.21.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

57
go.sum
View File

@ -1,7 +1,9 @@
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -22,11 +24,6 @@ github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
@ -41,8 +38,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
@ -51,22 +48,22 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo=
github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/minify/v2 v2.20.34 h1:XueI6sQtgS7du45fyBCNkNfPQ9SINaYavMFNOxp37SA=
github.com/tdewolff/minify/v2 v2.20.34/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.2 h1:NjGd7lO7zrUn/A7eKwn5PEOt4ONYGqpxSEeZuduvgxc=
github.com/yuin/goldmark v1.7.2/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -74,10 +71,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -88,32 +85,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io"
"math"
"math/rand"
"os"
"strings"
@ -58,7 +57,7 @@ func (c *integrationTestConfig) isConfigured() bool {
}
func (c *integrationTestConfig) genRandomUsername() string {
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Intn(math.MaxInt64))
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Int())
}
func TestIncorrectEndpoint(t *testing.T) {

View File

@ -2021,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
}
}
func TestFetchNebulaWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchNebulaWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
func TestFetchOdyseeWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")

View File

@ -56,6 +56,7 @@ const (
defaultMediaResourceTypes = "image"
defaultMediaProxyURL = ""
defaultFilterEntryMaxAgeDays = 0
defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
@ -140,6 +141,7 @@ type Options struct {
mediaProxyMode string
mediaProxyResourceTypes []string
mediaProxyCustomURL string
fetchNebulaWatchTime bool
fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int
@ -216,6 +218,7 @@ func NewOptions() *Options {
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
mediaProxyCustomURL: defaultMediaProxyURL,
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
@ -486,6 +489,12 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride
}
// FetchNebulaWatchTime returns true if the Nebula video duration
// should be fetched and used as a reading time.
func (o *Options) FetchNebulaWatchTime() bool {
return o.fetchNebulaWatchTime
}
// FetchOdyseeWatchTime returns true if the Odysee video duration
// should be fetched and used as a reading time.
func (o *Options) FetchOdyseeWatchTime() bool {
@ -647,6 +656,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
"HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,

View File

@ -259,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
case "FETCH_NEBULA_WATCH_TIME":
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME":

View File

@ -882,4 +882,25 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// Entry URLs can exceeds btree maximum size
// Checking entry existence is now using entries_feed_id_status_hash_idx index
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
_, err = tx.Exec(sql)
return err
},
}

View File

@ -1021,10 +1021,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
ID: fmt.Sprintf(EntryIDLong, entry.ID),
Title: entry.Title,
Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
Published: entry.Date.Unix(),
Updated: entry.Date.Unix(),
Updated: entry.ChangedAt.Unix(),
Categories: categories,
Canonical: []contentHREF{
{

View File

@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
)
// Cookie names.
const (
CookieAppSessionID = "MinifluxAppSessionID"
CookieUserSessionID = "MinifluxUserSessionID"
// Cookie duration in days.
cookieDuration = 30
)
// New creates a new cookie.
@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
SameSite: http.SameSiteLaxMode,
}
}

View File

@ -12,6 +12,8 @@ import (
"net/http"
"strings"
"time"
"github.com/andybalholm/brotli"
)
const compressionThreshold = 1024
@ -110,8 +112,15 @@ func (b *Builder) writeHeaders() {
func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold {
acceptEncoding := b.r.Header.Get("Accept-Encoding")
switch {
case strings.Contains(acceptEncoding, "br"):
b.headers["Content-Encoding"] = "br"
b.writeHeaders()
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
defer brotliWriter.Close()
brotliWriter.Write(data)
return
case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip"
b.writeHeaders()

View File

@ -228,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
func TestBuildResponseWithBrotliCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
@ -245,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "br"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
}
}
func TestBuildResponseWithGzipCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
New(w, r).WithBody(body).Write()
})
handler.ServeHTTP(w, r)
resp := w.Result()
expected := "gzip"
actual := resp.Header.Get("Content-Encoding")
if actual != expected {

View File

@ -19,6 +19,7 @@ import (
"miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli"
@ -359,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
}
}
if userIntegrations.OmnivoreEnabled {
slog.Debug("Sending entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID),
@ -376,6 +378,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
)
}
}
if userIntegrations.RaindropEnabled {
slog.Debug("Sending entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
slog.Error("Unable to send entry to Raindrop",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
}
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.

View File

@ -10,7 +10,7 @@ import (
"miniflux.app/v2/internal/model"
)
// PushEntry pushes entries to matrix chat using integration settings provided
// PushEntries pushes entries to matrix chat using integration settings provided
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
client := NewClient(matrixBaseURL)
discovery, err := client.DiscoverEndpoints()

View File

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

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "URL der Webseite",
"form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.description": "Beschreibung",
"form.feed.label.category": "Kategorie",
"form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.feed_username": "Benutzername des Abonnements",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Artikel in Readeck speichern",
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs"
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
@ -318,6 +319,7 @@
"form.feed.label.title": "Τίτλος",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.category": "Κατηγορία",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_api_key": "Κλειδί API Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους"
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "There are no starred entries.",
"alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this category.",
"alert.no_tag_entry": "There are no entries matching this tag.",
"alert.no_feed_entry": "There are no entries for this feed.",
"alert.no_feed": "You dont have any feeds.",
"alert.no_feed_in_category": "There is no feed for this category.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Description",
"form.feed.label.category": "Category",
"form.feed.label.crawler": "Fetch original content",
"form.feed.label.feed_username": "Feed Username",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Save entries to readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Readeck API key",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
"error.settings_media_playback_rate_range": "Playback speed is out of range"
"error.settings_media_playback_rate_range": "Playback speed is out of range",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta categoría.",
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
"alert.no_feed_entry": "No hay artículos para esta fuente.",
"alert.no_feed": "No tienes fuentes.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.description": "Descripción",
"form.feed.label.category": "Categoría",
"form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango"
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
@ -318,6 +319,7 @@
"form.feed.label.title": "Otsikko",
"form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.description": "Kuvaus",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_api_key": "Readeck API-avain",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella"
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed": "Vous n'avez aucun abonnement.",
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
"form.feed.label.description": "Description",
"form.feed.label.category": "Catégorie",
"form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
"form.integration.matrix_bot_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites"
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
"enclosure_media_controls.seek" : "Avancer/Reculer :",
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
"enclosure_media_controls.speed" : "Vitesse :",
"enclosure_media_controls.speed.faster" : "Accélérer",
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
"enclosure_media_controls.speed.slower" : "Ralentir",
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
"enclosure_media_controls.speed.reset" : "Réinitialiser",
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
@ -316,6 +317,7 @@
"form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.description": "विवरण",
"form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है"
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
@ -306,6 +307,7 @@
"form.feed.label.title": "Judul",
"form.feed.label.site_url": "URL Situs",
"form.feed.label.feed_url": "URL Umpan",
"form.feed.label.description": "Deskripsi",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.feed_username": "Nama Pengguna Umpan",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan"
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
"form.feed.label.description": "Descrizione",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.feed_username": "Nome utente del feed",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo"
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed": "何も購読していません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
@ -306,6 +307,7 @@
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
"form.feed.label.description": "説明",
"form.feed.label.category": "カテゴリ",
"form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.feed_username": "フィードのユーザー名",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Readeck に記事を保存する",
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_api_key": "Readeck の API key",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"error.settings_media_playback_rate_range": "再生速度が範囲外"
"error.settings_media_playback_rate_range": "再生速度が範囲外",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Beschrijving",
"form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed-gebruikersnaam",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Opslaan naar Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik"
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -268,6 +268,7 @@
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
@ -326,6 +327,7 @@
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
"form.feed.label.description": "Opis",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
@ -461,6 +463,10 @@
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key",
@ -545,5 +551,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem"
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -258,6 +258,7 @@
"alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta categoria.",
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
"alert.no_feed_entry": "Não há itens nessa fonte.",
"alert.no_feed": "Não há inscrições.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
@ -316,6 +317,7 @@
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte",
"form.feed.label.description": "Descrição",
"form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.feed_username": "Nome de usuário da fonte",
@ -451,6 +453,10 @@
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Salvar itens no Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck",
@ -528,5 +534,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo"
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -268,6 +268,7 @@
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.",
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_in_category": "Для этой категории нет подписки.",
@ -326,6 +327,7 @@
"form.feed.label.title": "Название",
"form.feed.label.site_url": "Адрес сайта",
"form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.description": "Описание",
"form.feed.label.category": "Категория",
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.feed_username": "Имя пользователя подписки",
@ -461,6 +463,10 @@
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_api_key": "API-ключ Readeck",
@ -545,5 +551,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона"
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -18,6 +18,7 @@
"alert.no_bookmark": "Yıldızlanmış makale yok.",
"alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
"alert.no_feed": "Hiç beslemeniz yok.",
"alert.no_feed_entry": "Bu besleme için makele yok.",
"alert.no_feed_in_category": "Bu kategori için besleme yok.",
@ -153,6 +154,7 @@
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.description": "Açıklama",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
@ -221,6 +223,10 @@
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
"form.integration.readeck_api_key": "Readeck API Anahtarı",
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
@ -495,5 +501,14 @@
"time_elapsed.years": ["%d yıl önce", "%d yıl önce"],
"time_elapsed.yesterday": "dün",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı"
"tooltip.logged_user": "%s olarak giriş yapıldı",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -268,6 +268,7 @@
"alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed": "У вас немає підписок.",
"alert.no_feed_in_category": "У цій категорії немає підписок.",
@ -326,6 +327,7 @@
"form.feed.label.title": "Назва",
"form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.description": "Опис",
"form.feed.label.category": "Категорія",
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
@ -461,6 +463,10 @@
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Зберігати статті до Readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Ключ API Readeck",
@ -545,5 +551,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону"
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章",
"alert.no_tag_entry": "没有与此标签匹配的条目。",
"alert.no_feed_entry": "该源中没有文章",
"alert.no_feed": "目前没有源",
"alert.no_history": "目前没有历史",
@ -306,6 +307,7 @@
"form.feed.label.title": "标题",
"form.feed.label.site_url": "源网站 URL",
"form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "类别",
"form.feed.label.crawler": "抓取全文内容",
"form.feed.label.feed_username": "源用户名",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "保存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_api_key": "Readeck API 密钥",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出范围"
"error.settings_media_playback_rate_range": "播放速度超出范围",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -248,6 +248,7 @@
"alert.no_bookmark": "目前沒有收藏",
"alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章",
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
"alert.no_feed_entry": "該Feed中沒有文章",
"alert.no_feed": "目前沒有Feed",
"alert.no_history": "目前沒有歷史",
@ -306,6 +307,7 @@
"form.feed.label.title": "標題",
"form.feed.label.site_url": "網站 URL",
"form.feed.label.feed_url": "訂閱 Feed URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "類別",
"form.feed.label.crawler": "下載原文內容",
"form.feed.label.feed_username": "Feed 使用者名稱",
@ -441,6 +443,10 @@
"form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "儲存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_api_key": "Readeck API 金鑰",
@ -511,5 +517,14 @@
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出範圍"
"error.settings_media_playback_rate_range": "播放速度超出範圍",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
}

View File

@ -28,6 +28,7 @@ type Feed struct {
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
@ -167,6 +168,7 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
@ -201,6 +203,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Title = *f.Title
}
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}

View File

@ -90,4 +90,8 @@ type Integration struct {
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
}

View File

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

View File

@ -109,6 +109,16 @@ func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
}
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
// We get the safe ciphers
ciphers := tls.CipherSuites()
if r.ignoreTLSErrors {
// and the insecure ones if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
ciphers = append(ciphers, tls.InsecureCipherSuites()...)
}
cipherSuites := []uint16{}
for _, cipher := range ciphers {
cipherSuites = append(cipherSuites, cipher.ID)
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
@ -128,6 +138,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
IdleConnTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{
CipherSuites: cipherSuites,
InsecureSkipVerify: r.ignoreTLSErrors,
},
}
@ -169,6 +180,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
}
req.Header = r.headers
req.Header.Set("Accept-Encoding", "br, gzip")
req.Header.Set("Accept", defaultAcceptHeader)
req.Header.Set("Connection", "close")

View File

@ -8,10 +8,12 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"miniflux.app/v2/internal/locale"
)
@ -71,12 +73,31 @@ func (r *ResponseHandler) Close() {
}
}
func (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {
contentEncoding := strings.ToLower(r.httpResponse.Header.Get("Content-Encoding"))
slog.Debug("Request response",
slog.String("effective_url", r.EffectiveURL()),
slog.String("content_length", r.httpResponse.Header.Get("Content-Length")),
slog.String("content_encoding", contentEncoding),
slog.String("content_type", r.httpResponse.Header.Get("Content-Type")),
)
reader := r.httpResponse.Body
switch contentEncoding {
case "br":
reader = NewBrotliReadCloser(r.httpResponse.Body)
case "gzip":
reader = NewGzipReadCloser(r.httpResponse.Body)
}
return http.MaxBytesReader(nil, reader, maxBodySize)
}
func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
return http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
return r.getReader(maxBodySize)
}
func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {
limitedReader := http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
limitedReader := r.getReader(maxBodySize)
buffer, err := io.ReadAll(limitedReader)
if err != nil && err != io.EOF {

View File

@ -29,6 +29,7 @@ func (h *Handler) Export(userID int64) (string, error) {
Title: feed.Title,
FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL,
Description: feed.Description,
CategoryName: feed.Category.Title,
})
}
@ -68,11 +69,12 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
}
feed := &model.Feed{
UserID: userID,
Title: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Category: category,
UserID: userID,
Title: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
Category: category,
}
h.store.CreateFeed(feed)

View File

@ -27,11 +27,12 @@ type opmlHeader struct {
}
type opmlOutline struct {
Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
Title string `xml:"title,attr,omitempty"`
Text string `xml:"text,attr"`
FeedURL string `xml:"xmlUrl,attr,omitempty"`
SiteURL string `xml:"htmlUrl,attr,omitempty"`
Description string `xml:"description,attr,omitempty"`
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
}
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {

View File

@ -34,6 +34,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
Title: outline.GetTitle(),
FeedURL: outline.FeedURL,
SiteURL: outline.GetSiteURL(),
Description: outline.Description,
CategoryName: category,
})
} else if outline.Outlines.HasChildren() {

View File

@ -33,7 +33,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
`
var expected SubcriptionList
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/"})
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/", Description: "Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media."})
subscriptions, err := Parse(bytes.NewBufferString(data))
if err != nil {

View File

@ -48,10 +48,11 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
for _, subscription := range groupedSubs[categoryName] {
category.Outlines = append(category.Outlines, opmlOutline{
Title: subscription.Title,
Text: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Title: subscription.Title,
Text: subscription.Title,
FeedURL: subscription.FeedURL,
SiteURL: subscription.SiteURL,
Description: subscription.Description,
})
}

View File

@ -9,12 +9,14 @@ type Subcription struct {
SiteURL string
FeedURL string
CategoryName string
Description string
}
// Equals compare two subscriptions.
func (s Subcription) Equals(subscription *Subcription) bool {
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
s.Description == subscription.Description
}
// SubcriptionList is a list of subscriptions.

View File

@ -23,10 +23,13 @@ import (
"miniflux.app/v2/internal/storage"
"github.com/PuerkitoBio/goquery"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/html"
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
@ -42,8 +45,9 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
slog.Debug("Processing entry",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("entry_hash", entry.Hash),
slog.String("entry_title", entry.Title),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
)
@ -52,14 +56,18 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
}
websiteURL := getUrlFromEntry(feed, entry)
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL)
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
if feed.Crawler && (entryIsNew || forceRefresh) {
slog.Debug("Scraping entry",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.String("entry_hash", entry.Hash),
slog.String("entry_title", entry.Title),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Bool("entry_is_new", entryIsNew),
slog.Bool("force_refresh", forceRefresh),
slog.String("website_url", websiteURL),
)
startTime := time.Now()
@ -90,7 +98,6 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
if scraperErr != nil {
slog.Warn("Unable to scrape entry",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
@ -98,7 +105,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
)
} else if content != "" {
// We replace the entry content only if the scraper doesn't return any error.
entry.Content = content
entry.Content = minifyEntryContent(content)
}
}
@ -134,7 +141,6 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
slog.Debug("Blocking entry based on rule",
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
@ -165,7 +171,6 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
slog.Debug("Allow entry based on rule",
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
@ -209,7 +214,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
}
if content != "" {
entry.Content = content
entry.Content = minifyEntryContent(content)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
@ -227,10 +232,16 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
if len(parts) >= 3 {
re := regexp.MustCompile(parts[1])
re, err := regexp.Compile(parts[1])
if err != nil {
slog.Error("Failed on regexp compilation",
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
slog.Any("error", err),
)
return url
}
url = re.ReplaceAllString(entry.URL, parts[2])
slog.Debug("Rewriting entry URL",
slog.Int64("entry_id", entry.ID),
slog.String("original_entry_url", entry.URL),
slog.String("rewritten_entry_url", url),
slog.Int64("feed_id", feed.ID),
@ -238,7 +249,6 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
)
} else {
slog.Debug("Cannot find search and replace terms for replace rule",
slog.Int64("entry_id", entry.ID),
slog.String("original_entry_url", entry.URL),
slog.String("rewritten_entry_url", url),
slog.Int64("feed_id", feed.ID),
@ -251,6 +261,11 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
}
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
if !user.ShowReadingTime {
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
return
}
if shouldFetchYouTubeWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL)
@ -266,7 +281,26 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(entry, feed)
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
if shouldFetchNebulaWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchNebulaWatchTime(entry.URL)
if err != nil {
slog.Warn("Unable to fetch Nebula watch time",
slog.Int64("user_id", user.ID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Int64("feed_id", feed.ID),
slog.String("feed_url", feed.FeedURL),
slog.Any("error", err),
)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
@ -285,14 +319,13 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(entry, feed)
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
}
}
// Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 {
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}
}
@ -305,6 +338,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
return urlMatchesYouTubePattern
}
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchNebulaWatchTime() {
return false
}
matches := nebulaRegex.FindStringSubmatch(entry.URL)
return matches != nil
}
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchOdyseeWatchTime() {
return false
@ -344,6 +385,38 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
return int(dur.Minutes()), nil
}
func fetchNebulaWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
defer responseHandler.Close()
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
return 0, localizedError.Error()
}
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
if docErr != nil {
return 0, docErr
}
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
// durs contains video watch time in seconds
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := strconv.ParseInt(durs, 10, 64)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur / 60), nil
}
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
@ -419,3 +492,19 @@ func isRecentEntry(entry *model.Entry) bool {
}
return false
}
func minifyEntryContent(entryContent string) string {
m := minify.New()
// Options required to avoid breaking the HTML content.
m.Add("text/html", &html.Minifier{
KeepEndTags: true,
KeepQuotes: true,
})
if minifiedHTML, err := m.String("text/html", entryContent); err == nil {
entryContent = minifiedHTML
}
return entryContent
}

View File

@ -117,3 +117,12 @@ func TestIsRecentEntry(t *testing.T) {
}
}
}
func TestMinifyEntryContent(t *testing.T) {
input := `<p> Some text with a <a href="http://example.org/"> link </a> </p>`
expected := `<p>Some text with a <a href="http://example.org/">link</a></p>`
result := minifyEntryContent(input)
if expected != result {
t.Errorf(`Unexpected result, got %q`, result)
}
}

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Package readtime provides a function to estimate the reading time of an article.
// Package readingtime provides a function to estimate the reading time of an article.
package readingtime
import (

View File

@ -24,13 +24,13 @@ var predefinedRules = map[string]string{
"monkeyuser.com": "add_image_title",
"mrlovenstein.com": "add_image_title",
"nedroid.com": "add_image_title",
"oglaf.com": "add_image_title",
"oglaf.com": `replace("media.oglaf.com/story/tt(.+).gif"|"media.oglaf.com/comic/$1.jpg"),add_image_title`,
"optipess.com": "add_image_title",
"peebleslab.com": "add_image_title",
"quantamagazine.org": `add_youtube_video_from_id, remove("h6:not(.byline,.post__title__kicker), #comments, .next-post__content, .footer__section, figure .outer--content, script")`,
"sentfromthemoon.com": "add_image_title",
"thedoghousediaries.com": "add_image_title",
"theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list")`,
"theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list, .hidden")`,
"treelobsters.com": "add_image_title",
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
"www.qwantz.com": "add_image_title,add_mailto_subject",

View File

@ -35,6 +35,7 @@ var predefinedRules = map[string]string{
"openingsource.org": "article.suxing-popup-gallery",
"osnews.com": "div.newscontent1",
"phoronix.com": "div.content",
"pitchfork.com": "#main-content",
"pseudo-sciences.org": "#art_main",
"quantamagazine.org": ".outer--content, figure, script",
"raywenderlich.com": "article",

View File

@ -5,10 +5,13 @@ package subscription // import "miniflux.app/v2/internal/reader/subscription"
import (
"bytes"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"regexp"
"strings"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/rssbridge"
@ -22,9 +25,19 @@ import (
"golang.org/x/net/html/charset"
)
type youtubeKind string
const (
youtubeIDKindChannel youtubeKind = "channel"
youtubeIDKindVideo youtubeKind = "video"
youtubeIDKindPlaylist youtubeKind = "playlist"
)
var (
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)$`)
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
youtubeHostRegex = regexp.MustCompile(`youtube\.com$`)
youtubeChannelRegex = regexp.MustCompile(`channel/(.*)$`)
errNotYoutubeUrl = fmt.Errorf("this website is not a YouTube page")
)
type SubscriptionFinder struct {
@ -74,31 +87,40 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil
}
// Step 2) Check if the website URL is a YouTube channel.
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
subscriptions, localizedError := f.FindSubscriptionsFromYouTubeChannelPage(websiteURL)
if localizedError != nil {
return nil, localizedError
subscriptions := make(Subscriptions, 1)
// Step 2) Parse URL to find feeds from YouTube.
kind, _, err := youtubeURLIDExtractor(websiteURL)
// If YouTube url has been detected, return the YouTube feed
if err == nil || !errors.Is(err, errNotYoutubeUrl) {
switch kind {
case youtubeIDKindChannel:
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeChannelPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
case youtubeIDKindVideo:
slog.Debug("Try to detect feeds from YouTube video page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeVideoPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
case youtubeIDKindPlaylist:
slog.Debug("Try to detect feeds from YouTube playlist page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubePlaylistPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
}
if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
}
if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube channel page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
// Step 3) Check if the website URL is a YouTube video.
slog.Debug("Try to detect feeds from YouTube video page", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeVideoPage(websiteURL)
if localizedError != nil {
return nil, localizedError
}
if len(subscriptions) > 0 {
slog.Debug("Subscriptions found from YouTube video page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
return subscriptions, nil
}
// Step 4) Parse web page to find feeds from HTML meta tags.
// Step 3) Parse web page to find feeds from HTML meta tags.
slog.Debug("Try to detect feeds from HTML meta tags",
slog.String("website_url", websiteURL),
slog.String("content_type", responseHandler.ContentType()),
@ -113,7 +135,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
return subscriptions, nil
}
// Step 5) Check if the website URL can use RSS-Bridge.
// Step 4) Check if the website URL can use RSS-Bridge.
if rssBridgeURL != "" {
slog.Debug("Try to detect feeds with RSS-Bridge", slog.String("website_url", websiteURL))
subscriptions, localizedError := f.FindSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL)
@ -127,7 +149,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
}
}
// Step 6) Check if the website has a known feed URL.
// Step 5) Check if the website has a known feed URL.
slog.Debug("Try to detect feeds from well-known URLs", slog.String("website_url", websiteURL))
subscriptions, localizedError = f.FindSubscriptionsFromWellKnownURLs(websiteURL)
if localizedError != nil {
@ -285,20 +307,24 @@ func (f *SubscriptionFinder) FindSubscriptionsFromRSSBridge(websiteURL, rssBridg
}
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
matches := youtubeChannelRegex.FindStringSubmatch(websiteURL)
kind, id, _ := youtubeURLIDExtractor(websiteURL)
if len(matches) == 2 {
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, matches[1])
if kind == youtubeIDKindChannel {
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, id)
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
}
slog.Debug("This website is not a YouTube channel page, the regex doesn't match", slog.String("website_url", websiteURL))
return nil, nil
}
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
if !youtubeVideoRegex.MatchString(websiteURL) {
kind, _, err := youtubeURLIDExtractor(websiteURL)
if err != nil {
slog.Debug("Could not parse url", slog.String("website_url", websiteURL))
}
if kind != youtubeIDKindVideo {
slog.Debug("This website is not a YouTube video page, the regex doesn't match", slog.String("website_url", websiteURL))
return nil, nil
}
@ -322,3 +348,51 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL st
return nil, nil
}
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubePlaylistPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
kind, id, _ := youtubeURLIDExtractor(websiteURL)
if kind == youtubeIDKindPlaylist {
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?playlist_id=%s`, id)
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
}
slog.Debug("This website is not a YouTube playlist page, the regex doesn't match", slog.String("website_url", websiteURL))
return nil, nil
}
func youtubeURLIDExtractor(websiteURL string) (idKind youtubeKind, id string, err error) {
decodedUrl, err := url.Parse(websiteURL)
if err != nil {
return
}
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
err = errNotYoutubeUrl
return
}
switch {
case strings.HasPrefix(decodedUrl.Path, "/channel"):
idKind = youtubeIDKindChannel
matches := youtubeChannelRegex.FindStringSubmatch(decodedUrl.Path)
id = matches[1]
return
case strings.HasPrefix(decodedUrl.Path, "/watch") && decodedUrl.Query().Has("list"):
idKind = youtubeIDKindPlaylist
id = decodedUrl.Query().Get("list")
return
case strings.HasPrefix(decodedUrl.Path, "/watch"):
idKind = youtubeIDKindVideo
id = decodedUrl.Query().Get("v")
return
case strings.HasPrefix(decodedUrl.Path, "/playlist"):
idKind = youtubeIDKindPlaylist
id = decodedUrl.Query().Get("list")
return
}
err = fmt.Errorf("unable to extract youtube id from URL: %s", websiteURL)
return
}

View File

@ -4,10 +4,99 @@
package subscription
import (
"errors"
"strings"
"testing"
)
func TestFindYoutubePlaylistFeed(t *testing.T) {
scenarios := map[string]string{
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
}
for websiteURL, expectedFeedURL := range scenarios {
subscriptions, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubePlaylistPage(websiteURL)
if localizedError != nil {
t.Fatalf(`Parsing a correctly formatted YouTube playlist page should not return any error: %v`, localizedError)
}
if len(subscriptions) != 1 {
t.Fatal(`Incorrect number of subscriptions returned`)
}
if subscriptions[0].URL != expectedFeedURL {
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, expectedFeedURL)
}
}
}
func TestItDoesNotConsiderPlaylistWatchPageAsVideoWatchPage(t *testing.T) {
_, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubeVideoPage("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM")
if localizedError != nil {
t.Fatalf(`Should not consider a playlist watch page as a video watch page`)
}
}
func TestYoutubeIdExtractor(t *testing.T) {
type testResult struct {
ID string
Kind youtubeKind
error error
}
urls := map[string]testResult{
"https://www.youtube.com/watch?v=dQw4w9WgXcQ": {
ID: "dQw4w9WgXcQ",
Kind: youtubeIDKindVideo,
error: nil,
},
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1": {
ID: "dQw4w9WgXcQ",
Kind: youtubeIDKindVideo,
error: nil,
},
"https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ": {
ID: "dQw4w9WgXcQ",
Kind: youtubeIDKindVideo,
error: nil,
},
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": {
ID: "PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
Kind: youtubeIDKindPlaylist,
error: nil,
},
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR": {
ID: "PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
Kind: youtubeIDKindPlaylist,
error: nil,
},
"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": {
ID: "UC-Qj80avWItNRjkZ41rzHyw",
Kind: youtubeIDKindChannel,
error: nil,
},
"https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw": {
ID: "",
Kind: "",
error: errNotYoutubeUrl,
},
}
for websiteURL, expected := range urls {
kind, id, err := youtubeURLIDExtractor(websiteURL)
if !errors.Is(err, expected.error) {
t.Fatalf(`Unexpected error: %v got %v`, expected.error, err)
}
if id != expected.ID {
t.Fatalf(`Unexpected ID: %v got %v`, expected.ID, id)
}
if kind != expected.Kind {
t.Fatalf(`Unexpected Kind: %v got %v`, expected.Kind, kind)
}
}
}
func TestFindYoutubeChannelFeed(t *testing.T) {
scenarios := map[string]string{
"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": "https://www.youtube.com/feeds/videos.xml?channel_id=UC-Qj80avWItNRjkZ41rzHyw",

View File

@ -225,24 +225,27 @@ func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {
return result, nil
}
// GetReadTime fetches the read time of an entry based on its hash, and the feed id and user id from the feed.
// It's intended to be used on entries objects created by parsing a feed as they don't contain much information.
// The feed param helps to scope the search to a specific user and feed in order to avoid hash clashes.
func (s *Storage) GetReadTime(entry *model.Entry, feed *model.Feed) int {
func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
var result bool
s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2`, feedID, entryHash).Scan(&result)
return !result
}
func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
var result int
// Note: This query uses entries_feed_id_hash_key index
s.db.QueryRow(
`SELECT
reading_time
FROM
entries
WHERE
user_id=$1 AND
feed_id=$2 AND
hash=$3
feed_id=$1 AND
hash=$2
`,
feed.UserID,
feed.ID,
entry.Hash,
feedID,
entryHash,
).Scan(&result)
return result
}
@ -575,14 +578,6 @@ func (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time)
return nil
}
// EntryURLExists returns true if an entry with this URL already exists.
func (s *Storage) EntryURLExists(feedID int64, entryURL string) bool {
var result bool
query := `SELECT true FROM entries WHERE feed_id=$1 AND url=$2`
s.db.QueryRow(query, feedID, entryURL).Scan(&result)
return result
}
// EntryShareCode returns the share code of the provided entry.
// It generates a new one if not already defined.
func (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) {

View File

@ -58,6 +58,15 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
}
}
func (e *EntryPaginationBuilder) WithTags(tags []string) {
if len(tags) > 0 {
for _, tag := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, tag)
}
}
}
// WithGloballyVisible adds global visibility to the condition.
func (e *EntryPaginationBuilder) WithGloballyVisible() {
e.conditions = append(e.conditions, "not c.hide_globally")

View File

@ -160,7 +160,7 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
if len(tags) > 0 {
for _, cat := range tags {
e.conditions = append(e.conditions, fmt.Sprintf("$%d = ANY(e.tags)", len(e.args)+1))
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
e.args = append(e.args, cat)
}
}
@ -281,6 +281,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
f.title as feed_title,
f.feed_url,
f.site_url,
f.description,
f.checked_at,
f.category_id,
c.title as category_title,
@ -347,6 +348,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.Feed.Title,
&entry.Feed.FeedURL,
&entry.Feed.SiteURL,
&entry.Feed.Description,
&entry.Feed.CheckedAt,
&entry.Feed.Category.ID,
&entry.Feed.Category.Title,

View File

@ -238,10 +238,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
url_rewrite_rules,
no_media_player,
apprise_service_urls,
disable_http2
disable_http2,
description
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
RETURNING
id
`
@ -272,6 +273,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
feed.NoMediaPlayer,
feed.AppriseServiceURLs,
feed.DisableHTTP2,
feed.Description,
).Scan(&feed.ID)
if err != nil {
return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
@ -344,9 +346,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
url_rewrite_rules=$25,
no_media_player=$26,
apprise_service_urls=$27,
disable_http2=$28
disable_http2=$28,
description=$29
WHERE
id=$29 AND user_id=$30
id=$30 AND user_id=$31
`
_, err = s.db.Exec(query,
feed.FeedURL,
@ -377,6 +380,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
feed.NoMediaPlayer,
feed.AppriseServiceURLs,
feed.DisableHTTP2,
feed.Description,
feed.ID,
feed.UserID,
)

View File

@ -135,6 +135,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
f.feed_url,
f.site_url,
f.title,
f.description,
f.etag_header,
f.last_modified_header,
f.user_id,
@ -202,6 +203,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
&feed.FeedURL,
&feed.SiteURL,
&feed.Title,
&feed.Description,
&feed.EtagHeader,
&feed.LastModifiedHeader,
&feed.UserID,

View File

@ -193,7 +193,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
rssbridge_url,
omnivore_enabled,
omnivore_api_key,
omnivore_url
omnivore_url,
raindrop_enabled,
raindrop_token,
raindrop_collection_id,
raindrop_tags
FROM
integrations
WHERE
@ -286,6 +290,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.OmnivoreEnabled,
&integration.OmnivoreAPIKey,
&integration.OmnivoreURL,
&integration.RaindropEnabled,
&integration.RaindropToken,
&integration.RaindropCollectionID,
&integration.RaindropTags,
)
switch {
case err == sql.ErrNoRows:
@ -386,9 +394,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
omnivore_url=$81,
linkwarden_enabled=$82,
linkwarden_url=$83,
linkwarden_api_key=$84
linkwarden_api_key=$84,
raindrop_enabled=$85,
raindrop_token=$86,
raindrop_collection_id=$87,
raindrop_tags=$88
WHERE
user_id=$85
user_id=$89
`
_, err := s.db.Exec(
query,
@ -476,6 +488,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.LinkwardenEnabled,
integration.LinkwardenURL,
integration.LinkwardenAPIKey,
integration.RaindropEnabled,
integration.RaindropToken,
integration.RaindropCollectionID,
integration.RaindropTags,
integration.UserID,
)
@ -513,7 +529,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
readeck_enabled='t' OR
shaarli_enabled='t' OR
webhook_enabled='t' OR
omnivore_enabled='t'
omnivore_enabled='t' OR
raindrop_enabled='t'
)
`
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {

View File

@ -8,6 +8,7 @@ import (
"html/template"
"math"
"net/mail"
"net/url"
"slices"
"strings"
"time"
@ -91,8 +92,9 @@ func (f *funcMap) Map() template.FuncMap {
"nonce": func() string {
return crypto.GenerateRandomStringHex(16)
},
"deRef": func(i *int) int { return *i },
"duration": duration,
"deRef": func(i *int) int { return *i },
"duration": duration,
"urlEncode": url.PathEscape,
// These functions are overrode at runtime after the parsing.
"elapsed": func(timezone string, t time.Time) string {

View File

@ -0,0 +1,18 @@
{{ define "enclosure_media_controls" }}
<div class="media-controls">
<div class="media-seek-control">
<div class="media-control-label">{{ t "enclosure_media_controls.seek" }} </div>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="-30" title="{{ t "enclosure_media_controls.seek.title" "-30" }}" ><span class="icon-label" >-30s</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="-10" title="{{ t "enclosure_media_controls.seek.title" "-10" }}" ><span class="icon-label" >-10s</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="+10" title="{{ t "enclosure_media_controls.seek.title" "+10" }}" ><span class="icon-label" >+10s</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="+30" title="{{ t "enclosure_media_controls.seek.title" "+30" }}" ><span class="icon-label" >+30s</span></button>
</div>
<div class="media-speed-control">
<div class="media-control-label">{{ t "enclosure_media_controls.speed" }} (<span class="speed-indicator" data-enclosure-id="{{.ID}}">1.00x</span>)</div> <!-- Need JS to display the current speed unfortunately -->
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed" data-action-value="-0.25" title="{{ t "enclosure_media_controls.speed.slower.title" "0.25" }}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.slower" }}</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed-reset" data-action-value="1" title="{{ t "enclosure_media_controls.speed.reset.title"}}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.reset" }}</span></button>
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed" data-action-value="+0.25" title="{{ t "enclosure_media_controls.speed.faster.title" "0.25" }}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.faster" }}</span></button>
</div>
</div>
{{ end }}

View File

@ -6,7 +6,7 @@
<title>{{template "title" .}} - Miniflux</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Miniflux">
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">

View File

@ -63,6 +63,9 @@
<label for="form-feed-url">{{ t "form.feed.label.feed_url" }}</label>
<input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" spellcheck="false" required>
<label for="form-description">{{ t "form.feed.label.description" }}</label>
<textarea name="description" id="form-description" cols="40" rows="10" >{{ .form.Description }}</textarea>
{{ if not .form.CategoryHidden }}
<label><input type="checkbox" name="hide_globally" value="1"{{ if .form.HideGlobally }} checked{{ end }}> {{ t "form.feed.label.hide_globally" }}</label>
{{ end }}

View File

@ -135,7 +135,7 @@
{{ if .entry.Tags }}
<div class="entry-tags">
{{ t "entry.tags.label" }}
{{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<strong>{{ $e }}</strong>{{end}}
{{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<a href="{{ route "tagEntriesAll" "tagName" (urlEncode $e) }}"><strong>{{ $e }}</strong></a>{{end}}
</div>
{{ end }}
<div class="entry-date">
@ -174,6 +174,7 @@
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -181,6 +182,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video">
@ -188,6 +190,7 @@
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -195,6 +198,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ end }}
{{ end }}
@ -218,6 +222,7 @@
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -225,6 +230,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video">
@ -232,6 +238,7 @@
data-last-position="{{ .MediaProgression }}"
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
@ -239,6 +246,7 @@
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "image/" }}
<div class="enclosure-image">

View File

@ -326,6 +326,28 @@
</div>
</details>
<details {{ if .form.RaindropEnabled }}open{{ end }}>
<summary>Raindrop</summary>
<div class="form-section">
<label>
<input type="checkbox" name="raindrop_enabled" value="1" {{ if .form.RaindropEnabled }}checked{{ end }}> {{ t "form.integration.raindrop_activate" }}
</label>
<label for="form-raindrop-token">{{ t "form.integration.raindrop_token" }}</label>
<input type="text" name="raindrop_token" id="form-raindrop-token" value="{{ .form.RaindropToken }}" spellcheck="false">
<label for="form-raindrop-collection-id">{{ t "form.integration.raindrop_collection_id" }}</label>
<input type="text" name="raindrop_collection_id" id="form-raindrop-collection-id" value="{{ .form.RaindropCollectionID }}" spellcheck="false">
<label for="form-raindrop-tags">{{ t "form.integration.raindrop_tags" }}</label>
<input type="text" name="raindrop_tags" id="form-raindrop-tags" value="{{ .form.RaindropTags }}" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
</div>
</details>
<details {{ if .form.ReadeckEnabled }}open{{ end }}>
<summary>Readeck</summary>
<div class="form-section">

View File

@ -0,0 +1,52 @@
{{ define "title"}}{{ .tagName }} ({{ .total }}){{ end }}
{{ define "page_header"}}
<section class="page-header" aria-labelledby="page-header-title page-header-title-count">
<h1 id="page-header-title" dir="auto">
{{ .tagName }}
<span aria-hidden="true"> ({{ .total }})</span>
</h1>
<span id="page-header-title-count" class="sr-only">{{ plural "page.tag_entry_count" .total .total }}</span>
</section>
{{ end }}
{{ define "content"}}
{{ if not .entries }}
<p role="alert" class="alert alert-info">{{ t "alert.no_tag_entry" }}</p>
{{ else }}
<div class="pagination-top">
{{ template "pagination" .pagination }}
</div>
<div class="items">
{{ range .entries }}
<article
class="item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}"
data-id="{{ .ID }}"
aria-labelledby="entry-title-{{ .ID }}"
tabindex="-1"
>
<header class="item-header" dir="auto">
<h2 id="entry-title-{{ .ID }}" class="item-title">
<a href="{{ route "tagEntry" "entryID" .ID "tagName" (urlEncode $.tagName) }}">
{{ if ne .Feed.Icon.IconID 0 }}
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="">
{{ end }}
{{ .Title }}
</a>
</h2>
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
{{ .Feed.Category.Title }}
</a>
</span>
</header>
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
<div class="pagination-bottom">
{{ template "pagination" .pagination }}
</div>
{{ end }}
{{ end }}

View File

@ -8,6 +8,7 @@ import (
"net/http"
"time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route"
@ -32,8 +33,9 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64
sess := session.New(h.store, request.SessionID(r))
// Avoid accidental and excessive refreshes.
if time.Now().UTC().Unix()-request.LastForceRefresh(r) < 1800 {
sess.NewFlashErrorMessage(printer.Print("alert.too_many_feeds_refresh"))
if time.Now().UTC().Unix()-request.LastForceRefresh(r) < int64(config.Opts.ForceRefreshInterval())*60 {
time := config.Opts.ForceRefreshInterval()
sess.NewFlashErrorMessage(printer.Plural("alert.too_many_feeds_refresh", time, time))
} else {
// We allow the end-user to force refresh all its feeds in this category
// without taking into consideration the number of errors.

90
internal/ui/entry_tag.go Normal file
View File

@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package ui // import "miniflux.app/v2/internal/ui"
import (
"net/http"
"net/url"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view"
)
func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
if err != nil {
html.ServerError(w, r, err)
return
}
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithTags([]string{tagName})
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
html.ServerError(w, r, err)
return
}
if entry == nil {
html.NotFound(w, r)
return
}
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
if err != nil {
html.ServerError(w, r, err)
return
}
entry.Status = model.EntryStatusRead
}
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
entryPaginationBuilder.WithTags([]string{tagName})
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
if err != nil {
html.ServerError(w, r, err)
return
}
nextEntryRoute := ""
if nextEntry != nil {
nextEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", nextEntry.ID)
}
prevEntryRoute := ""
if prevEntry != nil {
prevEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", prevEntry.ID)
}
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("entry", entry)
view.Set("prevEntry", prevEntry)
view.Set("nextEntry", nextEntry)
view.Set("nextEntryRoute", nextEntryRoute)
view.Set("prevEntryRoute", prevEntryRoute)
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
html.OK(w, r, view.Render("entry"))
}

View File

@ -43,6 +43,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
SiteURL: feed.SiteURL,
FeedURL: feed.FeedURL,
Title: feed.Title,
Description: feed.Description,
ScraperRules: feed.ScraperRules,
RewriteRules: feed.RewriteRules,
BlocklistRules: feed.BlocklistRules,

View File

@ -29,7 +29,9 @@ func (h *handler) showIcon(w http.ResponseWriter, r *http.Request) {
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
b.WithHeader("Content-Type", icon.MimeType)
b.WithBody(icon.Content)
b.WithoutCompression()
if icon.MimeType != "image/svg+xml" {
b.WithoutCompression()
}
b.Write()
})
}

View File

@ -59,6 +59,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
FeedURL: model.OptionalString(feedForm.FeedURL),
SiteURL: model.OptionalString(feedForm.SiteURL),
Title: model.OptionalString(feedForm.Title),
Description: model.OptionalString(feedForm.Description),
CategoryID: model.OptionalNumber(feedForm.CategoryID),
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),

View File

@ -15,6 +15,7 @@ type FeedForm struct {
FeedURL string
SiteURL string
Title string
Description string
ScraperRules string
RewriteRules string
BlocklistRules string
@ -43,6 +44,7 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
feed.Title = f.Title
feed.SiteURL = f.SiteURL
feed.FeedURL = f.FeedURL
feed.Description = f.Description
feed.ScraperRules = f.ScraperRules
feed.RewriteRules = f.RewriteRules
feed.BlocklistRules = f.BlocklistRules
@ -76,6 +78,7 @@ func NewFeedForm(r *http.Request) *FeedForm {
FeedURL: r.FormValue("feed_url"),
SiteURL: r.FormValue("site_url"),
Title: r.FormValue("title"),
Description: r.FormValue("description"),
ScraperRules: r.FormValue("scraper_rules"),
UserAgent: r.FormValue("user_agent"),
Cookie: r.FormValue("cookie"),

View File

@ -96,6 +96,10 @@ type IntegrationForm struct {
OmnivoreEnabled bool
OmnivoreAPIKey string
OmnivoreURL string
RaindropEnabled bool
RaindropToken string
RaindropCollectionID string
RaindropTags string
}
// Merge copy form values to the model.
@ -181,6 +185,10 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.OmnivoreEnabled = i.OmnivoreEnabled
integration.OmnivoreAPIKey = i.OmnivoreAPIKey
integration.OmnivoreURL = i.OmnivoreURL
integration.RaindropEnabled = i.RaindropEnabled
integration.RaindropToken = i.RaindropToken
integration.RaindropCollectionID = i.RaindropCollectionID
integration.RaindropTags = i.RaindropTags
}
// NewIntegrationForm returns a new IntegrationForm.
@ -269,6 +277,10 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
OmnivoreEnabled: r.FormValue("omnivore_enabled") == "1",
OmnivoreAPIKey: r.FormValue("omnivore_api_key"),
OmnivoreURL: r.FormValue("omnivore_url"),
RaindropEnabled: r.FormValue("raindrop_enabled") == "1",
RaindropToken: r.FormValue("raindrop_token"),
RaindropCollectionID: r.FormValue("raindrop_collection_id"),
RaindropTags: r.FormValue("raindrop_tags"),
}
}

View File

@ -110,6 +110,10 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
OmnivoreEnabled: integration.OmnivoreEnabled,
OmnivoreAPIKey: integration.OmnivoreAPIKey,
OmnivoreURL: integration.OmnivoreURL,
RaindropEnabled: integration.RaindropEnabled,
RaindropToken: integration.RaindropToken,
RaindropCollectionID: integration.RaindropCollectionID,
RaindropTags: integration.RaindropTags,
}
sess := session.New(h.store, request.SessionID(r))

View File

@ -30,6 +30,12 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
creds, err := h.store.WebAuthnCredentialsByUserID(loggedUser.ID)
if err != nil {
html.ServerError(w, r, err)
return
}
settingsForm := form.NewSettingsForm(r)
sess := session.New(h.store, request.SessionID(r))
@ -42,6 +48,10 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
view.Set("user", loggedUser)
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
view.Set("default_home_pages", model.HomePages())
view.Set("categories_sorting_options", model.CategoriesSortingOptions())
view.Set("countWebAuthnCerts", h.store.CountWebAuthnCredentialsByUserID(loggedUser.ID))
view.Set("webAuthnCerts", creds)
if validationErr := settingsForm.Validate(); validationErr != nil {
view.Set("errorMessage", validationErr.Translate(loggedUser.Language))

View File

@ -1215,6 +1215,39 @@ audio, video {
width: 100%;
}
.media-controls{
font-size: .9em;
display: flex;
flex-wrap: wrap;
}
.media-controls .media-control-label{
line-height: 1em;
}
.media-controls>div{
display: flex;
flex-wrap: nowrap;
justify-content:center;
min-width: 50%;
align-items: center;
}
.media-controls>div>*{
padding-left:12px;
}
.media-controls>div>*:first-child{
padding-left:0;
}
.media-controls span.speed-indicator{
/*monospace to ensure constant width even when value change. JS ensure the value is always on 4 characters (in most cases)
This reduce ui flickering due to element moving around a bit
*/
font-family: monospace;
}
.integration-form summary {
font-weight: 700;
}

View File

@ -86,7 +86,8 @@ function onClickMainMenuListItem(event) {
if (element.tagName === "A") {
window.location.href = element.getAttribute("href");
} else {
window.location.href = element.querySelector("a").getAttribute("href");
const linkElement = element.querySelector("a") || element.closest("a");
window.location.href = linkElement.getAttribute("href");
}
}
@ -445,8 +446,8 @@ function goToPage(page, fallbackSelf) {
}
/**
*
* @param {(number|event)} offset - many items to jump for focus.
*
* @param {(number|event)} offset - many items to jump for focus.
*/
function goToPrevious(offset) {
if (offset instanceof KeyboardEvent) {
@ -460,8 +461,8 @@ function goToPrevious(offset) {
}
/**
*
* @param {(number|event)} offset - How many items to jump for focus.
*
* @param {(number|event)} offset - How many items to jump for focus.
*/
function goToNext(offset) {
if (offset instanceof KeyboardEvent) {
@ -520,7 +521,7 @@ function goToListItem(offset) {
items[i].classList.remove("current-item");
// By default adjust selection by offset
let itemOffset = (i + offset + items.length) % items.length;
let itemOffset = (i + offset + items.length) % items.length;
// Allow jumping to top or bottom
if (offset == TOP) {
itemOffset = 0;
@ -741,3 +742,43 @@ function getCsrfToken() {
return "";
}
/**
* Handle all clicks on media player controls button on enclosures.
* Will change the current speed and position of the player accordingly.
* Will not save anything, all is done client-side, however, changing the position
* will trigger the handlePlayerProgressionSave and save the new position backends side.
* @param {Element} button
*/
function handleMediaControl(button) {
const action = button.dataset.enclosureAction;
const value = parseFloat(button.dataset.actionValue);
const targetEnclosureId = button.dataset.enclosureId;
const enclosures = document.querySelectorAll(`audio[data-enclosure-id="${targetEnclosureId}"],video[data-enclosure-id="${targetEnclosureId}"]`);
const speedIndicator = document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${targetEnclosureId}"]`);
enclosures.forEach((enclosure) => {
switch (action) {
case "seek":
enclosure.currentTime = enclosure.currentTime + value > 0 ? enclosure.currentTime + value : 0;
break;
case "speed":
// I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped.
// 0.25 was chosen because it will allow to get back to 1x in two "faster" click, and lower value with same property would be 0.
enclosure.playbackRate = Math.max(0.25, enclosure.playbackRate + value);
speedIndicator.forEach((speedI) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`;
});
break;
case "speed-reset":
enclosure.playbackRate = value ;
speedIndicator.forEach((speedI) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`;
});
break;
}
});
}

View File

@ -167,6 +167,20 @@ document.addEventListener("DOMContentLoaded", () => {
playbackRateElements.forEach((element) => {
if (element.dataset.playbackRate) {
element.playbackRate = element.dataset.playbackRate;
if (element.dataset.enclosureId){
// In order to display properly the speed we need to do it on bootstrap.
// Could not do it backend side because I didn't know how to do it because of the template inclusion and
// the way the initial playback speed is handled. See enclosure_media_controls.html if you want to try to fix this
document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${element.dataset.enclosureId}"]`).forEach((speedI)=>{
speedI.innerText = `${parseFloat(element.dataset.playbackRate).toFixed(2)}x`;
});
}
}
});
// Set enclosure media controls handlers
const mediaControlsElements = document.querySelectorAll("button[data-enclosure-action]");
mediaControlsElements.forEach((element) => {
element.addEventListener("click", () => handleMediaControl(element));
});
});

View File

@ -31,12 +31,12 @@ func (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) {
switch filepath.Ext(filename) {
case ".png":
b.WithoutCompression()
b.WithHeader("Content-Type", "image/png")
case ".svg":
b.WithHeader("Content-Type", "image/svg+xml")
}
b.WithoutCompression()
b.WithBody(blob)
b.Write()
})

View File

@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package ui // import "miniflux.app/v2/internal/ui"
import (
"net/http"
"net/url"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view"
)
func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
html.ServerError(w, r, err)
return
}
tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
if err != nil {
html.ServerError(w, r, err)
return
}
offset := request.QueryIntParam(r, "offset", 0)
builder := h.store.NewEntryQueryBuilder(user.ID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithTags([]string{tagName})
builder.WithSorting("status", "asc")
builder.WithSorting(user.EntryOrder, user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(user.EntriesPerPage)
entries, err := builder.GetEntries()
if err != nil {
html.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
html.ServerError(w, r, err)
return
}
sess := session.New(h.store, request.SessionID(r))
view := view.New(h.tpl, r, sess)
view.Set("tagName", tagName)
view.Set("total", count)
view.Set("entries", entries)
view.Set("pagination", getPagination(route.Path(h.router, "tagEntriesAll", "tagName", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
view.Set("user", user)
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
view.Set("showOnlyUnreadEntries", false)
html.OK(w, r, view.Render("tag_entries"))
}

View File

@ -93,6 +93,10 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost)
// Tag pages.
uiRouter.HandleFunc("/tags/{tagName}/entries/all", handler.showTagEntriesAllPage).Name("tagEntriesAll").Methods(http.MethodGet)
uiRouter.HandleFunc("/tags/{tagName}/entry/{entryID}", handler.showTagEntryPage).Name("tagEntry").Methods(http.MethodGet)
// Entry pages.
uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)

View File

@ -244,6 +244,12 @@ Set the value to 1 to disable the internal scheduler service\&.
.br
Default is false (The internal scheduler service is enabled)\&.
.TP
.B FETCH_NEBULA_WATCH_TIME
Set the value to 1 to scrape video duration from Nebula website and
use it as a reading time\&.
.br
Disabled by default\&.
.TP
.B FETCH_ODYSEE_WATCH_TIME
Set the value to 1 to scrape video duration from Odysee website and
use it as a reading time\&.

View File

@ -1,6 +1,4 @@
ARG BASE_IMAGE_ARCH="amd64"
FROM ${BASE_IMAGE_ARCH}/golang:1.22-bookworm AS build
FROM docker.io/golang:1.22-bookworm AS build
ENV DEBIAN_FRONTEND=noninteractive

View File

@ -1,14 +1,10 @@
FROM golang:alpine AS build
ENV CGO_ENABLED=0
RUN apk add --no-cache --update git
FROM docker.io/library/golang:alpine3.19 AS build
RUN apk add --no-cache build-base git make
ADD . /go/src/app
WORKDIR /go/src/app
RUN go build \
-o miniflux \
-ldflags="-s -w -X 'miniflux.app/v2/internal/version.Version=`git describe --tags --abbrev=0`' -X 'miniflux.app/v2/internal/version.Commit=`git rev-parse --short HEAD`' -X 'miniflux.app/v2/internal/version.BuildDate=`date +%FT%T%z`'" \
main.go
RUN make miniflux
FROM alpine:latest
FROM docker.io/library/alpine:3.20
LABEL org.opencontainers.image.title=Miniflux
LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader"

View File

@ -1,13 +1,9 @@
FROM golang:latest AS build
ENV CGO_ENABLED=0
FROM docker.io/library/golang:bookworm AS build
ADD . /go/src/app
WORKDIR /go/src/app
RUN go build \
-o miniflux \
-ldflags="-s -w -X 'miniflux.app/v2/internal/version.Version=`git describe --tags --abbrev=0`' -X 'miniflux.app/v2/internal/version.Commit=`git rev-parse --short HEAD`' -X 'miniflux.app/v2/internal/version.BuildDate=`date +%FT%T%z`'" \
main.go
RUN make miniflux
FROM gcr.io/distroless/base:nonroot
FROM gcr.io/distroless/base-debian12:nonroot
LABEL org.opencontainers.image.title=Miniflux
LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader"