Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33063a7775 | |||
| 49c62db2e1 | |||
| 400e8974f9 | |||
| 9c82e55b98 | |||
| c9c422b135 | |||
| 91f9a7650e | |||
| 605eeb4525 | |||
| eb6991ae49 | |||
| eac5d59f5b | |||
| 3861f1aa67 | |||
| 6af0cd5b5c | |||
| fccca0ce1e | |||
| 8c3a9184ac | |||
| 3b317b0b80 | |||
| a702bf0342 | |||
| e9520f5d1c | |||
| f5fde36d45 | |||
| 6cbe8c3a9d | |||
| 1e54a073d3 | |||
| bf66ef5a0f | |||
| 9b25ea4ed6 | |||
| f116f7dd6a | |||
| e540547104 | |||
| 5b1abbf340 | |||
| 0d72cef82e | |||
| 79ec6ef81f | |||
| 8d4954e29b | |||
| c81e17c20d | |||
| 5549f75dd7 | |||
| fc3c4873e5 | |||
| b87c547a07 | |||
| 3466e9e2d6 | |||
| 8df4b780a8 | |||
| e22520fc55 | |||
| 518bc4d6ff | |||
| 89620a7dd2 | |||
| bbfe39722a | |||
| f3989cdb2f | |||
| 195b75d185 | |||
| f52411f734 | |||
| b93543f416 | |||
| 057f760196 | |||
| 28fe053329 | |||
| d345c87376 | |||
| bd91e5f320 | |||
| 276b2d8b0b | |||
| bca9bea676 | |||
| 7346d751cc | |||
| 7939b54341 | |||
| a06657b74d | |||
| 2df59b4865 | |||
| 777d0dd248 | |||
| cfda948c3a | |||
| 14a6e8ed3a | |||
| c3e842eba6 | |||
| fd9cfd757a | |||
| 945d436055 | |||
| c3649bd6b1 | |||
| 6ad5ad0bb2 | |||
| 113abeea59 | |||
| e6185b1393 | |||
| 1b0b8b9c42 | |||
| 68448b4abb | |||
| 3caa16ac31 | |||
| 637fb85de0 | |||
| 02c6d14659 | |||
| 728423339a | |||
| eed3fcf92a | |||
| d5cfcf8956 | |||
| dea46ac0ea | |||
| 30c44380e0 | |||
| a913f3f75f | |||
| c1ef986cab | |||
| 2671f57edd | |||
| 2f56ebd3a6 | |||
| 059f5c0905 | |||
| 58178d90cb | |||
| cc885bbabb | |||
| 0e185849b4 | |||
| d0984f29da | |||
| 902ca63c45 | |||
| 2314500515 | |||
| 787d373211 | |||
| fefbf2c935 | |||
| bfb429b919 | |||
| 331c831c23 | |||
| 92a49d7e69 | |||
| 8cdf76df69 | |||
| 7bc0bffd85 | |||
| 3a18e5d205 | |||
| c3ca603960 | |||
| 7e2b50efee | |||
| b61ee15c1b | |||
| b7b0ccbf4b | |||
| 9c5228b2ee | |||
| 53beec685b | |||
| b1fb8be185 | |||
| 3a966c6ce5 | |||
| b2e702218d | |||
| 30c2e09a56 | |||
| c6c71c58b8 | |||
| 6eb1f25a53 | |||
| f0fe91172f | |||
| c3016a4c55 | |||
| 3a028a0669 | |||
| da951164d5 | |||
| a0988f6c16 | |||
| fddc861e41 | |||
| 9288001f09 | |||
| da261a5fc7 | |||
| edeb5f0366 | |||
| 65ff328804 | |||
| 7759ea1b43 | |||
| 7c5d6cf35f | |||
| 39cc1887ea | |||
| b0a3b4d5d9 | |||
| 469f23968e |
@@ -1,7 +1,7 @@
|
||||
Do you follow the guidelines?
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] There is no breaking changes
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] I really tested my changes and there is no regression
|
||||
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
|
||||
@@ -9,13 +9,13 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
check-latest: true
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Compile binaries
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
@@ -5,9 +5,17 @@ permissions: read-all
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.go'
|
||||
- '!**_test.go'
|
||||
schedule:
|
||||
- cron: '45 22 * * 3'
|
||||
|
||||
@@ -22,8 +30,6 @@ jobs:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -5,11 +5,11 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event.pull_request
|
||||
if: github.event_name == 'schedule'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: List generated files
|
||||
run: ls -l *.deb
|
||||
build-packages-manually:
|
||||
if: github.event_name != 'pull_request' && github.event_name != 'push'
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/docker/**'
|
||||
jobs:
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- run: "go vet ./..."
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
args: >
|
||||
|
||||
@@ -5,11 +5,11 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event.pull_request
|
||||
if: github.event_name == 'schedule'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: List generated files
|
||||
run: ls -l *.rpm
|
||||
build-package-manually:
|
||||
if: github.event_name != 'pull_request' && github.event_name != 'push'
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -17,14 +17,18 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.23.x"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Run unit tests
|
||||
- name: Run unit tests with coverage and race conditions checking
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make test
|
||||
- name: Run unit tests without coverage and race conditions checking
|
||||
if: matrix.os != 'ubuntu-latest'
|
||||
run: go test ./...
|
||||
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
@@ -40,12 +44,12 @@ jobs:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Postgres client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
|
||||
@@ -1,3 +1,120 @@
|
||||
Version 2.2.5 (January 20, 2025)
|
||||
--------------------------------
|
||||
|
||||
* tests(js): improve `.jshintrc` (strict comparison, etc...)
|
||||
* test(sanitizer): add a fuzzer
|
||||
* refactor(rewriter): use custom title case converter implementation instead of `golang.org/x/text/cases.Title()`
|
||||
* refactor(readingtime): replace `whatlanggo` package with an ad-hoc implementation
|
||||
* refactor(oauth2): no need to use `io.WriteString` when sha256 provides a way to obtain a sum in a single call
|
||||
* refactor(js): simplify a bit `keyboard_handler.js`
|
||||
* refactor(js): remove an outdated check for `{passive: true}`
|
||||
* refactor(js): minor refactoring of `touch_handler.js`
|
||||
* refactor(js): minor improvements in `app.js`
|
||||
* refactor(database): add special handling for PostgreSQL-specific migrations
|
||||
* fix(ui): reading preferences are reset if the form values are incorrect
|
||||
* fix(sanitizer): allow `<hr>` tags
|
||||
* fix(finder): do not add redirections to the list of subscriptions to avoid confusion
|
||||
* fix: update Wallabag URL label to avoid confusion
|
||||
* fix: improve pagination when having identical publication date
|
||||
* fix: do not strip tags in Atom entry title
|
||||
* feat(ntfy): Add option to use internal links
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): update German translation
|
||||
* feat(integration): add Discord integration
|
||||
* feat(database): add optional build support for SQLite
|
||||
* feat: validate usernames upon creation
|
||||
* feat: replace `%{?systemd_requires}` with `%{?systemd_ordering}`
|
||||
* feat: bump linter and minifier from ECMAScript 2017 to 2020 (ES11)
|
||||
* feat: add `fix_ghost_cards` rewrite rule
|
||||
* ci: tighten the CodeQL rules
|
||||
* ci: run Docker tests only when the Dockerfiles are modified
|
||||
* ci: run `-race -cover` only on Ubuntu jobs
|
||||
* ci: don't specify languages for CodeQL
|
||||
* ci: don't run `go vet ./...` as it's run as part of `golangci-lint`
|
||||
* ci: checkout before installing Go to improve cache efficiency
|
||||
* ci: avoid building Linux packages for each pull-request
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.24.0` to `0.25.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.33.0` to `0.34.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.31.0` to `0.32.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.2` to `2.21.3`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.0` to `1.10.1`
|
||||
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.11.0` to `3.12.0`
|
||||
|
||||
Version 2.2.4 (December 20, 2024)
|
||||
---------------------------------
|
||||
|
||||
* test(rewrite): add unit test for referer rewrite function
|
||||
* refactor(subscription): use `strings.HasSuffix` instead of a regex in `FindSubscriptionsFromYouTubePlaylistPage`
|
||||
* refactor(sanitizer): use `token.String()` instead of `html.EscapeString(token.Data)`
|
||||
* refactor(sanitizer): simplify `isValidTag`
|
||||
* refactor(sanitizer): simplify `hasRequiredAttributes`
|
||||
* refactor(sanitizer): remove condition because `config.Opts` is guaranteed to never be nil
|
||||
* refactor(sanitizer): remove a now-useless function after refactoring
|
||||
* refactor(sanitizer): refactor conditions to highlight their similitude, enabling further refactoring
|
||||
* refactor(sanitizer): optimize `strip_tags.go`
|
||||
* refactor(sanitizer): micro-optimizations of `srcset.go`
|
||||
* refactor(sanitizer): merge two conditions
|
||||
* refactor(sanitizer): inline a function in `sanitizeAttributes` and fix a bug in it
|
||||
* refactor(sanitizer): inline a condition in `sanitizeSrcsetAttr`
|
||||
* refactor(sanitizer): improve `rewriteIframeURL()`
|
||||
* refactor(sanitizer): Google+ isn't a thing anymore
|
||||
* refactor(sanitizer): change the scope of a variable
|
||||
* refactor(rewriter): replace regex with URL parsing for referrer override
|
||||
* refactor(rewriter): avoid the use of regex in `addDynamicImage`
|
||||
* refactor(rewrite): remove unused function arguments
|
||||
* refactor(readability): various improvements and optimizations
|
||||
* refactor(readability): simplify the regexes in `readability.go`
|
||||
* refactor(processor): use URL parsing instead of a regex
|
||||
* refactor(processor): improve the `rewrite` URL rule regex
|
||||
* refactor(locale): delay parsing of translations until they're used
|
||||
* refactor(js): factorise a line in `app.js`
|
||||
* refactor(handler): delay `store.UserByID()` as much as possible
|
||||
* refactor(css): replace `-ms-text-size-adjust` with `text-size-adjust`
|
||||
* refactor(css): remove `-webkit-clip-path`
|
||||
* refactor(css): factorise `.pagination-next` and `.pagination-last` together
|
||||
* refactor: use a better construct than `doc.Find(…).First()`
|
||||
* refactor: use `min/max` instead of `math.Min/math.Max`
|
||||
* refactor: refactor `internal/reader/readability/testdata`
|
||||
* refactor: optimize `sanitizeAttributes`
|
||||
* refactor: get rid of `numberOfPluralFormsPerLanguage` test-only variable
|
||||
* fix(storage): replace timezone function call with view
|
||||
* fix(consistency): align feed modification behavior between API and UI
|
||||
* fix(ci): fix grammar in pull-request template
|
||||
* fix: load icon from site URL instead of feed URL
|
||||
* fix: feed icon from xml ignored during force refresh
|
||||
* feat(rewrite)!: remove `parse_markdown` rewrite rule
|
||||
* feat(mediaproxy): update predefined referer spoofing rules for restricted media resources
|
||||
* feat(locale): update translations to clarify readeck URL instead of readeck API endpoint
|
||||
* feat(locale): update German translations
|
||||
* feat(locale): update Chinese translations
|
||||
* feat(apprise): update `SendNotification` to handle multiple entries and add logging
|
||||
* feat(apprise): add title in notification request body
|
||||
* feat: resize favicons before storing them in the database
|
||||
* feat: optionally fetch watch time from YouTube API instead of website
|
||||
* feat: only show the commit URL if it's not empty on `/about`
|
||||
* feat: add predefined scraper rules for `arstechnica.com`
|
||||
* feat: add date-based entry filtering rules
|
||||
* chore: remove `blog.laravel.com` rewrite rule
|
||||
* build(deps): bump `library/alpine` in `/packaging/docker/alpine` to `3.21`
|
||||
* build(deps): bump `golang.org/x/term` from `0.26.0` to `0.27.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.31.0` to `0.33.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.30.0` to `0.31.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.1` to `2.21.2`
|
||||
|
||||
Version 2.2.3 (November 10, 2024)
|
||||
---------------------------------
|
||||
|
||||
* fix: unable to change password due to a typo in SQL parameter
|
||||
* fix: show only one player when there are several audio/video enclosures
|
||||
* feat(mediaproxy): pass original filename in `Content-Disposition` header
|
||||
* feat(mediaproxy): implement referer spoofing for restricted media resources
|
||||
* feat(integration): update Shiori integration to use new API endpoints for login/bookmark
|
||||
* build(deps): bump `golang.org/x/text` from `0.19.0` to `0.20.0`
|
||||
* build(deps): bump `golang.org/x/term` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.30.0` to `0.31.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.28.0` to `0.29.0`
|
||||
|
||||
Version 2.2.2 (October 29, 2024)
|
||||
--------------------------------
|
||||
|
||||
|
||||
@@ -3,21 +3,20 @@ module miniflux.app/v2
|
||||
// +heroku goVersion go1.23
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.0
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/tdewolff/minify/v2 v2.21.1
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/term v0.25.0
|
||||
golang.org/x/text v0.19.0
|
||||
github.com/tdewolff/minify/v2 v2.21.3
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
golang.org/x/term v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -27,7 +26,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
@@ -39,9 +38,10 @@ require (
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.18 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.19 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
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/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
@@ -38,6 +36,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -54,10 +54,10 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/stretchr/testify v1.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.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
|
||||
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
|
||||
github.com/tdewolff/parse/v2 v2.7.18 h1:uSqjEMT2lwCj5oifBHDcWU2kN1pbLrRENgFWDJa57eI=
|
||||
github.com/tdewolff/parse/v2 v2.7.18/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8=
|
||||
github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw=
|
||||
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19/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=
|
||||
@@ -66,52 +66,81 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
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.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
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.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.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
|
||||
@@ -121,6 +121,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
originalFeed.ResetErrorCounter()
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/database"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/static"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -153,10 +152,6 @@ func Parse() {
|
||||
slog.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
if err := locale.LoadCatalogMessages(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to load translations: %v", err))
|
||||
}
|
||||
|
||||
if err := static.CalculateBinaryFileChecksums(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
|
||||
}
|
||||
|
||||
@@ -2116,6 +2116,24 @@ func TestFetchYouTubeWatchTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeApiKey(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
|
||||
result := opts.YouTubeApiKey()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedUrlOverride(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
@@ -60,6 +60,7 @@ const (
|
||||
defaultFetchNebulaWatchTime = false
|
||||
defaultFetchOdyseeWatchTime = false
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultYouTubeApiKey = ""
|
||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||
defaultCreateAdmin = false
|
||||
defaultAdminUsername = ""
|
||||
@@ -149,6 +150,7 @@ type Options struct {
|
||||
fetchOdyseeWatchTime bool
|
||||
fetchYouTubeWatchTime bool
|
||||
filterEntryMaxAgeDays int
|
||||
youTubeApiKey string
|
||||
youTubeEmbedUrlOverride string
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
@@ -228,6 +230,7 @@ func NewOptions() *Options {
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
youTubeApiKey: defaultYouTubeApiKey,
|
||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
|
||||
oauth2ClientID: defaultOAuth2ClientID,
|
||||
@@ -503,6 +506,11 @@ func (o *Options) FetchYouTubeWatchTime() bool {
|
||||
return o.fetchYouTubeWatchTime
|
||||
}
|
||||
|
||||
// YouTubeApiKey returns the YouTube API key if defined.
|
||||
func (o *Options) YouTubeApiKey() string {
|
||||
return o.youTubeApiKey
|
||||
}
|
||||
|
||||
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
|
||||
func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||
return o.youTubeEmbedUrlOverride
|
||||
@@ -733,6 +741,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"SERVER_TIMING_HEADER": o.serverTimingHeader,
|
||||
"WATCHDOG": o.watchdog,
|
||||
"WORKER_POOL_SIZE": o.workerPoolSize,
|
||||
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
|
||||
"YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride,
|
||||
"WEBAUTHN": o.webAuthn,
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
|
||||
@@ -7,34 +7,18 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
// Postgresql driver import
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// NewConnectionPool configures the database connection pool.
|
||||
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(maxConnections)
|
||||
db.SetMaxIdleConns(minConnections)
|
||||
db.SetConnMaxLifetime(connectionLifetime)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate executes database migrations.
|
||||
func Migrate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
|
||||
driver := getDriverStr()
|
||||
slog.Info("Running database migrations",
|
||||
slog.Int("current_version", currentVersion),
|
||||
slog.Int("latest_version", schemaVersion),
|
||||
slog.String("driver", driver),
|
||||
)
|
||||
|
||||
for version := currentVersion; version < schemaVersion; version++ {
|
||||
@@ -45,7 +29,7 @@ func Migrate(db *sql.DB) error {
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
if err := migrations[version](tx); err != nil {
|
||||
if err := migrations[version](tx, driver); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("[Migration v%d] %v", newVersion, err)
|
||||
}
|
||||
|
||||
+168
-146
@@ -10,8 +10,8 @@ import (
|
||||
var schemaVersion = len(migrations)
|
||||
|
||||
// Order is important. Add new migrations at the end of the list.
|
||||
var migrations = []func(tx *sql.Tx) error{
|
||||
func(tx *sql.Tx) (err error) {
|
||||
var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE schema_version (
|
||||
version text not null
|
||||
@@ -120,16 +120,19 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
func(tx *sql.Tx, driver string) (err error) {
|
||||
if driver == "postgresql" {
|
||||
sql := `
|
||||
CREATE EXTENSION IF NOT EXISTS hstore;
|
||||
ALTER TABLE users ADD COLUMN extra hstore;
|
||||
CREATE INDEX users_extra_idx ON users using gin(extra);
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE tokens (
|
||||
id text not null,
|
||||
@@ -141,7 +144,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE entry_sorting_direction AS enum('asc', 'desc');
|
||||
ALTER TABLE users ADD COLUMN entry_direction entry_sorting_direction default 'asc';
|
||||
@@ -149,7 +152,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE integrations (
|
||||
user_id int not null,
|
||||
@@ -170,27 +173,27 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN scraper_rules text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN rewrite_rules text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN crawler boolean default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE sessions rename to user_sessions`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
DROP TABLE tokens;
|
||||
|
||||
@@ -204,7 +207,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_url text default '';
|
||||
@@ -216,12 +219,12 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN starred bool default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE INDEX entries_user_status_idx ON entries(user_id, status);
|
||||
CREATE INDEX feeds_user_category_idx ON feeds(user_id, category_id);
|
||||
@@ -229,7 +232,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN nunux_keeper_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN nunux_keeper_url text default '';
|
||||
@@ -238,17 +241,17 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE enclosures ALTER COLUMN size SET DATA TYPE bigint`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN comments_url text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN pocket_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN pocket_access_token text default '';
|
||||
@@ -257,14 +260,14 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE user_sessions ALTER COLUMN ip SET DATA TYPE inet using ip::inet;
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN username text default '';
|
||||
ALTER TABLE feeds ADD COLUMN password text default '';
|
||||
@@ -272,7 +275,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN document_vectors tsvector;
|
||||
UPDATE entries SET document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
|
||||
@@ -281,12 +284,12 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN user_agent text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
UPDATE
|
||||
entries
|
||||
@@ -296,17 +299,17 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN keyboard_shortcuts boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN disabled boolean default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
|
||||
UPDATE users SET theme='light_serif' WHERE theme='default';
|
||||
@@ -316,7 +319,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN changed_at timestamp with time zone;
|
||||
UPDATE entries SET changed_at = published_at;
|
||||
@@ -325,7 +328,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TABLE api_keys (
|
||||
id serial not null,
|
||||
@@ -341,7 +344,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN share_code text not null default '';
|
||||
CREATE UNIQUE INDEX entries_share_code_idx ON entries USING btree(share_code) WHERE share_code <> '';
|
||||
@@ -349,12 +352,12 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `CREATE INDEX enclosures_user_entry_url_idx ON enclosures(user_id, entry_id, md5(url))`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN next_check_at timestamp with time zone default now();
|
||||
CREATE INDEX entries_user_feed_idx ON entries (user_id, feed_id);
|
||||
@@ -362,52 +365,52 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN ignore_http_cache bool default false`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN entries_per_page int default 100`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN show_reading_time boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `CREATE INDEX entries_id_user_status_idx ON entries USING btree (id, user_id, status)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN fetch_via_proxy bool default false`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `CREATE INDEX entries_feed_id_status_hash_idx ON entries USING btree (feed_id, status, hash)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `CREATE INDEX entries_user_id_status_starred_idx ON entries (user_id, status, starred)`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN entry_swipe boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE integrations DROP COLUMN fever_password`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds
|
||||
ADD COLUMN blocklist_rules text not null default '',
|
||||
@@ -416,12 +419,12 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE entries ADD COLUMN reading_time int not null default 0`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE entries ADD COLUMN created_at timestamp with time zone not null default now();
|
||||
UPDATE entries SET created_at = published_at;
|
||||
@@ -429,7 +432,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, driver string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users
|
||||
ADD column stylesheet text not null default '',
|
||||
@@ -440,63 +443,69 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
DECLARE my_cursor CURSOR FOR
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(extra->'custom_css', '') as custom_css,
|
||||
COALESCE(extra->'google_id', '') as google_id,
|
||||
COALESCE(extra->'oidc_id', '') as oidc_id
|
||||
FROM users
|
||||
FOR UPDATE
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Exec("CLOSE my_cursor")
|
||||
|
||||
for {
|
||||
var (
|
||||
userID int64
|
||||
customStylesheet string
|
||||
googleID string
|
||||
oidcID string
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tx.Exec(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
stylesheet=$2,
|
||||
google_id=$3,
|
||||
openid_connect_id=$4
|
||||
WHERE
|
||||
id=$1
|
||||
`,
|
||||
userID, customStylesheet, googleID, oidcID)
|
||||
if driver == "postgresql" {
|
||||
_, err = tx.Exec(`
|
||||
DECLARE my_cursor CURSOR FOR
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(extra->'custom_css', '') as custom_css,
|
||||
COALESCE(extra->'google_id', '') as google_id,
|
||||
COALESCE(extra->'oidc_id', '') as oidc_id
|
||||
FROM users
|
||||
FOR UPDATE
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Exec("CLOSE my_cursor")
|
||||
|
||||
for {
|
||||
var (
|
||||
userID int64
|
||||
customStylesheet string
|
||||
googleID string
|
||||
oidcID string
|
||||
)
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM my_cursor`).Scan(&userID, &customStylesheet, &googleID, &oidcID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tx.Exec(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
stylesheet=$2,
|
||||
google_id=$3,
|
||||
openid_connect_id=$4
|
||||
WHERE
|
||||
id=$1
|
||||
`,
|
||||
userID, customStylesheet, googleID, oidcID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, driver string) (err error) {
|
||||
if driver == "postgresql" {
|
||||
if _, err = tx.Exec(`ALTER TABLE users DROP COLUMN extra;`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users DROP COLUMN extra;
|
||||
CREATE UNIQUE INDEX users_google_id_idx ON users(google_id) WHERE google_id <> '';
|
||||
CREATE UNIQUE INDEX users_openid_connect_id_idx ON users(openid_connect_id) WHERE openid_connect_id <> '';
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE INDEX entries_feed_url_idx ON entries(feed_id, url);
|
||||
CREATE INDEX entries_user_status_feed_idx ON entries(user_id, status, feed_id);
|
||||
@@ -504,7 +513,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_cache (
|
||||
key varchar(400) not null primary key,
|
||||
@@ -514,13 +523,13 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
|
||||
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
|
||||
@@ -528,24 +537,24 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN hide_globally boolean not null default false
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_token text default '';
|
||||
@@ -554,7 +563,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
CREATE TYPE entry_sorting_order AS enum('published_at', 'created_at');
|
||||
ALTER TABLE users ADD COLUMN entry_order entry_sorting_order default 'published_at';
|
||||
@@ -562,7 +571,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN googlereader_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN googlereader_username text default '';
|
||||
@@ -571,7 +580,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN espial_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN espial_url text default '';
|
||||
@@ -581,7 +590,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkding_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN linkding_url text default '';
|
||||
@@ -590,38 +599,38 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN url_rewrite_rules text not null default ''
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN default_reading_speed int default 265;
|
||||
ALTER TABLE users ADD COLUMN cjk_reading_speed int default 500;
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN default_home_page text default 'unread';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE integrations ADD COLUMN wallabag_only_url bool default 'f';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE users ADD COLUMN categories_sorting_order text not null default 'unread_count';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN matrix_bot_user text default '';
|
||||
@@ -632,18 +641,18 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN double_tap boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE entries ADD COLUMN tags text[] default '{}';
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users RENAME double_tap TO gesture_nav;
|
||||
ALTER TABLE users ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end;
|
||||
@@ -652,14 +661,14 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkding_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
|
||||
ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
|
||||
@@ -667,14 +676,14 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkding_mark_as_unread bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
// Delete duplicated rows
|
||||
sql := `
|
||||
DELETE FROM enclosures a USING enclosures b
|
||||
@@ -702,12 +711,12 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
|
||||
return nil
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN mark_read_on_view boolean default 't'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN notion_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN notion_token text default '';
|
||||
@@ -716,7 +725,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN readwise_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN readwise_api_key text default '';
|
||||
@@ -724,7 +733,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN apprise_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN apprise_url text default '';
|
||||
@@ -733,7 +742,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN shiori_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN shiori_url text default '';
|
||||
@@ -743,7 +752,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN shaarli_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN shaarli_url text default '';
|
||||
@@ -752,13 +761,13 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE feeds ADD COLUMN apprise_service_urls text default '';
|
||||
`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN webhook_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN webhook_url text default '';
|
||||
@@ -767,7 +776,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_topic_id int;
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_web_page_preview bool default 'f';
|
||||
@@ -776,14 +785,14 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN telegram_bot_disable_buttons bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
-- Speed up has_enclosure
|
||||
CREATE INDEX enclosures_entry_id_idx ON enclosures(entry_id);
|
||||
@@ -799,7 +808,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN rssbridge_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN rssbridge_url text default '';
|
||||
@@ -807,7 +816,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE webauthn_credentials (
|
||||
handle bytea primary key,
|
||||
@@ -825,7 +834,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
`)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN omnivore_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN omnivore_api_key text default '';
|
||||
@@ -834,7 +843,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkace_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN linkace_url text default '';
|
||||
@@ -846,7 +855,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN linkwarden_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN linkwarden_url text default '';
|
||||
@@ -855,7 +864,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN readeck_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN readeck_only_url bool default 'f';
|
||||
@@ -866,29 +875,29 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
// the WHERE part speed-up the request a lot
|
||||
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (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) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
|
||||
@@ -898,12 +907,12 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users
|
||||
ADD COLUMN block_filter_entry_rules text not null default '',
|
||||
@@ -912,7 +921,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN betula_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN betula_token text default '';
|
||||
@@ -921,7 +930,7 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_url text default '';
|
||||
@@ -937,22 +946,22 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
|
||||
@@ -960,4 +969,17 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN discord_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN discord_webhook_link text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `ALTER TABLE integrations ADD COLUMN ntfy_internal_links bool default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build !sqlite
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// NewConnectionPool configures the database connection pool.
|
||||
func NewConnectionPool(dsn string, minConnections, maxConnections int, connectionLifetime time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(maxConnections)
|
||||
db.SetMaxIdleConns(minConnections)
|
||||
db.SetConnMaxLifetime(connectionLifetime)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getDriverStr() string {
|
||||
return "postgresql"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build sqlite
|
||||
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// NewConnectionPool configures the database connection pool.
|
||||
func NewConnectionPool(dsn string, _, _ int, _ time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getDriverStr() string {
|
||||
return "sqlite3"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -26,42 +27,53 @@ func NewClient(serviceURL, baseURL string) *Client {
|
||||
return &Client{serviceURL, baseURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendNotification(entry *model.Entry) error {
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
if c.baseURL == "" || c.servicesURL == "" {
|
||||
return fmt.Errorf("apprise: missing base URL or services URL")
|
||||
}
|
||||
|
||||
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"urls": c.servicesURL,
|
||||
"body": message,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to encode request body: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"urls": c.servicesURL,
|
||||
"body": message,
|
||||
"title": feed.Title,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to create request: %v", err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
slog.Debug("Sending Apprise notification",
|
||||
slog.String("apprise_url", c.baseURL),
|
||||
slog.String("services_url", c.servicesURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("body", message),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Discord Webhooks documentation: https://discord.com/developers/docs/resources/webhook
|
||||
|
||||
package discord // import "miniflux.app/v2/internal/integration/discord"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
const discordMsgColor = 5793266
|
||||
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
}
|
||||
|
||||
func NewClient(webhookURL string) *Client {
|
||||
return &Client{webhookURL: webhookURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
requestBody, err := json.Marshal(&discordMessage{
|
||||
Embeds: []discordEmbed{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: discordMsgColor,
|
||||
Fields: []discordFields{
|
||||
{
|
||||
Name: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Name: "Article link",
|
||||
Value: "[" + entry.Title + "]" + "(" + entry.URL + ")",
|
||||
},
|
||||
{
|
||||
Name: "Author",
|
||||
Value: entry.Author,
|
||||
Inline: true,
|
||||
},
|
||||
{
|
||||
Name: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Inline: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
slog.Debug("Sending Discord notification",
|
||||
slog.String("webhookURL", c.webhookURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type discordFields struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Inline bool `json:"inline,omitempty"`
|
||||
}
|
||||
|
||||
type discordEmbed struct {
|
||||
Title string `json:"title"`
|
||||
Color int `json:"color"`
|
||||
Fields []discordFields `json:"fields"`
|
||||
}
|
||||
|
||||
type discordMessage struct {
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/cubox"
|
||||
"miniflux.app/v2/internal/integration/discord"
|
||||
"miniflux.app/v2/internal/integration/espial"
|
||||
"miniflux.app/v2/internal/integration/instapaper"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
@@ -505,6 +506,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
userIntegrations.NtfyUsername,
|
||||
userIntegrations.NtfyPassword,
|
||||
userIntegrations.NtfyIconURL,
|
||||
userIntegrations.NtfyInternalLinks,
|
||||
feed.NtfyPriority,
|
||||
)
|
||||
|
||||
@@ -513,8 +515,46 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.AppriseEnabled {
|
||||
slog.Debug("Sending new entries to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
appriseServiceURLs := userIntegrations.AppriseServicesURL
|
||||
if feed.AppriseServiceURLs != "" {
|
||||
appriseServiceURLs = feed.AppriseServiceURLs
|
||||
}
|
||||
|
||||
client := apprise.NewClient(
|
||||
appriseServiceURLs,
|
||||
userIntegrations.AppriseURL,
|
||||
)
|
||||
|
||||
if err := client.SendNotification(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.DiscordEnabled {
|
||||
slog.Debug("Sending new entries to Discord",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := discord.NewClient(
|
||||
userIntegrations.DiscordWebhookLink,
|
||||
)
|
||||
|
||||
if err := client.SendDiscordMsg(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Discord", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
@@ -541,35 +581,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.AppriseEnabled {
|
||||
slog.Debug("Sending a new entry to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("apprise_url", userIntegrations.AppriseURL),
|
||||
)
|
||||
|
||||
appriseServiceURLs := userIntegrations.AppriseServicesURL
|
||||
if feed.AppriseServiceURLs != "" {
|
||||
appriseServiceURLs = feed.AppriseServiceURLs
|
||||
}
|
||||
|
||||
client := apprise.NewClient(
|
||||
appriseServiceURLs,
|
||||
userIntegrations.AppriseURL,
|
||||
)
|
||||
|
||||
if err := client.SendNotification(entry); err != nil {
|
||||
slog.Error("Unable to send entry to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("apprise_url", userIntegrations.AppriseURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
@@ -22,14 +24,15 @@ const (
|
||||
|
||||
type Client struct {
|
||||
ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string
|
||||
ntfyInternalLinks bool
|
||||
ntfyPriority int
|
||||
}
|
||||
|
||||
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyPriority int) *Client {
|
||||
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyInternalLinks bool, ntfyPriority int) *Client {
|
||||
if ntfyURL == "" {
|
||||
ntfyURL = defaultNtfyURL
|
||||
}
|
||||
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyPriority}
|
||||
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyInternalLinks, ntfyPriority}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
@@ -46,12 +49,21 @@ func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
ntfyMessage.Icon = c.ntfyIconURL
|
||||
}
|
||||
|
||||
if c.ntfyInternalLinks {
|
||||
url, err := url.Parse(config.Opts.BaseURL())
|
||||
if err != nil {
|
||||
slog.Error("Unable to parse base URL", slog.Any("error", err))
|
||||
} else {
|
||||
ntfyMessage.Click = fmt.Sprintf("%s%s%d", url, "/unread/entry/", entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("Sending Ntfy message",
|
||||
slog.String("url", c.ntfyURL),
|
||||
slog.String("topic", c.ntfyTopic),
|
||||
slog.Int("priority", ntfyMessage.Priority),
|
||||
slog.String("message", ntfyMessage.Message),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("entry_url", ntfyMessage.Click),
|
||||
)
|
||||
|
||||
if err := c.makeRequest(ntfyMessage); err != nil {
|
||||
|
||||
@@ -31,7 +31,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("shiori: missing base URL, username or password")
|
||||
}
|
||||
|
||||
sessionID, err := c.authenticate()
|
||||
token, err := c.authenticate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("shiori: unable to authenticate: %v", err)
|
||||
}
|
||||
@@ -44,7 +44,11 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
requestBody, err := json.Marshal(&addBookmarkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Excerpt: "",
|
||||
CreateArchive: true,
|
||||
CreateEbook: false,
|
||||
Public: 0,
|
||||
Tags: make([]string, 0),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -58,7 +62,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("X-Session-Id", sessionID)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
|
||||
@@ -75,13 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authenticate() (sessionID string, err error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
|
||||
func (c *Client) authenticate() (token string, err error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
|
||||
}
|
||||
@@ -111,21 +115,31 @@ func (c *Client) authenticate() (sessionID string, err error) {
|
||||
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
return authResponse.SessionID, nil
|
||||
return authResponse.Message.Token, nil
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RememberMe bool `json:"remember_me"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message authResponseMessage `json:"message"`
|
||||
}
|
||||
|
||||
type authResponseMessage struct {
|
||||
SessionID string `json:"session"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type addBookmarkRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
CreateArchive bool `json:"createArchive"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
CreateArchive bool `json:"create_archive"`
|
||||
CreateEbook bool `json:"create_ebook"`
|
||||
Public int `json:"public"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
@@ -12,17 +12,26 @@ import (
|
||||
type translationDict map[string]interface{}
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog catalog
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
|
||||
//go:embed translations/*.json
|
||||
var translationFiles embed.FS
|
||||
|
||||
func GetTranslationDict(language string) (translationDict, error) {
|
||||
if _, ok := defaultCatalog[language]; !ok {
|
||||
var err error
|
||||
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return defaultCatalog[language], nil
|
||||
}
|
||||
|
||||
// LoadCatalogMessages loads and parses all translations encoded in JSON.
|
||||
func LoadCatalogMessages() error {
|
||||
var err error
|
||||
defaultCatalog = make(catalog, len(AvailableLanguages()))
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
defaultCatalog[language], err = loadTranslationFile(language)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestLoadCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKeysHaveValue(t *testing.T) {
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
@@ -71,7 +71,7 @@ func TestMissingTranslations(t *testing.T) {
|
||||
t.Fatal(`Unable to parse reference language`)
|
||||
}
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
if language == refLang {
|
||||
continue
|
||||
}
|
||||
@@ -90,7 +90,27 @@ func TestMissingTranslations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
for language := range AvailableLanguages() {
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
|
||||
+20
-43
@@ -3,47 +3,24 @@
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the list of available languages.
|
||||
func AvailableLanguages() map[string]string {
|
||||
return map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
}
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
import "testing"
|
||||
|
||||
func TestAvailableLanguages(t *testing.T) {
|
||||
results := AvailableLanguages()
|
||||
results := AvailableLanguages
|
||||
for k, v := range results {
|
||||
if k == "" {
|
||||
t.Errorf(`Empty language key detected`)
|
||||
|
||||
+19
-14
@@ -11,9 +11,11 @@ type Printer struct {
|
||||
}
|
||||
|
||||
func (p *Printer) Print(key string) string {
|
||||
if str, ok := defaultCatalog[p.language][key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict[key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
}
|
||||
}
|
||||
}
|
||||
return key
|
||||
@@ -21,16 +23,16 @@ func (p *Printer) Print(key string) string {
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
var translation string
|
||||
translation := key
|
||||
|
||||
str, found := defaultCatalog[p.language][key]
|
||||
if !found {
|
||||
translation = key
|
||||
} else {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
str, found := dict[key]
|
||||
if found {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +41,12 @@ func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
choices, found := defaultCatalog[p.language][key]
|
||||
dict, err := GetTranslationDict(p.language)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
if found {
|
||||
if choices, found := dict[key]; found {
|
||||
var plurals []string
|
||||
|
||||
switch v := choices.(type) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Zum Inhalt springen",
|
||||
"confirm.question": "Sind Sie sicher?",
|
||||
"confirm.question.refresh": "Möchten Sie eine erzwungene Aktualisierung durchführen?",
|
||||
"confirm.yes": "ja",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
|
||||
"tooltip.logged_user": "Angemeldet als %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Startseite",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.starred": "Lesezeichen",
|
||||
"menu.history": "Verlauf",
|
||||
@@ -93,22 +93,22 @@
|
||||
"entry.tags.label": "Stichworte:",
|
||||
"page.shared_entries.title": "Geteilte Artikel",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d geteilter Artikel",
|
||||
"%d geteilte Artikel"
|
||||
],
|
||||
"page.unread.title": "Ungelesen",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d ungelesener Artikel",
|
||||
"%d ungelesene Artikel"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d Artikel insgesamt",
|
||||
"%d Artikel insgesamt"
|
||||
],
|
||||
"page.starred.title": "Lesezeichen",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d Lesezeichen",
|
||||
"%d Lesezeichen"
|
||||
],
|
||||
"page.categories.title": "Kategorien",
|
||||
"page.categories.no_feed": "Kein Abonnement.",
|
||||
@@ -119,15 +119,15 @@
|
||||
"Es gibt %d Abonnements."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d Kategorie",
|
||||
"%d Kategorien"
|
||||
],
|
||||
"page.new_category.title": "Neue Kategorie",
|
||||
"page.new_user.title": "Neuer Benutzer",
|
||||
"page.edit_category.title": "Kategorie bearbeiten: %s",
|
||||
"page.edit_user.title": "Benutzer bearbeiten: %s",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Kategorie: %s",
|
||||
"page.feeds.last_check": "Letzte Aktualisierung:",
|
||||
"page.feeds.next_check": "Nächste Aktualisierung:",
|
||||
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
|
||||
@@ -137,8 +137,8 @@
|
||||
],
|
||||
"page.history.title": "Verlauf",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d gelesener Artikel",
|
||||
"%d gelesene Artikel"
|
||||
],
|
||||
"page.import.title": "Importieren",
|
||||
"page.search.title": "Suchergebnisse",
|
||||
@@ -220,7 +220,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
|
||||
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
|
||||
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.login.webauthn_login.help": "Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Entfernen Sie %d Hauptschlüssel",
|
||||
"%d Hauptschlüssel entfernen"
|
||||
@@ -255,7 +255,7 @@
|
||||
"page.api_keys.never_used": "Nie benutzt",
|
||||
"page.new_api_key.title": "Neuer API-Schlüssel",
|
||||
"page.offline.title": "Offline-Modus",
|
||||
"page.offline.message": "Du bist offline",
|
||||
"page.offline.message": "Sie sind offline",
|
||||
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
|
||||
"page.webauthn_rename.title": "Passkey umbenennen",
|
||||
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
|
||||
@@ -273,12 +273,12 @@
|
||||
"alert.no_user": "Sie sind der einzige Benutzer.",
|
||||
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
|
||||
"alert.account_linked": "Ihr externes Konto wurde verknüpft!",
|
||||
"alert.pocket_linked": "Ihr Pocket Konto ist jetzt verknüpft!",
|
||||
"alert.pocket_linked": "Ihr Pocket-Konto ist jetzt verknüpft!",
|
||||
"alert.prefs_saved": "Einstellungen gespeichert!",
|
||||
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
|
||||
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
|
||||
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google Reader Benutzernamen!",
|
||||
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever-Benutzernamen!",
|
||||
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google-Reader-Benutzernamen!",
|
||||
"error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
|
||||
"error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
|
||||
"error.category_already_exists": "Diese Kategorie existiert bereits.",
|
||||
@@ -293,7 +293,7 @@
|
||||
"error.invalid_language": "Ungültige Sprache.",
|
||||
"error.invalid_timezone": "Ungültige Zeitzone.",
|
||||
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
|
||||
"error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
|
||||
"error.invalid_display_mode": "Progressive-Web-App- (PWA-)Anzeigemodus",
|
||||
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
|
||||
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
|
||||
"error.empty_file": "Diese Datei ist leer.",
|
||||
@@ -304,21 +304,21 @@
|
||||
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
|
||||
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
|
||||
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_block_rule_fieldname_invalid": "Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_block_rule_separator_required": "Ungültige Blockierregel: Das Muster für Regel #%d muss per '=' getrennt werden",
|
||||
"error.settings_block_rule_regex_required": "Ungültige Blockierregel: Regel #%d hat kein Muster",
|
||||
"error.settings_block_rule_invalid_regex": "Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ungültige Erlaubnisregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Ungültige Erlaubnisregel: Das Muster für Regel #%d muss per '=' getrennt werden",
|
||||
"error.settings_keep_rule_regex_required": "Ungültige Erlaubnisregel: Regel #%d hat kein Muster",
|
||||
"error.settings_keep_rule_invalid_regex": "Ungültige Erlaubnisregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
|
||||
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
|
||||
"error.feed_already_exists": "Dieser Feed existiert bereits.",
|
||||
"error.invalid_feed_url": "Ungültige Feed-URL.",
|
||||
"error.invalid_site_url": "Ungültige Site-URL.",
|
||||
"error.feed_url_not_empty": "Die Feed-URL darf nicht leer sein.",
|
||||
"error.site_url_not_empty": "Die Site-URL darf nicht leer sein.",
|
||||
"error.invalid_feed_url": "Ungültiger Feed-URL.",
|
||||
"error.invalid_site_url": "Ungültiger Site-URL.",
|
||||
"error.feed_url_not_empty": "Der Feed-URL darf nicht leer sein.",
|
||||
"error.site_url_not_empty": "Der Site-URL darf nicht leer sein.",
|
||||
"error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.",
|
||||
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
|
||||
"error.feed_invalid_blocklist_rule": "Die Blockierregel ist ungültig.",
|
||||
@@ -338,26 +338,26 @@
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.scraper_rules": "Extraktionsregeln",
|
||||
"form.feed.label.rewrite_rules": "Umschreiberegeln",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise Service-URLs",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
|
||||
"form.feed.label.blocklist_rules": "Blockierregeln",
|
||||
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
|
||||
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
|
||||
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_max_priority": "Höchste Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_min_priority": "Niedrigste Ntfy-Priorität",
|
||||
"form.integration.cubox_activate": "Einträge in Cubox speichern",
|
||||
"form.integration.cubox_api_link": "Cubox-API-Link",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.rules": "Regeln",
|
||||
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
|
||||
@@ -382,7 +382,7 @@
|
||||
"form.prefs.select.standalone": "Eigenständige",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlichte am",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
|
||||
"form.prefs.select.created_time": "Artikel erstellt am",
|
||||
"form.prefs.select.alphabetical": "Alphabetisch",
|
||||
"form.prefs.select.unread_count": "Ungelesen",
|
||||
@@ -399,66 +399,66 @@
|
||||
"form.prefs.label.default_home_page": "Standard-Startseite",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
|
||||
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
|
||||
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
|
||||
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
|
||||
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML Datei",
|
||||
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Ungültige Domainliste. Bitte geben Sie eine per Leerzeichen getrennte Liste von Domains an.",
|
||||
"form.import.label.file": "OPML-Datei",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Fever API aktivieren",
|
||||
"form.integration.fever_username": "Fever Benutzername",
|
||||
"form.integration.fever_password": "Fever Passwort",
|
||||
"form.integration.fever_endpoint": "Fever API Endpunkt:",
|
||||
"form.integration.googlereader_activate": "Google Reader API aktivieren",
|
||||
"form.integration.googlereader_username": "Google Reader Benutzername",
|
||||
"form.integration.googlereader_password": "Google Reader Passwort",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API-Endpunkt:",
|
||||
"form.integration.betula_activate": "Einträge in Betula speichern",
|
||||
"form.integration.betula_url": "Betula-Server-URL",
|
||||
"form.integration.betula_token": "Betula-Token",
|
||||
"form.integration.fever_activate": "Fever-API aktivieren",
|
||||
"form.integration.fever_username": "Fever-Benutzername",
|
||||
"form.integration.fever_password": "Fever-Passwort",
|
||||
"form.integration.fever_endpoint": "Fever-API-Endpunkt:",
|
||||
"form.integration.googlereader_activate": "Google-Reader-API aktivieren",
|
||||
"form.integration.googlereader_username": "Google-Reader-Benutzername",
|
||||
"form.integration.googlereader_password": "Google-Reader-Passwort",
|
||||
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
|
||||
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
|
||||
"form.integration.pinboard_token": "Pinboard API-Token",
|
||||
"form.integration.pinboard_tags": "Pinboard Tags",
|
||||
"form.integration.pinboard_token": "Pinboard-API-Token",
|
||||
"form.integration.pinboard_tags": "Pinboard-Tags",
|
||||
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
|
||||
"form.integration.instapaper_username": "Instapaper Benutzername",
|
||||
"form.integration.instapaper_password": "Instapaper Passwort",
|
||||
"form.integration.instapaper_username": "Instapaper-Benutzername",
|
||||
"form.integration.instapaper_password": "Instapaper-Passwort",
|
||||
"form.integration.pocket_activate": "Einträge in Pocket speichern",
|
||||
"form.integration.pocket_consumer_key": "Pocket Verbraucher-Schlüssel",
|
||||
"form.integration.pocket_access_token": "Pocket Zugangs-Token",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket Konto",
|
||||
"form.integration.pocket_consumer_key": "Pocket-Verbraucher-Schlüssel",
|
||||
"form.integration.pocket_access_token": "Pocket-Zugangstoken",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
|
||||
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
|
||||
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Geheimnis",
|
||||
"form.integration.wallabag_username": "Wallabag Benutzername",
|
||||
"form.integration.wallabag_password": "Wallabag Passwort",
|
||||
"form.integration.wallabag_endpoint": "Wallabag-Basis-URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
|
||||
"form.integration.wallabag_username": "Wallabag-Benutzername",
|
||||
"form.integration.wallabag_password": "Wallabag-Passwort",
|
||||
"form.integration.notion_activate": "Einträge in Notion speichern",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Geheimnis-Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise service URLs",
|
||||
"form.integration.notion_page_id": "Notion-Page-ID",
|
||||
"form.integration.notion_token": "Notion-Geheimnis-Token",
|
||||
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
|
||||
"form.integration.apprise_url": "Apprise-API-URL",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
|
||||
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
|
||||
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
|
||||
"form.integration.omnivore_url": "Omnivore API-Endpunkt",
|
||||
"form.integration.omnivore_api_key": "Omnivore API-Schlüssel",
|
||||
"form.integration.espial_activate": "Einträge in Espial",
|
||||
"form.integration.espial_endpoint": "Espial API-Endpunkt",
|
||||
"form.integration.espial_api_key": "Espial API-Schlüssel",
|
||||
"form.integration.espial_tags": "Espial Tags",
|
||||
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
|
||||
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
|
||||
"form.integration.espial_activate": "Einträge in Espial speichern",
|
||||
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
|
||||
"form.integration.espial_api_key": "Espial-API-Schlüssel",
|
||||
"form.integration.espial_tags": "Espial-Tags",
|
||||
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Zugangs-Token",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise Zugangs-Token",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
|
||||
"form.integration.telegram_bot_token": "Bot-Token",
|
||||
"form.integration.telegram_chat_id": "Chat-ID",
|
||||
@@ -466,53 +466,56 @@
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Webseiten-Vorschau deaktivieren",
|
||||
"form.integration.telegram_bot_disable_notification": "Benachrichtigungen deaktivieren",
|
||||
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_activate": "Einträge in LinkAce speichern",
|
||||
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
|
||||
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
|
||||
"form.integration.linkace_tags": "LinkAce-Tags",
|
||||
"form.integration.linkace_is_private": "Link als privat markieren",
|
||||
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
|
||||
"form.integration.linkding_activate": "Artikel in Linkding speichern",
|
||||
"form.integration.linkding_endpoint": "Linkding API-Endpunkt",
|
||||
"form.integration.linkding_api_key": "Linkding API-Schlüssel",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
|
||||
"form.integration.linkding_api_key": "Linkding-API-Schlüssel",
|
||||
"form.integration.linkding_tags": "Linkding-Tags",
|
||||
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API-Endpunkt",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-Schlüssel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-API-Endpunkt",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
|
||||
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
|
||||
"form.integration.matrix_bot_user": "Benutzername für Matrix",
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.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.raindrop_activate": "Einträge in Raindrop speichern",
|
||||
"form.integration.raindrop_token": "(Test-)Token",
|
||||
"form.integration.raindrop_collection_id": "Sammlungs-ID",
|
||||
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
|
||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_endpoint": "Readeck-URL",
|
||||
"form.integration.readeck_api_key": "Readeck-API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck-Labels",
|
||||
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.shiori_activate": "Artikel in Shiori speichern",
|
||||
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori Benutzername",
|
||||
"form.integration.shiori_password": "Shiori Passwort",
|
||||
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori-Benutzername",
|
||||
"form.integration.shiori_password": "Shiori-Passwort",
|
||||
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Geheimnis",
|
||||
"form.integration.shaarli_endpoint": "Shaarli-URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli-API-Geheimnis",
|
||||
"form.integration.webhook_activate": "Webhook aktivieren",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Geheimnis",
|
||||
"form.integration.webhook_url": "Webhook-URL",
|
||||
"form.integration.webhook_secret": "Webhook-Geheimnis",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge-Server-URL",
|
||||
"form.integration.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.integration.ntfy_topic": "Ntfy-Thema",
|
||||
"form.integration.ntfy_url": "Ntfy-URL (optional, Standard ist ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy-Benutzername (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy-Passwort (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Interne Links beim Klicken verwenden (optional)",
|
||||
"form.integration.discord_activate": "Einträge zu Discord pushen",
|
||||
"form.integration.discord_webhook_link": "Discord-Webhook-URL",
|
||||
"form.api_key.label.description": "API-Schlüsselbezeichnung",
|
||||
"form.submit.loading": "Lade...",
|
||||
"form.submit.saving": "Speichern...",
|
||||
@@ -556,7 +559,7 @@
|
||||
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
|
||||
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
|
||||
"error.http_client_error": "HTTP-Client-Fehler: %v.",
|
||||
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise sind der Benutzername oder das Passwort falsch.",
|
||||
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise ist der Benutzername oder das Passwort falsch.",
|
||||
"error.http_too_many_requests": "Miniflux hat zu viele Anfragen an diese Webseite gestellt. Bitte versuchen Sie es später erneut oder ändern Sie die Konfiguration der Anwendung.",
|
||||
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
||||
"error.http_resource_not_found": "Die gewünschte Quelle wurde nicht gefunden. Bitte stellen Sie sicher, dass die URL korrekt ist.",
|
||||
@@ -574,13 +577,13 @@
|
||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
"enclosure_media_controls.seek" : "Vorspulen:",
|
||||
"enclosure_media_controls.seek.title" : "%s Sekunden vorspulen",
|
||||
"enclosure_media_controls.speed" : "Geschwindigkeit:",
|
||||
"enclosure_media_controls.speed.faster" : "Schneller",
|
||||
"enclosure_media_controls.speed.faster.title" : "%sx schneller",
|
||||
"enclosure_media_controls.speed.slower" : "Langsamer",
|
||||
"enclosure_media_controls.speed.slower.title" : "%sx langsamer",
|
||||
"enclosure_media_controls.speed.reset" : "Zurücksetzen",
|
||||
"enclosure_media_controls.speed.reset.title" : "Wiedergabegeschwindigkeit auf 1x zurücksetzen"
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "Συνδέστε τον λογαριασμό Pocket σας",
|
||||
"form.integration.wallabag_activate": "Αποθήκευση άρθρων στο Wallabag",
|
||||
"form.integration.wallabag_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.wallabag_endpoint": "Τελικό σημείο Wallabag API ",
|
||||
"form.integration.wallabag_endpoint": "Βασική διεύθυνση URL Wallabag",
|
||||
"form.integration.wallabag_client_id": "Ταυτότητα πελάτη Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Μυστικό Πελάτη",
|
||||
"form.integration.wallabag_username": "Όνομα Χρήστη Wallabag",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "Ετικέτα κλειδιού API",
|
||||
"form.submit.loading": "Φόρτωση...",
|
||||
"form.submit.saving": "Αποθήκευση...",
|
||||
|
||||
@@ -433,7 +433,7 @@
|
||||
"form.integration.pocket_connect_link": "Connect your Pocket account",
|
||||
"form.integration.wallabag_activate": "Save entries to Wallabag",
|
||||
"form.integration.wallabag_only_url": "Send only URL (instead of full content)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag API Endpoint",
|
||||
"form.integration.wallabag_endpoint": "Wallabag Base URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_username": "Wallabag Username",
|
||||
@@ -488,7 +488,7 @@
|
||||
"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_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
|
||||
@@ -511,8 +511,11 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Loading…",
|
||||
"form.submit.saving": "Saving…",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "Conectar a la cuenta de Pocket",
|
||||
"form.integration.wallabag_activate": "Enviar artículos a Wallabag",
|
||||
"form.integration.wallabag_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.wallabag_endpoint": "Acceso API de Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL base de Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID de cliente de Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
|
||||
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
|
||||
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Enviar artículos a Discord",
|
||||
"form.integration.discord_webhook_link": "URL de la Webhook de Discord",
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.submit.loading": "Cargando...",
|
||||
"form.submit.saving": "Guardando...",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "Yhdistä Pocket-tilisi",
|
||||
"form.integration.wallabag_activate": "Tallenna artikkelit Wallabagiin",
|
||||
"form.integration.wallabag_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag API -päätepiste",
|
||||
"form.integration.wallabag_endpoint": "Wallabagin perus-URL-osoite",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_username": "Wallabag-käyttäjätunnus",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Ladataan...",
|
||||
"form.submit.saving": "Tallennetaan...",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "Connectez votre compte Pocket",
|
||||
"form.integration.wallabag_activate": "Sauvegarder les articles vers Wallabag",
|
||||
"form.integration.wallabag_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
|
||||
"form.integration.wallabag_endpoint": "URL de l'API de Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL de base de Wallabag",
|
||||
"form.integration.wallabag_client_id": "Identifiant unique du client Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Clé secrète du client Wallabag",
|
||||
"form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Nom d'utilisateur Ntfy (optionnel)",
|
||||
"form.integration.ntfy_password": "Mot de passe Ntfy (facultatif)",
|
||||
"form.integration.ntfy_icon_url": "URL de l'icône Ntfy (facultatif)",
|
||||
"form.integration.ntfy_internal_links": "Utiliser les liens internes vers Miniflux (facultatif)",
|
||||
"form.integration.discord_activate": "Envoyer les articles vers Discord",
|
||||
"form.integration.discord_webhook_link": "URL du Webhook Discord",
|
||||
"form.api_key.label.description": "Libellé de la clé d'API",
|
||||
"form.submit.loading": "Chargement...",
|
||||
"form.submit.saving": "Sauvegarde en cours...",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "अपना पॉकेट खाता कनेक्ट करें",
|
||||
"form.integration.wallabag_activate": "विषय सहेजें वालाबाग में ",
|
||||
"form.integration.wallabag_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.wallabag_endpoint": "वालबैग एपीआई एंडपॉइंट",
|
||||
"form.integration.wallabag_endpoint": "वल्लाबैग बेस यूआरएल",
|
||||
"form.integration.wallabag_client_id": "वालाबैग क्लाइंट आईडी",
|
||||
"form.integration.wallabag_client_secret": "वालाबैग क्लाइंट सीक्रेट",
|
||||
"form.integration.wallabag_username": "वालाबैग उपयोगकर्ता नाम",
|
||||
@@ -490,7 +490,7 @@
|
||||
"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_endpoint": "Readeck यूआरएल",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.submit.loading": "लोड हो रहा है...",
|
||||
"form.submit.saving": "सहेजा जा रहा है...",
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
"form.integration.pocket_connect_link": "Hubungkan akun Pocket Anda",
|
||||
"form.integration.wallabag_activate": "Simpan artikel ke Wallabag",
|
||||
"form.integration.wallabag_only_url": "Kirim hanya URL (alih-alih konten penuh)",
|
||||
"form.integration.wallabag_endpoint": "Titik URL API Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL Dasar Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID Klien Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Rahasia Klien Wallabag",
|
||||
"form.integration.wallabag_username": "Nama Pengguna Wallabag",
|
||||
@@ -503,6 +503,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "Label Kunci API",
|
||||
"form.submit.loading": "Memuat...",
|
||||
"form.submit.saving": "Menyimpan...",
|
||||
|
||||
@@ -434,7 +434,7 @@
|
||||
"form.integration.pocket_access_token": "Access token dell'account Pocket",
|
||||
"form.integration.pocket_connect_link": "Collega il tuo account Pocket",
|
||||
"form.integration.wallabag_activate": "Salva gli articoli su Wallabag",
|
||||
"form.integration.wallabag_endpoint": "Endpoint dell'API di Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL di base di Wallabagg",
|
||||
"form.integration.wallabag_only_url": "Invia solo URL (invece del contenuto completo)",
|
||||
"form.integration.wallabag_client_id": "Client ID dell'account Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Client secret dell'account Wallabag",
|
||||
@@ -514,6 +514,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.submit.loading": "Caricamento in corso...",
|
||||
"form.submit.saving": "Salvataggio in corso...",
|
||||
"time_elapsed.not_yet": "non ancora",
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
"form.integration.pocket_connect_link": "Pocket account に接続",
|
||||
"form.integration.wallabag_activate": "Wallabag に記事を保存する",
|
||||
"form.integration.wallabag_only_url": "URL のみを送信 (完全なコンテンツではなく)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag の API Endpoint",
|
||||
"form.integration.wallabag_endpoint": "ワラバッグベースURL",
|
||||
"form.integration.wallabag_client_id": "Wallabag の Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag の Client Secret",
|
||||
"form.integration.wallabag_username": "Wallabag のユーザー名",
|
||||
@@ -503,6 +503,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "API キーラベル",
|
||||
"form.submit.loading": "読み込み中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
|
||||
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
|
||||
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_endpoint": "Wallabag basis-URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
|
||||
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
|
||||
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Artikelen opslaan in Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "API-sleutel omschrijving",
|
||||
"form.submit.loading": "Laden...",
|
||||
"form.submit.saving": "Opslaan...",
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
|
||||
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
|
||||
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.wallabag_endpoint": "Punkt końcowy API Wallabag",
|
||||
"form.integration.wallabag_endpoint": "Podstawowy adres URL Wallabag",
|
||||
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
|
||||
"form.integration.wallabag_username": "Login do Wallabag",
|
||||
@@ -500,7 +500,7 @@
|
||||
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
|
||||
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
|
||||
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
|
||||
"form.integration.readeck_endpoint": "Punkt końcowy API Readeck",
|
||||
"form.integration.readeck_endpoint": "Adres URL Readeck",
|
||||
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
|
||||
"form.integration.readeck_labels": "Etykiety Readeck",
|
||||
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
@@ -523,6 +523,9 @@
|
||||
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
|
||||
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
|
||||
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
|
||||
"form.integration.ntfy_internal_links": "Używaj łączy wewnętrznych po kliknięciu (opcjonalnie)",
|
||||
"form.integration.discord_activate": "Przesyłaj wpisy do Discord",
|
||||
"form.integration.discord_webhook_link": "Adres URL Webhook Discord",
|
||||
"form.api_key.label.description": "Etykieta klucza API",
|
||||
"form.submit.loading": "Ładowanie…",
|
||||
"form.submit.saving": "Zapisywanie…",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"form.integration.pocket_connect_link": "Conectar a conta do Pocket",
|
||||
"form.integration.wallabag_activate": "Salvar itens no Wallabag",
|
||||
"form.integration.wallabag_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
|
||||
"form.integration.wallabag_endpoint": "Endpoint da API do Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL base do Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID de cliente (Client ID) do Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Segredo do cliente (Client Secret) do Wallabag",
|
||||
"form.integration.wallabag_username": "Nome de usuário do Wallabag",
|
||||
@@ -513,6 +513,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "Etiqueta da chave de API",
|
||||
"form.submit.loading": "Carregando...",
|
||||
"form.submit.saving": "Salvando...",
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
"form.integration.pocket_connect_link": "Подключить аккаунт Pocket",
|
||||
"form.integration.wallabag_only_url": "Отправлять только ссылку (без содержимого)",
|
||||
"form.integration.wallabag_activate": "Сохранять статьи в Wallabag",
|
||||
"form.integration.wallabag_endpoint": "Конечная точка Wallabag API",
|
||||
"form.integration.wallabag_endpoint": "URL-адрес базы Валлабаг",
|
||||
"form.integration.wallabag_client_id": "Номер клиента Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Секретный код клиента Wallabag",
|
||||
"form.integration.wallabag_username": "Имя пользователя Wallabag",
|
||||
@@ -523,6 +523,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Отправить статьи в Discord",
|
||||
"form.integration.discord_webhook_link": "Ссылка на Discord Webhook",
|
||||
"form.api_key.label.description": "Описание API-ключа",
|
||||
"form.submit.loading": "Загрузка…",
|
||||
"form.submit.saving": "Сохранение…",
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_endpoint": "Wallabag API Uç Noktası",
|
||||
"form.integration.wallabag_endpoint": "Wallabag Üssü URL",
|
||||
"form.integration.wallabag_only_url": "Yalnızca URL gönder (tam makale yerine)",
|
||||
"form.integration.wallabag_password": "Wallabag Parolası",
|
||||
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
|
||||
@@ -279,6 +279,7 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
@@ -288,6 +289,8 @@
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.integration.discord_activate": "Makaleleri Discord'a gönder",
|
||||
"form.integration.discord_webhook_link": "Discord hizmet Webhook'lerinin virgülle ayrılmış listesi",
|
||||
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
|
||||
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
|
||||
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
"form.integration.pocket_connect_link": "Підключити ваш обліковий запис Pocket",
|
||||
"form.integration.wallabag_activate": "Зберігати статті до Wallabag",
|
||||
"form.integration.wallabag_only_url": "Надіслати лише URL (замість повного вмісту)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag API Endpoint",
|
||||
"form.integration.wallabag_endpoint": "Базова URL-адреса Wallabag",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_username": "Ім’я користувача Wallabag",
|
||||
@@ -500,7 +500,7 @@
|
||||
"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_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
|
||||
@@ -523,6 +523,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "Push entries to Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "Назва ключа API",
|
||||
"form.submit.loading": "Завантаження...",
|
||||
"form.submit.saving": "Зберігаю...",
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "最后使用时间",
|
||||
"page.settings.webauthn.register": "注册 Passkey",
|
||||
"page.settings.webauthn.register.error": "无法注册 Passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入用户名。",
|
||||
"page.settings.webauthn.delete": [
|
||||
"删除 %d 个 Passkey"
|
||||
],
|
||||
@@ -425,7 +425,7 @@
|
||||
"form.integration.pocket_connect_link": "连接您的 Pocket 帐户",
|
||||
"form.integration.wallabag_activate": "保存文章到 Wallabag",
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 基本 URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥",
|
||||
"form.integration.wallabag_username": "Wallabag 用户名",
|
||||
@@ -503,6 +503,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy用户名(可选)",
|
||||
"form.integration.ntfy_password": "Ntfy密码(可选)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy图标URL(可选)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "将新文章推送到 Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "API密钥标签",
|
||||
"form.submit.loading": "载入中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
"form.integration.pocket_connect_link": "連線您的 Pocket 帳戶",
|
||||
"form.integration.wallabag_activate": "儲存文章到 Wallabag",
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_endpoint": "Wallabag 基本 URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag 客戶端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客戶端 Secret",
|
||||
"form.integration.wallabag_username": "Wallabag 使用者名稱",
|
||||
@@ -503,6 +503,9 @@
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.ntfy_internal_links": "Use internal links on click (optional)",
|
||||
"form.integration.discord_activate": "推送文章到 Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook link",
|
||||
"form.api_key.label.description": "API金鑰標籤",
|
||||
"form.submit.loading": "載入中…",
|
||||
"form.submit.saving": "儲存中…",
|
||||
|
||||
@@ -87,7 +87,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
}
|
||||
}
|
||||
|
||||
output, err := doc.Find("body").First().Html()
|
||||
output, err := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
if err != nil {
|
||||
return htmlDocument
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
@@ -34,9 +35,34 @@ func (e Enclosure) Html5MimeType() string {
|
||||
return e.MimeType
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsAudio() bool {
|
||||
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsVideo() bool {
|
||||
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsImage() bool {
|
||||
mimeType := strings.ToLower(e.MimeType)
|
||||
mediaURL := strings.ToLower(e.URL)
|
||||
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
|
||||
}
|
||||
|
||||
// EnclosureList represents a list of attachments.
|
||||
type EnclosureList []*Enclosure
|
||||
|
||||
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
|
||||
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
|
||||
for _, enclosure := range el {
|
||||
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
return enclosure
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
for _, enclosure := range el {
|
||||
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
|
||||
@@ -123,8 +123,8 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
|
||||
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
|
||||
} else {
|
||||
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
|
||||
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
|
||||
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
|
||||
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ type Integration struct {
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
NtfyInternalLinks bool
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
DiscordEnabled bool
|
||||
DiscordWebhookLink string
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package oauth2 // import "miniflux.app/v2/internal/oauth2"
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
@@ -33,17 +32,14 @@ func (u *Authorization) CodeVerifier() string {
|
||||
|
||||
func GenerateAuthorization(config *oauth2.Config) *Authorization {
|
||||
codeVerifier := crypto.GenerateRandomStringHex(32)
|
||||
|
||||
sha2 := sha256.New()
|
||||
io.WriteString(sha2, codeVerifier)
|
||||
codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))
|
||||
sum := sha256.Sum256([]byte(codeVerifier))
|
||||
|
||||
state := crypto.GenerateRandomStringHex(24)
|
||||
|
||||
authUrl := config.AuthCodeURL(
|
||||
state,
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(sum[:])),
|
||||
)
|
||||
|
||||
return &Authorization{
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/reader/media"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
)
|
||||
|
||||
// The "atom:feed" element is the document (i.e., top-level) element of
|
||||
@@ -188,7 +187,6 @@ func (a *Atom10Text) Title() string {
|
||||
content = a.CharData
|
||||
}
|
||||
|
||||
content = sanitizer.StripTags(content)
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
|
||||
@@ -492,6 +492,10 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
|
||||
</title>
|
||||
<link href="http://example.org/c"/>
|
||||
</entry>
|
||||
<entry>
|
||||
<title type="html"><![CDATA[Test with self-closing <tag>]]></title>
|
||||
<link href="http://example.org/d"/>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||
@@ -499,7 +503,11 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Code Test" {
|
||||
if len(feed.Entries) != 4 {
|
||||
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "<code>Code</code> Test" {
|
||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||
}
|
||||
|
||||
@@ -510,6 +518,10 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
|
||||
if feed.Entries[2].Title != "Entry title with space around CDATA" {
|
||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[2].Title)
|
||||
}
|
||||
|
||||
if feed.Entries[3].Title != "Test with self-closing <tag>" {
|
||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[3].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithXHTMLTitle(t *testing.T) {
|
||||
@@ -537,7 +549,7 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != `This is XHTML content.` {
|
||||
if feed.Entries[0].Title != `This is <b>XHTML</b> content.` {
|
||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
@@ -643,7 +655,7 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != `'AT&T'` {
|
||||
if feed.Entries[0].Title != `'AT&T'` {
|
||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bo
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) IsRedirect() bool {
|
||||
return r.httpResponse != nil &&
|
||||
(r.httpResponse.StatusCode == http.StatusMovedPermanently ||
|
||||
r.httpResponse.StatusCode == http.StatusFound ||
|
||||
r.httpResponse.StatusCode == http.StatusSeeOther ||
|
||||
r.httpResponse.StatusCode == http.StatusTemporaryRedirect ||
|
||||
r.httpResponse.StatusCode == http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) Close() {
|
||||
if r.httpResponse != nil && r.httpResponse.Body != nil && r.clientErr == nil {
|
||||
r.httpResponse.Body.Close()
|
||||
|
||||
@@ -31,11 +31,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
slog.String("feed_url", feedCreationRequest.FeedURL),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
@@ -71,7 +66,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
processor.ProcessFeedEntries(store, subscription, user, true)
|
||||
processor.ProcessFeedEntries(store, subscription, userID, true)
|
||||
|
||||
if storeErr := store.CreateFeed(subscription); storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -105,11 +100,6 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
slog.String("feed_url", feedCreationRequest.FeedURL),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
@@ -170,7 +160,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
processor.ProcessFeedEntries(store, subscription, user, true)
|
||||
processor.ProcessFeedEntries(store, subscription, userID, true)
|
||||
|
||||
if storeErr := store.CreateFeed(subscription); storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -195,11 +185,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.Bool("force_refresh", forceRefresh),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed, storeErr := store.FeedByID(userID, feedID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -256,6 +241,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch feed", slog.String("feed_url", originalFeed.FeedURL), slog.Any("error", localizedError.Error()))
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -263,6 +252,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -289,6 +282,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
|
||||
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
|
||||
}
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
@@ -309,13 +306,17 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
)
|
||||
|
||||
originalFeed.Entries = updatedFeed.Entries
|
||||
processor.ProcessFeedEntries(store, originalFeed, user, forceRefresh)
|
||||
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
|
||||
|
||||
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
|
||||
updateExistingEntries := forceRefresh || !originalFeed.Crawler
|
||||
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
|
||||
if storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -335,6 +336,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
originalFeed.EtagHeader = responseHandler.ETag()
|
||||
originalFeed.LastModifiedHeader = responseHandler.LastModified()
|
||||
|
||||
originalFeed.IconURL = updatedFeed.IconURL
|
||||
iconChecker := icon.NewIconChecker(store, originalFeed)
|
||||
if forceRefresh {
|
||||
iconChecker.UpdateOrCreateFeedIcon()
|
||||
@@ -358,6 +360,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
|
||||
@@ -34,32 +34,32 @@ func (c *IconChecker) fetchAndStoreIcon() {
|
||||
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
|
||||
|
||||
iconFinder := NewIconFinder(requestBuilder, c.feed.FeedURL, c.feed.IconURL)
|
||||
iconFinder := NewIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
|
||||
if icon, err := iconFinder.FindIcon(); err != nil {
|
||||
slog.Debug("Unable to find feed icon",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon == nil {
|
||||
slog.Debug("No icon found",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
)
|
||||
} else {
|
||||
if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
|
||||
slog.Error("Unable to store feed icon",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Feed icon stored",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.FeedURL),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Int64("icon_id", icon.ID),
|
||||
slog.String("icon_hash", icon.Hash),
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -19,6 +25,7 @@ import (
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
@@ -180,9 +187,59 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) {
|
||||
Content: responseBody,
|
||||
}
|
||||
|
||||
icon = resizeIcon(icon)
|
||||
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
r := bytes.NewReader(icon.Content)
|
||||
|
||||
if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif"}, icon.MimeType) {
|
||||
slog.Info("icon isn't a png/gif/jpeg/ico, can't resize", slog.String("mimetype", icon.MimeType))
|
||||
return icon
|
||||
}
|
||||
|
||||
// Don't resize icons that we can't decode, or that already have the right size.
|
||||
config, _, err := image.DecodeConfig(r)
|
||||
if err != nil {
|
||||
slog.Warn("unable to decode the metadata of the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
if config.Height <= 32 && config.Width <= 32 {
|
||||
slog.Debug("icon don't need to be rescaled", slog.Int("height", config.Height), slog.Int("width", config.Width))
|
||||
return icon
|
||||
}
|
||||
|
||||
r.Seek(0, io.SeekStart)
|
||||
|
||||
var src image.Image
|
||||
switch icon.MimeType {
|
||||
case "image/jpeg":
|
||||
src, err = jpeg.Decode(r)
|
||||
case "image/png":
|
||||
src, err = png.Decode(r)
|
||||
case "image/gif":
|
||||
src, err = gif.Decode(r)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("unable to decode the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, 32, 32))
|
||||
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
|
||||
|
||||
var b bytes.Buffer
|
||||
if err = png.Encode(io.Writer(&b), dst); err != nil {
|
||||
slog.Warn("unable to encode the new icon", slog.Any("error", err))
|
||||
}
|
||||
|
||||
icon.Content = b.Bytes()
|
||||
icon.MimeType = "image/png"
|
||||
return icon
|
||||
}
|
||||
|
||||
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
|
||||
queries := []string{
|
||||
"link[rel='icon' i]",
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestParseImageDataURL(t *testing.T) {
|
||||
@@ -125,3 +130,52 @@ func TestParseDocumentWithWhitespaceIconURL(t *testing.T) {
|
||||
t.Errorf(`Invalid icon URL, got %q`, iconURLs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeIconSmallGif(t *testing.T) {
|
||||
data, err := base64.StdEncoding.DecodeString("R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := model.Icon{
|
||||
Content: data,
|
||||
MimeType: "image/gif",
|
||||
}
|
||||
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
|
||||
t.Fatalf("Converted gif smaller than 16x16")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeIconPng(t *testing.T) {
|
||||
data, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAALUlEQVR42u3OMQEAAAgDoJnc6BpjDyRgcrcpGwkJCQkJCQkJCQkJCQkJCYmyB7NfUj/Kk4FkAAAAAElFTkSuQmCC")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := model.Icon{
|
||||
Content: data,
|
||||
MimeType: "image/png",
|
||||
}
|
||||
resizedIcon := resizeIcon(&icon)
|
||||
|
||||
if bytes.Equal(data, resizedIcon.Content) {
|
||||
t.Fatalf("Didn't convert png of 33x33")
|
||||
}
|
||||
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(resizedIcon.Content))
|
||||
if err != nil {
|
||||
t.Fatalf("Couln't decode resulting png: %v", err)
|
||||
}
|
||||
|
||||
if config.Height != 32 || config.Width != 32 {
|
||||
t.Fatalf("Was expecting an image of 16x16, got %dx%d", config.Width, config.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeInvalidImage(t *testing.T) {
|
||||
icon := model.Icon{
|
||||
Content: []byte("invalid data"),
|
||||
MimeType: "image/gif",
|
||||
}
|
||||
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
|
||||
t.Fatalf("Tried to convert an invalid image")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@@ -17,14 +17,17 @@ import (
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
|
||||
|
||||
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchNebulaWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := nebulaRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
|
||||
u, err := url.Parse(entry.URL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Hostname() == "nebula.tv"
|
||||
}
|
||||
|
||||
func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
@@ -45,7 +48,7 @@ func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="video:duration"]`)).Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@@ -17,14 +17,17 @@ import (
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := odyseeRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
|
||||
u, err := url.Parse(entry.URL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Hostname() == "odysee.com"
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
@@ -45,7 +48,7 @@ func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="og:video:duration"]`).First().Attr("content")
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="og:video:duration"]`)).Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/metric"
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -20,17 +23,20 @@ import (
|
||||
"miniflux.app/v2/internal/reader/scraper"
|
||||
"miniflux.app/v2/internal/reader/urlcleaner"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
)
|
||||
|
||||
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("([^"]+)"\|"([^"]+)"\)`)
|
||||
|
||||
// ProcessFeedEntries downloads original web page for entries and apply filters.
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64, forceRefresh bool) {
|
||||
var filteredEntries model.Entries
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
slog.Error("Database error", slog.Any("error", storeErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Process older entries first
|
||||
for i := len(feed.Entries) - 1; i >= 0; i-- {
|
||||
entry := feed.Entries[i]
|
||||
@@ -135,6 +141,9 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
@@ -205,6 +214,9 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
@@ -456,3 +468,44 @@ func minifyEntryContent(entryContent string) string {
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
|
||||
if pattern == "future" {
|
||||
return entryDate.After(time.Now())
|
||||
}
|
||||
|
||||
parts := strings.SplitN(pattern, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
operator := parts[0]
|
||||
dateStr := parts[1]
|
||||
|
||||
switch operator {
|
||||
case "before":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.Before(targetDate)
|
||||
case "after":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(targetDate)
|
||||
case "between":
|
||||
dates := strings.Split(dateStr, ",")
|
||||
if len(dates) != 2 {
|
||||
return false
|
||||
}
|
||||
startDate, err1 := time.Parse("2006-01-02", dates[0])
|
||||
endDate, err2 := time.Parse("2006-01-02", dates[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(startDate) && entryDate.Before(endDate)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ func TestAllowEntries(t *testing.T) {
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(-24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=before:2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 16, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=after:2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -33,6 +35,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
if config.Opts.YouTubeApiKey() == "" {
|
||||
return fetchYouTubeWatchTimeFromWebsite(websiteURL)
|
||||
} else {
|
||||
return fetchYouTubeWatchTimeFromApi(websiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
@@ -50,7 +60,7 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
@@ -63,6 +73,61 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse URL: %v", err)
|
||||
}
|
||||
|
||||
apiQuery := url.Values{}
|
||||
apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
|
||||
apiQuery.Set("key", config.Opts.YouTubeApiKey())
|
||||
apiQuery.Set("part", "contentDetails")
|
||||
|
||||
apiURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.googleapis.com",
|
||||
Path: "youtube/v3/videos",
|
||||
RawQuery: apiQuery.Encode(),
|
||||
}
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var videos struct {
|
||||
Items []struct {
|
||||
ContentDetails struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"contentDetails"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
|
||||
return 0, fmt.Errorf("unable to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if n := len(videos.Items); n != 1 {
|
||||
return 0, fmt.Errorf("invalid items length: %d", n)
|
||||
}
|
||||
|
||||
durs := videos.Items[0].ContentDetails.Duration
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
package readability // import "miniflux.app/v2/internal/reader/readability"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -24,14 +22,12 @@ const (
|
||||
|
||||
var (
|
||||
divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
|
||||
sentenceRegexp = regexp.MustCompile(`\.( |$)`)
|
||||
|
||||
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
|
||||
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
|
||||
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
negativeRegexp = regexp.MustCompile(`hid|banner|combx|comment|com-|contact|foot|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby`)
|
||||
positiveRegexp = regexp.MustCompile(`article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
)
|
||||
|
||||
type candidate struct {
|
||||
@@ -77,16 +73,14 @@ func ExtractContent(page io.Reader) (baseURL string, extractedContent string, er
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
|
||||
if hrefValue, exists := document.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
document.Find("script,style").Each(func(i int, s *goquery.Selection) {
|
||||
removeNodes(s)
|
||||
})
|
||||
document.Find("script,style").Remove()
|
||||
|
||||
transformMisusedDivsIntoParagraphs(document)
|
||||
removeUnlikelyCandidates(document)
|
||||
@@ -107,8 +101,9 @@ func ExtractContent(page io.Reader) (baseURL string, extractedContent string, er
|
||||
// Now that we have the top candidate, look through its siblings for content that might also be related.
|
||||
// Things like preambles, content split by ads that we removed, etc.
|
||||
func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
output := bytes.NewBufferString("<div>")
|
||||
siblingScoreThreshold := float32(math.Max(10, float64(topCandidate.score*.2)))
|
||||
var output strings.Builder
|
||||
output.WriteString("<div>")
|
||||
siblingScoreThreshold := max(10, topCandidate.score*.2)
|
||||
|
||||
topCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {
|
||||
append := false
|
||||
@@ -125,10 +120,14 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
content := s.Text()
|
||||
contentLength := len(content)
|
||||
|
||||
if contentLength >= 80 && linkDensity < .25 {
|
||||
append = true
|
||||
} else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
|
||||
append = true
|
||||
if contentLength >= 80 {
|
||||
if linkDensity < .25 {
|
||||
append = true
|
||||
}
|
||||
} else {
|
||||
if linkDensity == 0 && containsSentence(content) {
|
||||
append = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +138,7 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
}
|
||||
|
||||
html, _ := s.Html()
|
||||
fmt.Fprintf(output, "<%s>%s</%s>", tag, html, tag)
|
||||
output.WriteString("<" + tag + ">" + html + "</" + tag + ">")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -148,16 +147,29 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
||||
}
|
||||
|
||||
func removeUnlikelyCandidates(document *goquery.Document) {
|
||||
var shouldRemove = func(str string) bool {
|
||||
str = strings.ToLower(str)
|
||||
if strings.Contains(str, "popupbody") || strings.Contains(str, "-ad") || strings.Contains(str, "g-plus") {
|
||||
return true
|
||||
} else if unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
document.Find("*").Each(func(i int, s *goquery.Selection) {
|
||||
if s.Length() == 0 || s.Get(0).Data == "html" || s.Get(0).Data == "body" {
|
||||
return
|
||||
}
|
||||
class, _ := s.Attr("class")
|
||||
id, _ := s.Attr("id")
|
||||
str := class + id
|
||||
|
||||
if blacklistCandidatesRegexp.MatchString(str) || (unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str)) {
|
||||
removeNodes(s)
|
||||
if class, ok := s.Attr("class"); ok {
|
||||
if shouldRemove(class) {
|
||||
s.Remove()
|
||||
}
|
||||
} else if id, ok := s.Attr("id"); ok {
|
||||
if shouldRemove(id) {
|
||||
s.Remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -221,7 +233,7 @@ func getCandidates(document *goquery.Document) candidateList {
|
||||
contentScore += float32(strings.Count(text, ",") + 1)
|
||||
|
||||
// For every 100 characters in this paragraph, add another point. Up to 3 points.
|
||||
contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
|
||||
contentScore += float32(min(len(text)/100.0, 3))
|
||||
|
||||
candidates[parentNode].score += contentScore
|
||||
if grandParentNode != nil {
|
||||
@@ -260,13 +272,14 @@ func scoreNode(s *goquery.Selection) *candidate {
|
||||
// Get the density of links as a percentage of the content
|
||||
// This is the amount of text that is inside a link divided by the total text in the node.
|
||||
func getLinkDensity(s *goquery.Selection) float32 {
|
||||
linkLength := len(s.Find("a").Text())
|
||||
textLength := len(s.Text())
|
||||
|
||||
if textLength == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
linkLength := len(s.Find("a").Text())
|
||||
|
||||
return float32(linkLength) / float32(textLength)
|
||||
}
|
||||
|
||||
@@ -274,25 +287,21 @@ func getLinkDensity(s *goquery.Selection) float32 {
|
||||
// element looks good or bad.
|
||||
func getClassWeight(s *goquery.Selection) float32 {
|
||||
weight := 0
|
||||
class, _ := s.Attr("class")
|
||||
id, _ := s.Attr("id")
|
||||
|
||||
if class != "" {
|
||||
if class, ok := s.Attr("class"); ok {
|
||||
class = strings.ToLower(class)
|
||||
if negativeRegexp.MatchString(class) {
|
||||
weight -= 25
|
||||
}
|
||||
|
||||
if positiveRegexp.MatchString(class) {
|
||||
} else if positiveRegexp.MatchString(class) {
|
||||
weight += 25
|
||||
}
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
if id, ok := s.Attr("id"); ok {
|
||||
id = strings.ToLower(id)
|
||||
if negativeRegexp.MatchString(id) {
|
||||
weight -= 25
|
||||
}
|
||||
|
||||
if positiveRegexp.MatchString(id) {
|
||||
} else if positiveRegexp.MatchString(id) {
|
||||
weight += 25
|
||||
}
|
||||
}
|
||||
@@ -310,11 +319,6 @@ func transformMisusedDivsIntoParagraphs(document *goquery.Document) {
|
||||
})
|
||||
}
|
||||
|
||||
func removeNodes(s *goquery.Selection) {
|
||||
s.Each(func(i int, s *goquery.Selection) {
|
||||
parent := s.Parent()
|
||||
if parent.Length() > 0 {
|
||||
parent.Get(0).RemoveChild(s.Get(0))
|
||||
}
|
||||
})
|
||||
func containsSentence(content string) bool {
|
||||
return strings.HasSuffix(content, ".") || strings.Contains(content, ". ")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package readability // import "miniflux.app/v2/internal/reader/readability"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -100,3 +102,83 @@ func TestWithoutBaseURL(t *testing.T) {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of ""`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStyleScript(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<script src="tololo.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="tololo.js"></script>
|
||||
<style>
|
||||
h1 {color:red;}
|
||||
p {color:blue;}
|
||||
</style>
|
||||
<article>Some content</article>
|
||||
</body>
|
||||
</html>`
|
||||
want := `<div><div><article>Somecontent</article></div></div>`
|
||||
|
||||
_, content, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
content = strings.ReplaceAll(content, " ", "")
|
||||
content = strings.ReplaceAll(content, "\t", "")
|
||||
|
||||
if content != want {
|
||||
t.Errorf(`Invalid content, got %s instead of %s`, content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveBlacklist(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<article class="super-ad">Some content</article>
|
||||
<article class="g-plus-crap">Some other thing</article>
|
||||
<article class="stuff popupbody">And more</article>
|
||||
<article class="legit">Valid!</article>
|
||||
</body>
|
||||
</html>`
|
||||
want := `<div><div><articleclass="legit">Valid!</article></div></div>`
|
||||
|
||||
_, content, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\n", "")
|
||||
content = strings.ReplaceAll(content, " ", "")
|
||||
content = strings.ReplaceAll(content, "\t", "")
|
||||
|
||||
if content != want {
|
||||
t.Errorf(`Invalid content, got %s instead of %s`, content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExtractContent(b *testing.B) {
|
||||
var testCases = map[string][]byte{
|
||||
"miniflux_github.html": {},
|
||||
"miniflux_wikipedia.html": {},
|
||||
}
|
||||
for filename := range testCases {
|
||||
data, err := os.ReadFile("testdata/" + filename)
|
||||
if err != nil {
|
||||
b.Fatalf(`Unable to read file %q: %v`, filename, err)
|
||||
}
|
||||
testCases[filename] = data
|
||||
}
|
||||
for range b.N {
|
||||
for _, v := range testCases {
|
||||
ExtractContent(bytes.NewReader(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../reader/sanitizer/testdata/
|
||||
@@ -7,33 +7,37 @@ package readingtime
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
"github.com/abadojack/whatlanggo"
|
||||
)
|
||||
|
||||
// EstimateReadingTime returns the estimated reading time of an article in minute.
|
||||
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
|
||||
sanitizedContent := sanitizer.StripTags(content)
|
||||
truncationPoint := min(len(sanitizedContent), 50)
|
||||
|
||||
// Litterature on language detection says that around 100 signes is enough, we're safe here.
|
||||
truncationPoint := int(math.Min(float64(len(sanitizedContent)), 250))
|
||||
|
||||
// We're only interested in identifying Japanse/Chinese/Korean
|
||||
options := whatlanggo.Options{
|
||||
Whitelist: map[whatlanggo.Lang]bool{
|
||||
whatlanggo.Jpn: true,
|
||||
whatlanggo.Cmn: true,
|
||||
whatlanggo.Kor: true,
|
||||
},
|
||||
}
|
||||
langInfo := whatlanggo.DetectWithOptions(sanitizedContent[:truncationPoint], options)
|
||||
|
||||
if langInfo.IsReliable() {
|
||||
if isCJK(sanitizedContent[:truncationPoint]) {
|
||||
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
|
||||
}
|
||||
nbOfWords := len(strings.Fields(sanitizedContent))
|
||||
return int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
|
||||
return int(math.Ceil(float64(len(strings.Fields(sanitizedContent))) / float64(defaultReadingSpeed)))
|
||||
}
|
||||
|
||||
func isCJK(text string) bool {
|
||||
totalCJK := 0
|
||||
|
||||
for _, r := range text[:min(len(text), 50)] {
|
||||
if unicode.Is(unicode.Han, r) ||
|
||||
unicode.Is(unicode.Hangul, r) ||
|
||||
unicode.Is(unicode.Hiragana, r) ||
|
||||
unicode.Is(unicode.Katakana, r) ||
|
||||
unicode.Is(unicode.Yi, r) ||
|
||||
unicode.Is(unicode.Bopomofo, r) {
|
||||
totalCJK++
|
||||
}
|
||||
}
|
||||
|
||||
// if at least 50% of the text is CJK, odds are that the text is in CJK.
|
||||
return totalCJK > len(text)/50
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetRefererForURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Weibo Image URL",
|
||||
url: "https://wx1.sinaimg.cn/large/example.jpg",
|
||||
expected: "https://weibo.com",
|
||||
},
|
||||
{
|
||||
name: "Pixiv Image URL",
|
||||
url: "https://i.pximg.net/img-master/example.jpg",
|
||||
expected: "https://www.pixiv.net",
|
||||
},
|
||||
{
|
||||
name: "SSPai CDN URL",
|
||||
url: "https://cdnfile.sspai.com/example.png",
|
||||
expected: "https://sspai.com",
|
||||
},
|
||||
{
|
||||
name: "Instagram CDN URL",
|
||||
url: "https://scontent-sjc3-1.cdninstagram.com/example.jpg",
|
||||
expected: "https://www.instagram.com",
|
||||
},
|
||||
{
|
||||
name: "Piokok URL",
|
||||
url: "https://sp1.piokok.com/example.jpg",
|
||||
expected: "https://sp1.piokok.com",
|
||||
},
|
||||
{
|
||||
name: "Weibo Video URL",
|
||||
url: "https://f.video.weibocdn.com/example.mp4",
|
||||
expected: "https://weibo.com",
|
||||
},
|
||||
{
|
||||
name: "HelloGithub Image URL",
|
||||
url: "https://img.hellogithub.com/example.png",
|
||||
expected: "https://hellogithub.com",
|
||||
},
|
||||
{
|
||||
name: "Non-matching URL",
|
||||
url: "https://example.com/image.jpg",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := GetRefererForURL(tc.url)
|
||||
if result != tc.expected {
|
||||
t.Errorf("GetRefererForURL(%s): expected %s, got %s",
|
||||
tc.url, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,25 +11,41 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
|
||||
nethtml "golang.org/x/net/html"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/yuin/goldmark"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
youtubeIdRegex = regexp.MustCompile(`youtube_id"?\s*[:=]\s*"([a-zA-Z0-9_-]{11})"`)
|
||||
invidioRegex = regexp.MustCompile(`https?://(.*)/watch\?v=(.*)`)
|
||||
imgRegex = regexp.MustCompile(`<img [^>]+>`)
|
||||
textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
||||
)
|
||||
|
||||
func addImageTitle(entryURL, entryContent string) string {
|
||||
// titlelize returns a copy of the string s with all Unicode letters that begin words
|
||||
// mapped to their Unicode title case.
|
||||
func titlelize(s string) string {
|
||||
// A closure is used here to remember the previous character
|
||||
// so that we can check if there is a space preceding the current
|
||||
// character.
|
||||
previous := ' '
|
||||
return strings.Map(
|
||||
func(current rune) rune {
|
||||
if unicode.IsSpace(previous) {
|
||||
previous = current
|
||||
return unicode.ToTitle(current)
|
||||
}
|
||||
previous = current
|
||||
return current
|
||||
}, strings.ToLower(s))
|
||||
}
|
||||
|
||||
func addImageTitle(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -46,14 +62,14 @@ func addImageTitle(entryURL, entryContent string) string {
|
||||
img.ReplaceWithHtml(`<figure><img src="` + srcAttr + `" alt="` + altAttr + `"/><figcaption><p>` + html.EscapeString(titleAttr) + `</p></figcaption></figure>`)
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addMailtoSubject(entryURL, entryContent string) string {
|
||||
func addMailtoSubject(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -78,18 +94,19 @@ func addMailtoSubject(entryURL, entryContent string) string {
|
||||
a.AppendHtml(" [" + html.EscapeString(subject) + "]")
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addDynamicImage(entryURL, entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
func addDynamicImage(entryContent string) string {
|
||||
parserHtml, err := nethtml.ParseWithOptions(strings.NewReader(entryContent), nethtml.ParseOptionEnableScripting(false))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
}
|
||||
doc := goquery.NewDocumentFromNode(parserHtml)
|
||||
|
||||
// Ordered most preferred to least preferred.
|
||||
candidateAttrs := []string{
|
||||
@@ -151,25 +168,22 @@ func addDynamicImage(entryURL, entryContent string) string {
|
||||
|
||||
if !changed {
|
||||
doc.Find("noscript").Each(func(i int, noscript *goquery.Selection) {
|
||||
matches := imgRegex.FindAllString(noscript.Text(), 2)
|
||||
|
||||
if len(matches) == 1 {
|
||||
if img := noscript.Find("img"); img.Length() == 1 {
|
||||
img.Unwrap()
|
||||
changed = true
|
||||
|
||||
noscript.ReplaceWithHtml(matches[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if changed {
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func addDynamicIframe(entryURL, entryContent string) string {
|
||||
func addDynamicIframe(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -199,14 +213,14 @@ func addDynamicIframe(entryURL, entryContent string) string {
|
||||
})
|
||||
|
||||
if changed {
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func fixMediumImages(entryURL, entryContent string) string {
|
||||
func fixMediumImages(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -219,11 +233,11 @@ func fixMediumImages(entryURL, entryContent string) string {
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
func useNoScriptImages(entryURL, entryContent string) string {
|
||||
func useNoScriptImages(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
@@ -241,7 +255,7 @@ func useNoScriptImages(entryURL, entryContent string) string {
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -319,7 +333,7 @@ func removeCustom(entryContent string, selector string) string {
|
||||
|
||||
doc.Find(selector).Remove()
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -346,7 +360,7 @@ func applyFuncOnTextContent(entryContent string, selector string, repl func(stri
|
||||
|
||||
doc.Find(selector).Each(treatChildren)
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -403,28 +417,13 @@ func addHackerNewsLinksUsing(entryContent, app string) string {
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func parseMarkdown(entryContent string) string {
|
||||
var sb strings.Builder
|
||||
md := goldmark.New(
|
||||
goldmark.WithRendererOptions(
|
||||
goldmarkhtml.WithUnsafe(),
|
||||
),
|
||||
)
|
||||
|
||||
if err := md.Convert([]byte(entryContent), &sb); err != nil {
|
||||
return entryContent
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func removeTables(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
@@ -437,7 +436,7 @@ func removeTables(entryContent string) string {
|
||||
|
||||
for _, selector := range selectors {
|
||||
for {
|
||||
loopElement = doc.Find(selector).First()
|
||||
loopElement = doc.FindMatcher(goquery.Single(selector))
|
||||
|
||||
if loopElement.Length() == 0 {
|
||||
break
|
||||
@@ -453,6 +452,58 @@ func removeTables(entryContent string) string {
|
||||
}
|
||||
}
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return output
|
||||
}
|
||||
|
||||
func fixGhostCards(entryContent string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(entryContent))
|
||||
if err != nil {
|
||||
return entryContent
|
||||
}
|
||||
|
||||
const cardSelector = "figure.kg-card"
|
||||
var currentList *goquery.Selection
|
||||
|
||||
doc.Find(cardSelector).Each(func(i int, s *goquery.Selection) {
|
||||
title := s.Find(".kg-bookmark-title").First().Text()
|
||||
author := s.Find(".kg-bookmark-author").First().Text()
|
||||
href := s.Find("a.kg-bookmark-container").First().AttrOr("href", "")
|
||||
|
||||
// if there is no link or title, skip processing
|
||||
if href == "" || title == "" {
|
||||
return
|
||||
}
|
||||
|
||||
link := ""
|
||||
if author == "" || strings.HasSuffix(title, author) {
|
||||
link = fmt.Sprintf("<a href=\"%s\">%s</a>", href, title)
|
||||
} else {
|
||||
link = fmt.Sprintf("<a href=\"%s\">%s - %s</a>", href, title, author)
|
||||
}
|
||||
|
||||
next := s.Next()
|
||||
|
||||
// if the next element is also a card, start a list
|
||||
if next.Is(cardSelector) && currentList == nil {
|
||||
currentList = s.BeforeHtml("<ul></ul>").Prev()
|
||||
}
|
||||
|
||||
if currentList != nil {
|
||||
// add this card to the list, then delete it
|
||||
currentList.AppendHtml("<li>" + link + "</li>")
|
||||
s.Remove()
|
||||
} else {
|
||||
// replace single card
|
||||
s.ReplaceWithHtml(link)
|
||||
}
|
||||
|
||||
// if the next element is not a card, start a new list
|
||||
if !next.Is(cardSelector) && currentList != nil {
|
||||
currentList = nil
|
||||
}
|
||||
})
|
||||
|
||||
output, _ := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type rule struct {
|
||||
@@ -24,13 +21,13 @@ type rule struct {
|
||||
func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
switch rule.name {
|
||||
case "add_image_title":
|
||||
entry.Content = addImageTitle(entryURL, entry.Content)
|
||||
entry.Content = addImageTitle(entry.Content)
|
||||
case "add_mailto_subject":
|
||||
entry.Content = addMailtoSubject(entryURL, entry.Content)
|
||||
entry.Content = addMailtoSubject(entry.Content)
|
||||
case "add_dynamic_image":
|
||||
entry.Content = addDynamicImage(entryURL, entry.Content)
|
||||
entry.Content = addDynamicImage(entry.Content)
|
||||
case "add_dynamic_iframe":
|
||||
entry.Content = addDynamicIframe(entryURL, entry.Content)
|
||||
entry.Content = addDynamicIframe(entry.Content)
|
||||
case "add_youtube_video":
|
||||
entry.Content = addYoutubeVideo(entryURL, entry.Content)
|
||||
case "add_invidious_video":
|
||||
@@ -46,9 +43,9 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
case "convert_text_link", "convert_text_links":
|
||||
entry.Content = replaceTextLinks(entry.Content)
|
||||
case "fix_medium_images":
|
||||
entry.Content = fixMediumImages(entryURL, entry.Content)
|
||||
entry.Content = fixMediumImages(entry.Content)
|
||||
case "use_noscript_figure_images":
|
||||
entry.Content = useNoScriptImages(entryURL, entry.Content)
|
||||
entry.Content = useNoScriptImages(entry.Content)
|
||||
case "replace":
|
||||
// Format: replace("search-term"|"replace-term")
|
||||
if len(rule.args) >= 2 {
|
||||
@@ -91,12 +88,12 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
entry.Content = addHackerNewsLinksUsing(entry.Content, "hack")
|
||||
case "add_hn_links_using_opener":
|
||||
entry.Content = addHackerNewsLinksUsing(entry.Content, "opener")
|
||||
case "parse_markdown":
|
||||
entry.Content = parseMarkdown(entry.Content)
|
||||
case "remove_tables":
|
||||
entry.Content = removeTables(entry.Content)
|
||||
case "remove_clickbait":
|
||||
entry.Title = cases.Title(language.English).String(strings.ToLower(entry.Title))
|
||||
entry.Title = titlelize(entry.Title)
|
||||
case "fix_ghost_cards":
|
||||
entry.Content = fixGhostCards(entry.Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ func TestRewriteWithNoLazyImage(t *testing.T) {
|
||||
func TestRewriteWithLazyImage(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<img src="https://example.org/image.jpg" data-url="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"></noscript>`,
|
||||
Content: `<img src="https://example.org/image.jpg" data-url="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"/></noscript>`,
|
||||
}
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
@@ -272,7 +272,7 @@ func TestRewriteWithLazyImage(t *testing.T) {
|
||||
func TestRewriteWithLazyDivImage(t *testing.T) {
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<img src="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"></noscript>`,
|
||||
Content: `<img src="https://example.org/image.jpg" alt="Image"/><noscript><img src="https://example.org/fallback.jpg" alt="Fallback"/></noscript>`,
|
||||
}
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
@@ -703,3 +703,231 @@ func TestAddImageTitle(t *testing.T) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCard(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
<div class="kg-bookmark-metadata">
|
||||
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
|
||||
<span class="kg-bookmark-author">Example</span>
|
||||
<span class="kg-bookmark-publisher">Test Author</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCardNoCard(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCardInvalidCard(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a href="https://example.org/article">This card does not have the required fields</a>
|
||||
</figure>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a href="https://example.org/article">This card does not have the required fields</a>
|
||||
</figure>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCardMissingAuthor(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<a href="https://example.org/article">Example Article</a>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCardDuplicatedAuthor(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article - Example</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
<div class="kg-bookmark-metadata">
|
||||
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
|
||||
<span class="kg-bookmark-author">Example</span>
|
||||
<span class="kg-bookmark-publisher">Test Author</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<a href="https://example.org/article">Example Article - Example</a>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCardMultiple(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article1">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article 1 - Example</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
<div class="kg-bookmark-metadata">
|
||||
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
|
||||
<span class="kg-bookmark-author">Example</span>
|
||||
<span class="kg-bookmark-publisher">Test Author</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>
|
||||
<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article2">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article 2 - Example</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
<div class="kg-bookmark-metadata">
|
||||
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
|
||||
<span class="kg-bookmark-author">Example</span>
|
||||
<span class="kg-bookmark-publisher">Test Author</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<ul><li><a href="https://example.org/article1">Example Article 1 - Example</a></li><li><a href="https://example.org/article2">Example Article 2 - Example</a></li></ul>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixGhostCardMultipleSplit(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article1">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article 1 - Example</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
<div class="kg-bookmark-metadata">
|
||||
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
|
||||
<span class="kg-bookmark-author">Example</span>
|
||||
<span class="kg-bookmark-publisher">Test Author</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>
|
||||
<p>This separates the two cards</p>
|
||||
<figure class="kg-card kg-bookmark-card">
|
||||
<a class="kg-bookmark-container" href="https://example.org/article2">
|
||||
<div class="kg-bookmark-content">
|
||||
<div class="kg-bookmark-title">Example Article 2 - Example</div>
|
||||
<div class="kg-bookmark-description">Lorem ipsum odor amet, consectetuer adipiscing elit. Pretium magnis luctus ligula conubia quam, donec orci vehicula efficitur...</div>
|
||||
<div class="kg-bookmark-metadata">
|
||||
<img class="kg-bookmark-icon" src="https://example.org/favicon.ico" alt="">
|
||||
<span class="kg-bookmark-author">Example</span>
|
||||
<span class="kg-bookmark-publisher">Test Author</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kg-bookmark-thumbnail">
|
||||
<img src="https://example.org/article-image.jpg" alt="" onerror="this.style.display = 'none'">
|
||||
</div>
|
||||
</a>
|
||||
</figure>`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<a href="https://example.org/article1">Example Article 1 - Example</a>
|
||||
<p>This separates the two cards</p>
|
||||
<a href="https://example.org/article2">Example Article 2 - Example</a>`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `fix_ghost_cards`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// List of predefined rewrite rules (alphabetically sorted)
|
||||
// Available rules: "add_image_title", "add_youtube_video"
|
||||
// domain => rule name
|
||||
@@ -10,7 +15,6 @@ var predefinedRules = map[string]string{
|
||||
"abstrusegoose.com": "add_image_title",
|
||||
"amazingsuperpowers.com": "add_image_title",
|
||||
"blog.cloudflare.com": `add_image_title,remove("figure.kg-image-card figure.kg-image + img")`,
|
||||
"blog.laravel.com": "parse_markdown",
|
||||
"cowbirdsinlove.com": "add_image_title",
|
||||
"drawingboardcomic.com": "add_image_title",
|
||||
"exocomics.com": "add_image_title",
|
||||
@@ -34,7 +38,44 @@ var predefinedRules = map[string]string{
|
||||
"treelobsters.com": "add_image_title",
|
||||
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
|
||||
"www.qwantz.com": "add_image_title,add_mailto_subject",
|
||||
"www.recalbox.com": "parse_markdown",
|
||||
"xkcd.com": "add_image_title",
|
||||
"youtube.com": "add_youtube_video",
|
||||
}
|
||||
|
||||
// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.
|
||||
func GetRefererForURL(u string) string {
|
||||
parsedUrl, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch parsedUrl.Hostname() {
|
||||
case "moyu.im":
|
||||
return "https://i.jandan.net"
|
||||
case "i.pximg.net":
|
||||
return "https://www.pixiv.net"
|
||||
case "sp1.piokok.com":
|
||||
return "https://sp1.piokok.com"
|
||||
case "cdnfile.sspai.com":
|
||||
return "https://sspai.com"
|
||||
case "f.video.weibocdn.com":
|
||||
return "https://weibo.com"
|
||||
case "img.hellogithub.com":
|
||||
return "https://hellogithub.com"
|
||||
case "bjp.org.cn":
|
||||
return "https://bjp.org.cn"
|
||||
case "appinn.com":
|
||||
return "https://appinn.com"
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".sinaimg.cn"):
|
||||
return "https://weibo.com"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".cdninstagram.com"):
|
||||
return "https://www.instagram.com"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".moyu.im"):
|
||||
return "https://i.jandan.net"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,8 +18,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeEmbedRegex = regexp.MustCompile(`//(?:www\.)?youtube\.com/embed/(.+)$`)
|
||||
tagAllowList = map[string][]string{
|
||||
tagAllowList = map[string][]string{
|
||||
"a": {"href", "title", "id"},
|
||||
"abbr": {"title"},
|
||||
"acronym": {"title"},
|
||||
@@ -45,6 +43,7 @@ var (
|
||||
"h4": {"id"},
|
||||
"h5": {"id"},
|
||||
"h6": {"id"},
|
||||
"hr": {},
|
||||
"iframe": {"width", "height", "frameborder", "src", "allowfullscreen"},
|
||||
"img": {"alt", "title", "src", "srcset", "sizes", "width", "height"},
|
||||
"ins": {},
|
||||
@@ -98,6 +97,7 @@ func Sanitize(baseURL, input string) string {
|
||||
}
|
||||
|
||||
token := tokenizer.Token()
|
||||
tagName := token.DataAtom.String()
|
||||
switch token.Type {
|
||||
case html.TextToken:
|
||||
if len(blockedStack) > 0 {
|
||||
@@ -110,9 +110,8 @@ func Sanitize(baseURL, input string) string {
|
||||
continue
|
||||
}
|
||||
|
||||
buffer.WriteString(html.EscapeString(token.Data))
|
||||
buffer.WriteString(token.String())
|
||||
case html.StartTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
parentTag = tagName
|
||||
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
@@ -121,38 +120,42 @@ func Sanitize(baseURL, input string) string {
|
||||
|
||||
if isBlockedTag(tagName) || slices.ContainsFunc(token.Attr, func(attr html.Attribute) bool { return attr.Key == "hidden" }) {
|
||||
blockedStack = append(blockedStack, tagName)
|
||||
} else if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + ">")
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
|
||||
tagStack = append(tagStack, tagName)
|
||||
}
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if len(blockedStack) > 0 && blockedStack[len(blockedStack)-1] == tagName {
|
||||
blockedStack = blockedStack[:len(blockedStack)-1]
|
||||
} else if len(blockedStack) == 0 && isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString("</" + tagName + ">")
|
||||
if len(blockedStack) == 0 {
|
||||
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
} else {
|
||||
if blockedStack[len(blockedStack)-1] == tagName {
|
||||
blockedStack = blockedStack[:len(blockedStack)-1]
|
||||
}
|
||||
}
|
||||
case html.SelfClosingTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) && len(blockedStack) == 0 {
|
||||
if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + "/>")
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,11 +186,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
if tagName == "img" && (attribute.Key == "width" || attribute.Key == "height") {
|
||||
if !isPositiveInteger(value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isImageLargerThanLayout {
|
||||
if isImageLargerThanLayout || !isPositiveInteger(value) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -201,7 +200,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
value = rewriteIframeURL(attribute.Val)
|
||||
case tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val):
|
||||
value = attribute.Val
|
||||
case isAnchor("a", attribute):
|
||||
case tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#"):
|
||||
value = attribute.Val
|
||||
isAnchorLink = true
|
||||
default:
|
||||
@@ -221,7 +220,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s=%q`, attribute.Key, html.EscapeString(value)))
|
||||
htmlAttrs = append(htmlAttrs, attribute.Key+`="`+html.EscapeString(value)+`"`)
|
||||
}
|
||||
|
||||
if !isAnchorLink {
|
||||
@@ -251,10 +250,8 @@ func getExtraAttributes(tagName string) ([]string, []string) {
|
||||
}
|
||||
|
||||
func isValidTag(tagName string) bool {
|
||||
if _, ok := tagAllowList[tagName]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
_, ok := tagAllowList[tagName]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isValidAttribute(tagName, attributeName string) bool {
|
||||
@@ -294,24 +291,16 @@ func isPixelTracker(tagName string, attributes []html.Attribute) bool {
|
||||
}
|
||||
|
||||
func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
elements := map[string][]string{
|
||||
"a": {"href"},
|
||||
"iframe": {"src"},
|
||||
"img": {"src"},
|
||||
"source": {"src", "srcset"},
|
||||
switch tagName {
|
||||
case "a":
|
||||
return slices.Contains(attributes, "href")
|
||||
case "iframe", "img":
|
||||
return slices.Contains(attributes, "src")
|
||||
case "source":
|
||||
return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
if attrs, ok := elements[tagName]; ok {
|
||||
for _, attribute := range attributes {
|
||||
if slices.Contains(attrs, attribute) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
||||
@@ -369,7 +358,6 @@ func isBlockedResource(src string) bool {
|
||||
"feedsportal.com",
|
||||
"api.flattr.com",
|
||||
"stats.wordpress.com",
|
||||
"plus.google.com/share",
|
||||
"twitter.com/share",
|
||||
"feeds.feedburner.com",
|
||||
}
|
||||
@@ -401,7 +389,7 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
}
|
||||
|
||||
// allow iframe from custom invidious instance
|
||||
if config.Opts != nil && config.Opts.InvidiousInstance() == domain {
|
||||
if config.Opts.InvidiousInstance() == domain {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -409,9 +397,27 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
}
|
||||
|
||||
func rewriteIframeURL(link string) string {
|
||||
matches := youtubeEmbedRegex.FindStringSubmatch(link)
|
||||
if len(matches) == 2 {
|
||||
return config.Opts.YouTubeEmbedUrlOverride() + matches[1]
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return link
|
||||
}
|
||||
|
||||
switch strings.TrimPrefix(u.Hostname(), "www.") {
|
||||
case "youtube.com":
|
||||
if strings.HasPrefix(u.Path, "/embed/") {
|
||||
if len(u.RawQuery) > 0 {
|
||||
return config.Opts.YouTubeEmbedUrlOverride() + strings.TrimPrefix(u.Path, "/embed/") + "?" + u.RawQuery
|
||||
}
|
||||
return config.Opts.YouTubeEmbedUrlOverride() + strings.TrimPrefix(u.Path, "/embed/")
|
||||
}
|
||||
case "player.vimeo.com":
|
||||
// See https://help.vimeo.com/hc/en-us/articles/12426260232977-About-Player-parameters
|
||||
if strings.HasPrefix(u.Path, "/video/") {
|
||||
if len(u.RawQuery) > 0 {
|
||||
return link + "&dnt=1"
|
||||
}
|
||||
return link + "?dnt=1"
|
||||
}
|
||||
}
|
||||
|
||||
return link
|
||||
@@ -431,8 +437,7 @@ func sanitizeSrcsetAttr(baseURL, value string) string {
|
||||
imageCandidates := ParseSrcSetAttribute(value)
|
||||
|
||||
for _, imageCandidate := range imageCandidates {
|
||||
absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL)
|
||||
if err == nil {
|
||||
if absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL); err == nil {
|
||||
imageCandidate.ImageURL = absoluteURL
|
||||
}
|
||||
}
|
||||
@@ -457,10 +462,6 @@ func isValidDataAttribute(value string) bool {
|
||||
})
|
||||
}
|
||||
|
||||
func isAnchor(tagName string, attribute html.Attribute) bool {
|
||||
return tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#")
|
||||
}
|
||||
|
||||
func isPositiveInteger(value string) bool {
|
||||
if number, err := strconv.Atoi(value); err == nil {
|
||||
return number > 0
|
||||
@@ -468,16 +469,12 @@ func isPositiveInteger(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func getAttributeValue(name string, attributes []html.Attribute) string {
|
||||
func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
|
||||
for _, attribute := range attributes {
|
||||
if attribute.Key == name {
|
||||
return attribute.Val
|
||||
number, _ := strconv.Atoi(attribute.Val)
|
||||
return number
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
|
||||
number, _ := strconv.Atoi(getAttributeValue(name, attributes))
|
||||
return number
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
@@ -35,6 +38,28 @@ func BenchmarkSanitize(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzSanitizer(f *testing.F) {
|
||||
f.Fuzz(func(t *testing.T, orig string) {
|
||||
tok := html.NewTokenizer(strings.NewReader(orig))
|
||||
i := 0
|
||||
for tok.Next() != html.ErrorToken {
|
||||
i++
|
||||
}
|
||||
|
||||
out := Sanitize("", orig)
|
||||
|
||||
tok = html.NewTokenizer(strings.NewReader(out))
|
||||
j := 0
|
||||
for tok.Next() != html.ErrorToken {
|
||||
j++
|
||||
}
|
||||
|
||||
if j > i {
|
||||
t.Errorf("Got more html tokens in the sanitized html.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidInput(t *testing.T) {
|
||||
input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
@@ -611,9 +636,9 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceIframeURL(t *testing.T) {
|
||||
func TestReplaceIframeVimedoDNTURL(t *testing.T) {
|
||||
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0"></iframe>`
|
||||
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
|
||||
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0&dnt=1" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
|
||||
@@ -17,12 +17,12 @@ type ImageCandidate struct {
|
||||
type ImageCandidates []*ImageCandidate
|
||||
|
||||
func (c ImageCandidates) String() string {
|
||||
var htmlCandidates []string
|
||||
htmlCandidates := make([]string, 0, len(c))
|
||||
|
||||
for _, imageCandidate := range c {
|
||||
var htmlCandidate string
|
||||
if imageCandidate.Descriptor != "" {
|
||||
htmlCandidate = fmt.Sprintf(`%s %s`, imageCandidate.ImageURL, imageCandidate.Descriptor)
|
||||
htmlCandidate = imageCandidate.ImageURL + " " + imageCandidate.Descriptor
|
||||
} else {
|
||||
htmlCandidate = imageCandidate.ImageURL
|
||||
}
|
||||
@@ -36,9 +36,7 @@ func (c ImageCandidates) String() string {
|
||||
// ParseSrcSetAttribute returns the list of image candidates from the set.
|
||||
// https://html.spec.whatwg.org/#parse-a-srcset-attribute
|
||||
func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidates) {
|
||||
unparsedCandidates := strings.Split(attributeValue, ", ")
|
||||
|
||||
for _, unparsedCandidate := range unparsedCandidates {
|
||||
for _, unparsedCandidate := range strings.Split(attributeValue, ", ") {
|
||||
if candidate, err := parseImageCandidate(unparsedCandidate); err == nil {
|
||||
imageCandidates = append(imageCandidates, candidate)
|
||||
}
|
||||
@@ -48,22 +46,20 @@ func ParseSrcSetAttribute(attributeValue string) (imageCandidates ImageCandidate
|
||||
}
|
||||
|
||||
func parseImageCandidate(input string) (*ImageCandidate, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
parts := strings.Split(strings.TrimSpace(input), " ")
|
||||
nbParts := len(parts)
|
||||
|
||||
if nbParts > 2 || nbParts == 0 {
|
||||
return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
|
||||
}
|
||||
|
||||
if nbParts == 2 {
|
||||
switch {
|
||||
case nbParts == 1:
|
||||
return &ImageCandidate{ImageURL: parts[0]}, nil
|
||||
case nbParts == 2:
|
||||
if !isValidWidthOrDensityDescriptor(parts[1]) {
|
||||
return nil, fmt.Errorf(`srcset: invalid descriptor`)
|
||||
}
|
||||
return &ImageCandidate{ImageURL: parts[0], Descriptor: parts[1]}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf(`srcset: invalid number of descriptors`)
|
||||
}
|
||||
|
||||
return &ImageCandidate{ImageURL: parts[0]}, nil
|
||||
}
|
||||
|
||||
func isValidWidthOrDensityDescriptor(value string) bool {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// StripTags removes all HTML/XML tags from the input string.
|
||||
// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
|
||||
func StripTags(input string) string {
|
||||
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
|
||||
var buffer bytes.Buffer
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(input))
|
||||
var buffer strings.Builder
|
||||
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
|
||||
@@ -6,6 +6,7 @@ package scraper // import "miniflux.app/v2/internal/reader/scraper"
|
||||
// List of predefined scraper rules (alphabetically sorted)
|
||||
// domain => CSS selectors
|
||||
var predefinedRules = map[string]string{
|
||||
"arstechnica.com": "div.post-content",
|
||||
"bbc.co.uk": "div.vxp-column--single, div.story-body__inner, ul.gallery-images__list",
|
||||
"blog.cloudflare.com": "div.post-content",
|
||||
"cbc.ca": ".story-content",
|
||||
|
||||
@@ -75,7 +75,7 @@ func findContentUsingCustomRules(page io.Reader, rules string) (baseURL string,
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
|
||||
if hrefValue, exists := document.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeHostRegex = regexp.MustCompile(`youtube\.com$`)
|
||||
youtubeChannelRegex = regexp.MustCompile(`channel/(.*)$`)
|
||||
)
|
||||
|
||||
@@ -147,7 +146,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
|
||||
if hrefValue, exists := doc.Find("head base").First().Attr("href"); exists {
|
||||
if hrefValue, exists := doc.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
websiteURL = hrefValue
|
||||
@@ -229,8 +228,17 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWellKnownURLs(websiteURL strin
|
||||
localizedError := responseHandler.LocalizedError()
|
||||
responseHandler.Close()
|
||||
|
||||
// Do not add redirections to the possible list of subscriptions to avoid confusion.
|
||||
if responseHandler.IsRedirect() {
|
||||
slog.Debug("Ignore URL redirection during feed discovery", slog.String("fullURL", fullURL))
|
||||
continue
|
||||
}
|
||||
|
||||
if localizedError != nil {
|
||||
slog.Debug("Unable to subscribe", slog.String("fullURL", fullURL), slog.Any("error", localizedError.Error()))
|
||||
slog.Debug("Ignore invalid feed URL during feed discovery",
|
||||
slog.String("fullURL", fullURL),
|
||||
slog.Any("error", localizedError.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -284,7 +292,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
if !strings.HasSuffix(decodedUrl.Host, "youtube.com") {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
@@ -303,7 +311,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubePlaylistPage(websiteURL
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
if !strings.HasSuffix(decodedUrl.Host, "youtube.com") {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -112,13 +112,13 @@ func (e *EntryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID
|
||||
WITH entry_pagination AS (
|
||||
SELECT
|
||||
e.id,
|
||||
lag(e.id) over (order by e.%[1]s asc, e.id desc) as prev_id,
|
||||
lead(e.id) over (order by e.%[1]s asc, e.id desc) as next_id
|
||||
lag(e.id) over (order by e.%[1]s asc, e.created_at asc, e.id desc) as prev_id,
|
||||
lead(e.id) over (order by e.%[1]s asc, e.created_at asc, e.id desc) as next_id
|
||||
FROM entries AS e
|
||||
JOIN feeds AS f ON f.id=e.feed_id
|
||||
JOIN categories c ON c.id = f.category_id
|
||||
WHERE %[2]s
|
||||
ORDER BY e.%[1]s asc, e.id desc
|
||||
ORDER BY e.%[1]s asc, e.created_at asc, e.id desc
|
||||
)
|
||||
SELECT prev_id, next_id FROM entry_pagination AS ep WHERE %[3]s;
|
||||
`
|
||||
|
||||
@@ -208,8 +208,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
ntfy_username,
|
||||
ntfy_password,
|
||||
ntfy_icon_url,
|
||||
ntfy_internal_links,
|
||||
cubox_enabled,
|
||||
cubox_api_link
|
||||
cubox_api_link,
|
||||
discord_enabled,
|
||||
discord_webhook_link
|
||||
FROM
|
||||
integrations
|
||||
WHERE
|
||||
@@ -316,8 +319,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
&integration.NtfyUsername,
|
||||
&integration.NtfyPassword,
|
||||
&integration.NtfyIconURL,
|
||||
&integration.NtfyInternalLinks,
|
||||
&integration.CuboxEnabled,
|
||||
&integration.CuboxAPILink,
|
||||
&integration.DiscordEnabled,
|
||||
&integration.DiscordWebhookLink,
|
||||
)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
@@ -433,10 +439,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
ntfy_username=$96,
|
||||
ntfy_password=$97,
|
||||
ntfy_icon_url=$98,
|
||||
cubox_enabled=$99,
|
||||
cubox_api_link=$100
|
||||
ntfy_internal_links=$99,
|
||||
cubox_enabled=$100,
|
||||
cubox_api_link=$101,
|
||||
discord_enabled=$102,
|
||||
discord_webhook_link=$103
|
||||
WHERE
|
||||
user_id=$101
|
||||
user_id=$104
|
||||
`
|
||||
_, err := s.db.Exec(
|
||||
query,
|
||||
@@ -538,8 +547,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
integration.NtfyUsername,
|
||||
integration.NtfyPassword,
|
||||
integration.NtfyIconURL,
|
||||
integration.NtfyInternalLinks,
|
||||
integration.CuboxEnabled,
|
||||
integration.CuboxAPILink,
|
||||
integration.DiscordEnabled,
|
||||
integration.DiscordWebhookLink,
|
||||
integration.UserID,
|
||||
)
|
||||
|
||||
@@ -580,7 +592,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
|
||||
omnivore_enabled='t' OR
|
||||
raindrop_enabled='t' OR
|
||||
betula_enabled='t' OR
|
||||
cubox_enabled='t'
|
||||
cubox_enabled='t' OR
|
||||
discord_enabled='t'
|
||||
)
|
||||
`
|
||||
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// Timezones returns all timezones supported by the database.
|
||||
func (s *Storage) Timezones() (map[string]string, error) {
|
||||
timezones := make(map[string]string)
|
||||
rows, err := s.db.Query(`SELECT name FROM pg_timezone_names() ORDER BY name ASC`)
|
||||
rows, err := s.db.Query(`SELECT name FROM pg_timezone_names ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch timezones: %v`, err)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
custom_js=$14,
|
||||
external_font_hosts=$15,
|
||||
google_id=$16,
|
||||
openid_connect_id=$167,
|
||||
openid_connect_id=$17,
|
||||
display_mode=$18,
|
||||
entry_order=$19,
|
||||
default_reading_speed=$20,
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
<h3>Miniflux</h3>
|
||||
<ul>
|
||||
<li><strong>{{ t "page.about.version" }}</strong> <a href="https://github.com/miniflux/v2/releases/tag/{{ .version }}">{{ .version }}</a></li>
|
||||
{{ if .commit }}
|
||||
<li><strong>Git Commit</strong> <a href="https://github.com/miniflux/v2/commit/{{ .commit }}">{{ .commit }}</a></li>
|
||||
{{ end }}
|
||||
<li><strong>{{ t "page.about.build_date" }}</strong> {{ .build_date }}</li>
|
||||
{{ if .user.IsAdmin }}<li><strong>{{ t "page.about.postgres_version" }}</strong> {{ .postgres_version }}</li>{{ end }}
|
||||
<li><strong>{{t "page.about.go_version" }}</strong> {{ .go_version }}</li>
|
||||
|
||||
@@ -165,56 +165,55 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<article class="entry-content gesture-nav-{{ $.user.GestureNav }}" dir="auto">
|
||||
{{ if (and .entry.Enclosures (not .entry.Feed.NoMediaPlayer)) }}
|
||||
{{ range .entry.Enclosures }}
|
||||
{{ if ne .URL "" }}
|
||||
{{ if hasPrefix .MimeType "audio/" }}
|
||||
<div class="enclosure-audio" >
|
||||
<audio controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ if not .entry.Feed.NoMediaPlayer }}
|
||||
{{ $mediaPlayerEnclosure := .entry.Enclosures.FindMediaPlayerEnclosure }}
|
||||
|
||||
{{ if $mediaPlayerEnclosure }}
|
||||
{{ with $mediaPlayerEnclosure }}
|
||||
{{ if .IsAudio }}
|
||||
<div class="enclosure-audio" >
|
||||
<audio controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{ .ID }}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if .IsVideo }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{ .ID }}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{end}}
|
||||
{{ if .user }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .user }}
|
||||
{{ noescape (proxyFilter .entry.Content) }}
|
||||
{{ else }}
|
||||
{{ else }}
|
||||
{{ noescape .entry.Content }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ if .entry.Enclosures }}
|
||||
<details class="entry-enclosures">
|
||||
@@ -222,45 +221,7 @@
|
||||
{{ range .entry.Enclosures }}
|
||||
{{ if ne .URL "" }}
|
||||
<div class="entry-enclosure">
|
||||
{{ if hasPrefix .MimeType "audio/" }}
|
||||
<div class="enclosure-audio">
|
||||
<audio controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
<video controls preload="metadata"
|
||||
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
|
||||
data-mark-read-on-completion="0.9"
|
||||
{{ end }}
|
||||
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
{{ else }}
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "image/" }}
|
||||
{{ if .IsImage }}
|
||||
<div class="enclosure-image">
|
||||
{{ if (and $.user (mustBeProxyfied "image")) }}
|
||||
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
|
||||
@@ -271,7 +232,7 @@
|
||||
{{ end }}
|
||||
|
||||
<div class="entry-enclosure-download">
|
||||
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
|
||||
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
|
||||
<small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,22 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.DiscordEnabled }}open{{ end }}>
|
||||
<summary>Discord</summary>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="discord_enabled" value="1" {{ if .form.DiscordEnabled }}checked{{ end }}> {{ t "form.integration.discord_activate" }}
|
||||
</label>
|
||||
|
||||
<label for="form-discord-webhook-link">{{ t "form.integration.discord_webhook_link" }}</label>
|
||||
<input type="url" name="discord_webhook_link" id="form-discord-webhook-link" value="{{ .form.DiscordWebhookLink }}" placeholder="https://discord.com/api/webhooks/xxx/xxx" 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.EspialEnabled }}open{{ end }}>
|
||||
<summary>Espial</summary>
|
||||
<div class="form-section">
|
||||
@@ -300,6 +316,10 @@
|
||||
<label for="form-ntfy-icon-url">{{ t "form.integration.ntfy_icon_url" }}</label>
|
||||
<input type="url" name="ntfy_icon_url" id="form-ntfy-icon-url" value="{{ .form.NtfyIconURL }}" spellcheck="false">
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="ntfy_internal_links" value="1" {{ if .form.NtfyInternalLinks }}checked{{ end }}> {{ t "form.integration.ntfy_internal_links" }}
|
||||
</label>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
@@ -561,7 +581,7 @@
|
||||
</label>
|
||||
|
||||
<label for="form-wallabag-url">{{ t "form.integration.wallabag_endpoint" }}</label>
|
||||
<input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/" spellcheck="false">
|
||||
<input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" spellcheck="false">
|
||||
|
||||
<label for="form-wallabag-client-id">{{ t "form.integration.wallabag_client_id" }}</label>
|
||||
<input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}" spellcheck="false">
|
||||
|
||||
@@ -115,14 +115,14 @@
|
||||
|
||||
<label><input type="checkbox" name="show_reading_time" value="1" {{ if .form.ShowReadingTime }}checked{{ end }}> {{ t "form.prefs.label.show_reading_time" }}</label>
|
||||
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .const.NoAutoMarkAsRead }}"
|
||||
{{ if eq .form.MarkReadBehavior .const.NoAutoMarkAsRead }}checked{{end}} > {{ t "form.prefs.label.mark_read_manually" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .const.MarkAsReadOnView }}"
|
||||
{{ if eq .form.MarkReadBehavior .const.MarkAsReadOnView }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_view" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .const.MarkAsReadOnViewButWaitForPlayerCompletion }}"
|
||||
{{ if eq .form.MarkReadBehavior .const.MarkAsReadOnViewButWaitForPlayerCompletion }}checked{{end}}> {{ t "form.prefs.label.mark_read_on_view_or_media_completion" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .const.MarkAsReadOnlyOnPlayerCompletion }}"
|
||||
{{ if eq .form.MarkReadBehavior .const.MarkAsReadOnlyOnPlayerCompletion }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_media_completion" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.NoAutoMarkAsRead }}"
|
||||
{{ if eq .form.MarkReadBehavior .readBehaviors.NoAutoMarkAsRead }}checked{{end}} > {{ t "form.prefs.label.mark_read_manually" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.MarkAsReadOnView }}"
|
||||
{{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnView }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_view" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.MarkAsReadOnViewButWaitForPlayerCompletion }}"
|
||||
{{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnViewButWaitForPlayerCompletion }}checked{{end}}> {{ t "form.prefs.label.mark_read_on_view_or_media_completion" }}</label>
|
||||
<label><input type="radio" name="mark_read_behavior" value="{{ .readBehaviors.MarkAsReadOnlyOnPlayerCompletion }}"
|
||||
{{ if eq .form.MarkReadBehavior .readBehaviors.MarkAsReadOnlyOnPlayerCompletion }}checked{{end}} > {{ t "form.prefs.label.mark_read_on_media_completion" }}</label>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
|
||||
@@ -26,6 +26,7 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStarred(true)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithSorting("id", user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithSorting("id", user.EntryDirection)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithSorting("id", user.EntryDirection)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithCategoryID(category.ID)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithSorting("id", user.EntryDirection)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithStarred(true)
|
||||
builder.WithOffset(offset)
|
||||
|
||||
@@ -38,6 +38,7 @@ func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
|
||||
builder.WithFeedID(feed.ID)
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithSorting("id", user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
|
||||
builder.WithFeedID(feed.ID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithSorting("id", user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
|
||||
@@ -110,8 +110,11 @@ type IntegrationForm struct {
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
NtfyInternalLinks bool
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
DiscordEnabled bool
|
||||
DiscordWebhookLink string
|
||||
}
|
||||
|
||||
// Merge copy form values to the model.
|
||||
@@ -211,8 +214,11 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
|
||||
integration.NtfyUsername = i.NtfyUsername
|
||||
integration.NtfyPassword = i.NtfyPassword
|
||||
integration.NtfyIconURL = i.NtfyIconURL
|
||||
integration.NtfyInternalLinks = i.NtfyInternalLinks
|
||||
integration.CuboxEnabled = i.CuboxEnabled
|
||||
integration.CuboxAPILink = i.CuboxAPILink
|
||||
integration.DiscordEnabled = i.DiscordEnabled
|
||||
integration.DiscordWebhookLink = i.DiscordWebhookLink
|
||||
}
|
||||
|
||||
// NewIntegrationForm returns a new IntegrationForm.
|
||||
@@ -315,8 +321,11 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
|
||||
NtfyUsername: r.FormValue("ntfy_username"),
|
||||
NtfyPassword: r.FormValue("ntfy_password"),
|
||||
NtfyIconURL: r.FormValue("ntfy_icon_url"),
|
||||
NtfyInternalLinks: r.FormValue("ntfy_internal_links") == "1",
|
||||
CuboxEnabled: r.FormValue("cubox_enabled") == "1",
|
||||
CuboxAPILink: r.FormValue("cubox_api_link"),
|
||||
DiscordEnabled: r.FormValue("discord_enabled") == "1",
|
||||
DiscordWebhookLink: r.FormValue("discord_webhook_link"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// MarkReadBehavior list all possible behaviors for automatically marking an entry as read
|
||||
type MarkReadBehavior string
|
||||
|
||||
var (
|
||||
const (
|
||||
NoAutoMarkAsRead MarkReadBehavior = "no-auto"
|
||||
MarkAsReadOnView MarkReadBehavior = "on-view"
|
||||
MarkAsReadOnViewButWaitForPlayerCompletion MarkReadBehavior = "on-view-but-wait-for-player-completion"
|
||||
|
||||
@@ -124,8 +124,11 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
|
||||
NtfyUsername: integration.NtfyUsername,
|
||||
NtfyPassword: integration.NtfyPassword,
|
||||
NtfyIconURL: integration.NtfyIconURL,
|
||||
NtfyInternalLinks: integration.NtfyInternalLinks,
|
||||
CuboxEnabled: integration.CuboxEnabled,
|
||||
CuboxAPILink: integration.CuboxAPILink,
|
||||
DiscordEnabled: integration.DiscordEnabled,
|
||||
DiscordWebhookLink: integration.DiscordWebhookLink,
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
+15
-4
@@ -12,6 +12,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/reader/rewrite"
|
||||
)
|
||||
|
||||
func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -56,23 +58,23 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(string(decodedURL))
|
||||
parsedMediaURL, err := url.Parse(string(decodedURL))
|
||||
if err != nil {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
if parsedMediaURL.Scheme != "http" && parsedMediaURL.Scheme != "https" {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
if parsedMediaURL.Host == "" {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if !u.IsAbs() {
|
||||
if !parsedMediaURL.IsAbs() {
|
||||
html.BadRequest(w, r, errors.New("invalid URL provided"))
|
||||
return
|
||||
}
|
||||
@@ -90,6 +92,10 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
req.Header.Set("Connection", "close")
|
||||
|
||||
if referer := rewrite.GetRefererForURL(mediaURL); referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
|
||||
forwardedRequestHeader := []string{"Range", "Accept", "Accept-Encoding", "User-Agent"}
|
||||
for _, requestHeaderName := range forwardedRequestHeader {
|
||||
if r.Header.Get(requestHeaderName) != "" {
|
||||
@@ -140,6 +146,11 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
|
||||
b.WithStatus(resp.StatusCode)
|
||||
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
b.WithHeader("Content-Type", resp.Header.Get("Content-Type"))
|
||||
|
||||
if filename := path.Base(parsedMediaURL.Path); filename != "" {
|
||||
b.WithHeader("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename))
|
||||
}
|
||||
|
||||
forwardedResponseHeader := []string{"Content-Encoding", "Content-Type", "Content-Length", "Accept-Ranges", "Content-Range"}
|
||||
for _, responseHeaderName := range forwardedResponseHeader {
|
||||
if resp.Header.Get(responseHeaderName) != "" {
|
||||
|
||||
@@ -63,15 +63,14 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("form", settingsForm)
|
||||
// In order to keep the continuity between form and model, I pass adhoc constants to the view
|
||||
view.Set("const", map[string]interface{}{
|
||||
view.Set("readBehaviors", map[string]any{
|
||||
"NoAutoMarkAsRead": form.NoAutoMarkAsRead,
|
||||
"MarkAsReadOnView": form.MarkAsReadOnView,
|
||||
"MarkAsReadOnViewButWaitForPlayerCompletion": form.MarkAsReadOnViewButWaitForPlayerCompletion,
|
||||
"MarkAsReadOnlyOnPlayerCompletion": form.MarkAsReadOnlyOnPlayerCompletion,
|
||||
})
|
||||
view.Set("themes", model.Themes())
|
||||
view.Set("languages", locale.AvailableLanguages())
|
||||
view.Set("languages", locale.AvailableLanguages)
|
||||
view.Set("timezones", timezones)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user