Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 600f19cc87 | |||
| 4a77e937af | |||
| f7e30822c3 | |||
| 1cffb70362 | |||
| 462ba8d7f7 | |||
| 991c54150d | |||
| 6eedf4111f | |||
| af1f966250 | |||
| 7f54b27079 | |||
| a3ce03cc9d | |||
| 502db64b28 | |||
| 033806df4b | |||
| 50bd6eb5d9 | |||
| 3387201634 | |||
| 76acd81fa4 | |||
| 28b235595e | |||
| 9398f7abd7 | |||
| 0f55c81e2b | |||
| f2f60a8f73 | |||
| e777f12490 | |||
| 7bdf133b9d | |||
| 7eb1d15315 | |||
| a74186c555 | |||
| fb2fdf17b7 | |||
| 6edb3da977 | |||
| c0309b6ad1 | |||
| 7bf1dd6e7a | |||
| 6a008eee14 | |||
| b9b2d6822a | |||
| 459284ab96 | |||
| b193bc212a | |||
| 3ebeb38ade | |||
| c951ac2876 | |||
| 7275bc808a | |||
| e40446ad3c | |||
| fba23cf464 | |||
| bae872e79b | |||
| d0c9ef5acf | |||
| 0c74497ef7 | |||
| 369054b02d | |||
| c3c42b0c37 | |||
| 4938a94968 | |||
| ffe1be59ea | |||
| 2318e9011d | |||
| 67df305ac2 | |||
| 2e57e3351b | |||
| a412cde3b3 | |||
| abfd9306a4 | |||
| 8889e44b5d | |||
| 9657bf1f5b | |||
| 8c5f88ac62 | |||
| 736f8b4dac | |||
| 60e1d9e361 | |||
| 7967ce4df2 | |||
| 71c7845c42 | |||
| aa56d23551 | |||
| 3cd448099d | |||
| 47ccefba4e | |||
| 1faccc7eca | |||
| e74d875d95 | |||
| 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 |
@@ -1,7 +1,7 @@
|
||||
Do you follow the guidelines?
|
||||
Have you followed these guidelines?
|
||||
|
||||
- [ ] I have tested my 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
|
||||
- [ ] I have thoroughly tested my changes and verified there are no regressions
|
||||
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I have read this document: https://miniflux.app/faq.html#pull-request
|
||||
|
||||
@@ -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"
|
||||
go-version: "1.24.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
|
||||
@@ -31,7 +37,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -5,11 +5,15 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event.pull_request
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -30,7 +34,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
|
||||
|
||||
@@ -28,8 +28,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- run: "go vet ./..."
|
||||
go-version: "1.24.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
args: >
|
||||
@@ -39,5 +38,23 @@ jobs:
|
||||
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
with:
|
||||
version: "2024.1.1"
|
||||
version: "latest"
|
||||
install-go: false
|
||||
|
||||
commitlint:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
- name: Install commitlint
|
||||
run: |
|
||||
npm install --save-dev @commitlint/config-conventional @commitlint/cli
|
||||
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
|
||||
- name: Validate PR commits
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
|
||||
@@ -5,11 +5,15 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event.pull_request
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -21,7 +25,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:
|
||||
|
||||
@@ -15,16 +15,20 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.23.x"]
|
||||
go-version: ["1.24.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
|
||||
go-version: "1.24.x"
|
||||
- name: Install Postgres client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
|
||||
@@ -1,3 +1,106 @@
|
||||
Version 2.2.6 (February 22, 2025)
|
||||
---------------------------------
|
||||
|
||||
* test(encoding): add unit tests for `CharsetReader` function
|
||||
* refactor(xml): improve the performances of `NewXMLDecoder`
|
||||
* refactor(ui): remove superfluous cast
|
||||
* refactor(request): broaden an error condition when parsing cookies
|
||||
* refactor(processor): remove superfluous parenthesis
|
||||
* refactor(opml): don't define receivers on both values and pointer
|
||||
* refactor(model): simplify a condition
|
||||
* refactor(model): don't define methods both on instance and pointer
|
||||
* refactor(locale): sort JSON documents alphabetically by keys
|
||||
* refactor(locale): remove superfluous parenthesis
|
||||
* refactor(js): use proper types in `app.js`
|
||||
* refactor(js): replace the deprecated `window.pageYOffset` with `window.scollY`
|
||||
* refactor(js): remove a useless `return`
|
||||
* refactor(js): anchor `=` removal in `webauthn_handler.js` regex
|
||||
* refactor(js): add default value for parameter `fallbackSelf` in `goToPage` function
|
||||
* refactor(integration): don't use `defer` in a loop
|
||||
* refactor(icon): guard against a potential `null` dereference
|
||||
* refactor(date): use an else-if instead of two if statements
|
||||
* refactor(css): use shortcuts to declare padding
|
||||
* refactor(client): remove a useless cast
|
||||
* perf(sanitizer): remove two useless calls to `strings.ReplaceAll`
|
||||
* fix(ui): Redirect correctly post feed removal from category feeds list
|
||||
* fix(scraper): update TechCrunch scraper rule
|
||||
* fix(scraper): avoid encoding issue if charset meta tag is after 1024 bytes
|
||||
* fix(sanitizer): non-allowed attributes are not properly stripped
|
||||
* fix(sanitizer): correct HTML tag name from `tfooter` to `tfoot`
|
||||
* fix(rss): handle item title with `CDATA` content correctly
|
||||
* fix(locale): missing hyphen in `de_DE.json`
|
||||
* fix(css): avoid aside overflow on the pagination menu
|
||||
* fix(css): `--entry-content-aside-border-color` is missing from `system.css`
|
||||
* fix(api): return 500 response when JSON serialization fails
|
||||
* fix(api): JSON encoding is failing with dates at OAD and negative timezone offset
|
||||
* feat(urlcleaner): add trackers to the blocklist
|
||||
* feat(ui): open the `<details>` tag in edit feed page when the feature is enabled
|
||||
* feat(sanitizer): improve text truncation with better space handling
|
||||
* feat(sanitizer): allow `img` tags with only a `srcset` and no `src` attribute
|
||||
* feat(rss): add workaround for RSS item title with HTML content
|
||||
* feat(pushover): add integration with pushover.net
|
||||
* feat(processor): fetch YouTube watch time in bulk using the API
|
||||
* feat(locale): update Traditional Chinese translation
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): update French translation
|
||||
* feat(locale): add Taiwanese POJ (nan-Latn-pehoeji)
|
||||
* feat(integration): update Linkace integration to support API v2
|
||||
* feat(integration): add webhook URL per feed
|
||||
* feat(integration): add Slack integration
|
||||
* feat(css): improve aside element position on smartphone
|
||||
* ci: update GitHub Actions workflows to use Go 1.24
|
||||
* ci: trigger packaging tests on pull requests
|
||||
* ci: add `commitlint` to validate PR commit messages
|
||||
* build(deps): bump `golang` in `/packaging/debian`
|
||||
* build(deps): bump `golang.org/x/term` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.34.0` to `0.35.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.32.0` to `0.33.0`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.1` to `1.10.2`
|
||||
|
||||
Version 2.2.5 (January 20, 2025)
|
||||
--------------------------------
|
||||
|
||||
* test(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)
|
||||
---------------------------------
|
||||
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
|
||||
func (r *request) buildClient() http.Client {
|
||||
return http.Client{
|
||||
Timeout: time.Duration(defaultTimeout * time.Second),
|
||||
Timeout: defaultTimeout * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.2
|
||||
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.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.21.0
|
||||
github.com/tdewolff/minify/v2 v2.21.3
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/oauth2 v0.26.0
|
||||
golang.org/x/term v0.29.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
|
||||
@@ -41,7 +40,8 @@ require (
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.19 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.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.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
||||
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,8 +54,8 @@ 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.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
|
||||
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
|
||||
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=
|
||||
@@ -68,50 +68,80 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
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/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
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/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
|
||||
golang.org/x/oauth2 v0.26.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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
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.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+194
-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,43 @@ 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
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN slack_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN slack_webhook_link text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN pushover_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_user text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_device text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_prefix text default '';
|
||||
|
||||
ALTER TABLE feeds ADD COLUMN pushover_enabled bool default 'f';
|
||||
ALTER TABLE feeds ADD COLUMN pushover_priority int default '0';
|
||||
`
|
||||
_, 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"
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import "net/http"
|
||||
// CookieValue returns the cookie value.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err == http.ErrNoCookie {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ type Builder struct {
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
enableCompression bool
|
||||
body interface{}
|
||||
body any
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
@@ -41,7 +41,7 @@ func (b *Builder) WithHeader(key, value string) *Builder {
|
||||
}
|
||||
|
||||
// WithBody uses the given body to build the response.
|
||||
func (b *Builder) WithBody(body interface{}) *Builder {
|
||||
func (b *Builder) WithBody(body any) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -16,19 +16,31 @@ import (
|
||||
const contentTypeHeader = `application/json`
|
||||
|
||||
// OK creates a new JSON response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSON(body))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Created sends a created response to the client.
|
||||
func Created(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func Created(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusCreated)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSON(body))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -62,10 +74,17 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(err))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -84,10 +103,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(err))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -105,10 +131,17 @@ func Unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("access unauthorized")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -126,10 +159,17 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("access forbidden")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -147,27 +187,29 @@ func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("resource not found")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
func toJSONError(err error) []byte {
|
||||
func generateJSONError(err error) ([]byte, error) {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
return toJSON(errorMsg{ErrorMessage: err.Error()})
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
|
||||
if err != nil {
|
||||
slog.Error("Unable to marshal JSON response", slog.Any("error", err))
|
||||
return []byte("")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b
|
||||
return encodedBody, nil
|
||||
}
|
||||
|
||||
@@ -293,12 +293,12 @@ func TestBuildInvalidJSONResponse(t *testing.T) {
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := ``
|
||||
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
|
||||
@@ -69,7 +69,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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"
|
||||
@@ -22,11 +23,13 @@ import (
|
||||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/pushover"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
"miniflux.app/v2/internal/integration/shiori"
|
||||
"miniflux.app/v2/internal/integration/slack"
|
||||
"miniflux.app/v2/internal/integration/telegrambot"
|
||||
"miniflux.app/v2/internal/integration/wallabag"
|
||||
"miniflux.app/v2/internal/integration/webhook"
|
||||
@@ -472,20 +475,27 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if feed.WebhookURL != "" {
|
||||
webhookURL = feed.WebhookURL
|
||||
} else {
|
||||
webhookURL = userIntegrations.WebhookURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
)
|
||||
|
||||
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
|
||||
slog.Debug("Unable to send new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
@@ -505,6 +515,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
userIntegrations.NtfyUsername,
|
||||
userIntegrations.NtfyPassword,
|
||||
userIntegrations.NtfyIconURL,
|
||||
userIntegrations.NtfyInternalLinks,
|
||||
feed.NtfyPriority,
|
||||
)
|
||||
|
||||
@@ -535,6 +546,58 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.SlackEnabled {
|
||||
slog.Debug("Sending new entries to Slack",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := slack.NewClient(
|
||||
userIntegrations.SlackWebhookLink,
|
||||
)
|
||||
|
||||
if err := client.SendSlackMsg(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Slack", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PushoverEnabled && feed.PushoverEnabled {
|
||||
slog.Debug("Sending new entries to Pushover",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := pushover.New(
|
||||
userIntegrations.PushoverUser,
|
||||
userIntegrations.PushoverToken,
|
||||
feed.PushoverPriority,
|
||||
userIntegrations.PushoverDevice,
|
||||
userIntegrations.PushoverPrefix,
|
||||
)
|
||||
|
||||
if err := client.SendMessages(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Pushover", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
|
||||
@@ -35,7 +35,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v2/links")
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", 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 {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pushover // import "miniflux.app/v2/internal/integration/pushover"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultPushoverURL = "https://api.pushover.net"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
prefix string
|
||||
|
||||
token string
|
||||
user string
|
||||
device string
|
||||
|
||||
priority int
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
|
||||
URL string `json:"url"`
|
||||
URLTitle string `json:"url_title"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
Status int `json:"status"`
|
||||
Request string `json:"request"`
|
||||
}
|
||||
|
||||
func New(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
if urlPrefix == "" {
|
||||
urlPrefix = defaultPushoverURL
|
||||
}
|
||||
if priority < -2 {
|
||||
priority = -2
|
||||
}
|
||||
if priority > 2 {
|
||||
priority = 2
|
||||
}
|
||||
|
||||
return &Client{
|
||||
user: user,
|
||||
token: token,
|
||||
device: device,
|
||||
prefix: urlPrefix,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
if c.token == "" || c.user == "" {
|
||||
return fmt.Errorf("pushover token and user are required")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
msg := &Message{
|
||||
User: c.user,
|
||||
Token: c.token,
|
||||
Device: c.device,
|
||||
|
||||
Message: entry.Title,
|
||||
Title: feed.Title,
|
||||
Priority: c.priority,
|
||||
URL: entry.URL,
|
||||
}
|
||||
|
||||
slog.Debug("Sending Pushover message",
|
||||
slog.Int("priority", msg.Priority),
|
||||
slog.String("message", msg.Message),
|
||||
slog.String("entry_url", msg.URL),
|
||||
)
|
||||
|
||||
if err := c.makeRequest(msg); err != nil {
|
||||
return fmt.Errorf("c.makeRequest: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload *Message) error {
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
}
|
||||
url := c.prefix + "/1/messages.json"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("http.NewRequest: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpClient.Do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
errorMessage := resp.Status
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||
if len(errResp.Errors) > 0 {
|
||||
errorMessage = strings.Join(errResp.Errors, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Slack Webhooks documentation: https://api.slack.com/messaging/webhooks
|
||||
|
||||
package slack // import "miniflux.app/v2/internal/integration/slack"
|
||||
|
||||
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 slackMsgColor = "#5865F2"
|
||||
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
}
|
||||
|
||||
func NewClient(webhookURL string) *Client {
|
||||
return &Client{webhookURL: webhookURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
requestBody, err := json.Marshal(&slackMessage{
|
||||
Attachments: []slackAttachments{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: slackMsgColor,
|
||||
Fields: []slackFields{
|
||||
{
|
||||
Title: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article title",
|
||||
Value: entry.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article link",
|
||||
Value: entry.URL,
|
||||
},
|
||||
{
|
||||
Title: "Author",
|
||||
Value: entry.Author,
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Short: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
slog.Debug("Sending Slack 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("slack: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type slackFields struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
type slackAttachments struct {
|
||||
Title string `json:"title"`
|
||||
Color string `json:"color"`
|
||||
Fields []slackFields `json:"fields"`
|
||||
}
|
||||
|
||||
type slackMessage struct {
|
||||
Attachments []slackAttachments `json:"attachments"`
|
||||
}
|
||||
@@ -91,24 +91,25 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
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,
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nan_Latn_pehoeji": 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)
|
||||
|
||||
+19
-18
@@ -5,22 +5,23 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// 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",
|
||||
"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": "繁體中文",
|
||||
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
|
||||
"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",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
|
||||
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
|
||||
var pluralForms = map[string](func(n int) int){
|
||||
var pluralForms = map[string]func(n int) int{
|
||||
// nplurals=2; plural=(n != 1);
|
||||
"default": func(n int) int {
|
||||
if n != 1 {
|
||||
@@ -88,6 +88,9 @@ var pluralForms = map[string](func(n int) int){
|
||||
"zh_TW": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
"nan_Latn_pehoeji": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
|
||||
@@ -77,6 +77,10 @@ func TestPluralRules(t *testing.T) {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
"nan_Latn_pehoeji": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for rule, values := range scenarios {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
{
|
||||
"action.cancel": "Chhú-siau",
|
||||
"action.download": "Lia̍h----loh-lâi",
|
||||
"action.edit": "Pian-chi̍p",
|
||||
"action.home_screen": "Chng tī chú ōe-bīn",
|
||||
"action.import": "Hōe--li̍p",
|
||||
"action.login": "Teng-lo̍k",
|
||||
"action.or": "ah-sī",
|
||||
"action.remove": "Thâi tiāu",
|
||||
"action.remove_feed": "Thâi tiāu chit ê siau-sit lâi-goân",
|
||||
"action.save": "Pó-chûn",
|
||||
"action.subscribe": "Tēng",
|
||||
"action.update": "Ōaⁿ-sin",
|
||||
"alert.account_linked": "Í-keng kah lí ê gōa-pō͘ kháu-chō kiat chòe-hé--ah!",
|
||||
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
|
||||
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
|
||||
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
|
||||
"alert.no_bookmark": "Chit-má ah bô siu-chông",
|
||||
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
|
||||
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
|
||||
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
|
||||
"alert.no_feed_entry": "Chit ê siau-sit lâi-goân lāi bô siau-sit",
|
||||
"alert.no_feed_in_category": "Bô chit ê lūi-pia̍t ê siau-sit lâi-goân",
|
||||
"alert.no_history": "Chit-má ah bô kì-lo̍k",
|
||||
"alert.no_search_result": "Bô hû-ha̍p ê chhiau-chhē kiat-kó",
|
||||
"alert.no_shared_entry": "Chit-má ah bô hun-hióng ê siau-sit",
|
||||
"alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit",
|
||||
"alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit",
|
||||
"alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng",
|
||||
"alert.pocket_linked": "Í-keng kā lí ê Pocket kháu-chō kiat chòe-hé--ah!",
|
||||
"alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi."
|
||||
],
|
||||
"confirm.loading": "Tng leh chip-hêng…",
|
||||
"confirm.no": "Hóⁿ",
|
||||
"confirm.question": "Kám ū khak-tēng?",
|
||||
"confirm.question.refresh": "Kám beh kiông-chè têng lia̍h?",
|
||||
"confirm.yes": "Sī",
|
||||
"enclosure_media_controls.seek": "sóa-ūi:",
|
||||
"enclosure_media_controls.seek.title": "sóa %s bió",
|
||||
"enclosure_media_controls.speed": "Sok-tō͘",
|
||||
"enclosure_media_controls.speed.faster": "Cheng-ka sok-tō͘",
|
||||
"enclosure_media_controls.speed.faster.title": "Cheng-ka sok-tō͘ %sx",
|
||||
"enclosure_media_controls.speed.reset": "Têng siat-tēng",
|
||||
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
|
||||
"enclosure_media_controls.speed.slower": "Pàng bān",
|
||||
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
|
||||
"entry.bookmark.toast.off": "Chhú-siau siu-chông chòe soah",
|
||||
"entry.bookmark.toast.on": "Sin cheng-ka siu-chông chòe soah",
|
||||
"entry.bookmark.toggle.off": "Chhú-siau siu-chông",
|
||||
"entry.bookmark.toggle.on": "Siu-chông khí-lâi",
|
||||
"entry.comments.label": "Hôe-èng",
|
||||
"entry.comments.title": "Khòaⁿ hôe-èng",
|
||||
"entry.estimated_reading_time": [
|
||||
"Ài %d hun-cheng lâi tha̍k"
|
||||
],
|
||||
"entry.external_link.label": "Gōa-pō͘ liân-kiat",
|
||||
"entry.save.completed": "Pó-chûn chò soah",
|
||||
"entry.save.label": "Pó-chûn",
|
||||
"entry.save.title": "Pó-chûn chit ê siau-sit",
|
||||
"entry.save.toast.completed": "Pó-chûn chò soah",
|
||||
"entry.scraper.completed": "Lia̍h soah--ah",
|
||||
"entry.scraper.label": "Lia̍h--lo̍h-lâi",
|
||||
"entry.scraper.title": "Lia̍h goân-tóe lōe-iông",
|
||||
"entry.share.label": "Hun-hióng",
|
||||
"entry.share.title": "Hun-hióng chit ê siau-sit",
|
||||
"entry.shared_entry.label": "Hun-hióng",
|
||||
"entry.shared_entry.title": "Phah khui kong-khai ê liân-kiat",
|
||||
"entry.state.loading": "Tng leh chip-hêng…",
|
||||
"entry.state.saving": "Tng leh pó-chûn…",
|
||||
"entry.status.read": "Chù chòe tha̍k--kè",
|
||||
"entry.status.title": "Kái chōng-thài",
|
||||
"entry.status.toast.read": "Chù chòe tha̍k kè chòe soah",
|
||||
"entry.status.toast.unread": "Chù chòe ah-bōe tha̍k chòe soah",
|
||||
"entry.status.unread": "Chù chòe ah-bōe tha̍k",
|
||||
"entry.tags.label": "Khan-á:",
|
||||
"entry.unshare.label": "Chhú-siau hun-hióng",
|
||||
"error.api_key_already_exists": "Chit ê API só-sî í-keng chûn-chāi",
|
||||
"error.bad_credentials": "M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.",
|
||||
"error.category_already_exists": "Lūi-pia̍t í-keng chûn-chāi.",
|
||||
"error.category_not_found": "Chit ê lūi-pia̍t bô chûn-chāi ah-sī bô sio̍k-tī lí.",
|
||||
"error.database_error": "Chu-liāu khò͘ ū m̄-tiō: %v.",
|
||||
"error.different_passwords": "Su-li̍p ê bi̍t-bé chit nn̄g pái bô kâng.",
|
||||
"error.duplicate_fever_username": "Fever ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
|
||||
"error.duplicate_googlereader_username": "Google Reader ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
|
||||
"error.duplicate_linked_account": "Chit ê beh kiat chòe-hé--ê í-keng seng hō͘ lâng kiat khì--ah!",
|
||||
"error.duplicated_feed": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
|
||||
"error.empty_file": "Chit ê tóng-àn sī khang--ê.",
|
||||
"error.entries_per_page_invalid": "Ta̍k ia̍h ê siau-sit sò͘ ū būn-tôe.",
|
||||
"error.feed_already_exists": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
|
||||
"error.feed_category_not_found": "Bô chit ê lūi-pia̍t ah-sī kóng bô sio̍k-tī chit ê sú-iōng-lâng.",
|
||||
"error.feed_format_not_detected": "Bōe līn chit ê siau-sit lâi-goân ê keh-sek: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "Hong-só kui-chek bô-hāu.",
|
||||
"error.feed_invalid_keeplist_rule": "Pó-liû kui-chek bô-hāu.",
|
||||
"error.feed_mandatory_fields": "Tio̍h-ài su-lip bāng-chí kah lūi-pia̍t.",
|
||||
"error.feed_not_found": "Chhē bô chit ê siau-sit lâi-goân ah-sī bô sio̍k-tī lí",
|
||||
"error.feed_title_not_empty": "Beh tēng ê siau-sit lâi-goân ê piau-tôe bōe-sái sī khang--ê.",
|
||||
"error.feed_url_not_empty": "Beh tēng ê siau-sit lâi-goân bāng-chí bōe-sái sī khang--ê.",
|
||||
"error.fields_mandatory": "Tio̍h-ài kā chu-liāu lóng siá chê.",
|
||||
"error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
|
||||
"error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
|
||||
"error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
|
||||
"error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
|
||||
"error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",
|
||||
"error.http_gateway_timeout": "Tán chit ê bāng-chām ê hôe-èng í-keng chhiau-kè sî-kan, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_internal_server_error": "Chit ê bāng-chām ê su-hāu-khì in ka-kī ū būn-tôe, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_not_authorized": "Bô khoân chûn-chhú chit ê bāng-chām, chhiáⁿ kiám-cha kháu-chō miâ kah bi̍t-bé。",
|
||||
"error.http_resource_not_found": "Chhē bô chit ê liân-kiat, chhiáⁿ khak-līn bāng-chí kám ū chèng-khak.",
|
||||
"error.http_response_too_large": "HTTP hôe-èng siuⁿ tōa. Lí ē-sái tī choân-he̍k siat-tēng lāi kā siōng koân hān-tō͘ kái khah koân (ài têng khui su-hāu-khì)。",
|
||||
"error.http_service_unavailable": "Chit ê bāng-chām in-ūi in ka-kī lāi-pō͘ ū būn-tôe,m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_too_many_requests": "Miniflux tùi chit ê bāng-chām ê chhéng-kiû siuⁿ kè chōe, chhiáⁿ têng chhì-khòaⁿ-māi ah-sī tiâu-chéng thêng-sek siat-tēng.",
|
||||
"error.http_unexpected_status_code": "Chit ê bāng-chām chòe liáu chi̍t ê liāu-bōe-tio̍h ê HTTP chōng-thài bé: %d, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.invalid_default_home_page": "Ū-siat chú-ia̍h ū būn-tôe!",
|
||||
"error.invalid_display_mode": "Ū būn-tôe ê su-li̍p bô͘-sek.",
|
||||
"error.invalid_entry_direction": "Ū būn-tôe ê su-li̍p hong-hiòng.",
|
||||
"error.invalid_feed_url": "Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.",
|
||||
"error.invalid_gesture_nav": "Chhiú-sè tō-lám ū būn-tôe.",
|
||||
"error.invalid_language": "Ū būn-tôe ê gú-giân.",
|
||||
"error.invalid_site_url": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí ū būn-tôe.",
|
||||
"error.invalid_theme": "Ū būn-tôe ê chú-tôe.",
|
||||
"error.invalid_timezone": "Ū būn-tôe ê sî-khu.",
|
||||
"error.network_operation": "Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.",
|
||||
"error.network_timeout": "Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.",
|
||||
"error.password_min_length": "Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.",
|
||||
"error.pocket_access_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h access token",
|
||||
"error.pocket_request_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h request token",
|
||||
"error.settings_block_rule_fieldname_invalid": "Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_block_rule_regex_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_block_rule_separator_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
|
||||
"error.settings_invalid_domain_list": "Bāng-he̍k chheng-toaⁿ ū būn-tôe, chhiáⁿ iōng khang-keh keh khui bô kâng ê bāng-he̍k.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_keep_rule_regex_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_keep_rule_separator_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
|
||||
"error.settings_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ, chú-tôe, gú-giân, sî-khu.",
|
||||
"error.settings_media_playback_rate_range": "Pàng ê sok-tō͘ chhiau-kè hoān-ûi",
|
||||
"error.settings_reading_speed_is_positive": "Tha̍k ê sok-tō͘ tio̍h-ài sī chiaⁿ chéng-sò͘",
|
||||
"error.site_url_not_empty": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí bōe-sái sī khang--ê.",
|
||||
"error.subscription_not_found": "Chhē bōe tio̍h līm-hô tēng ê siau-sit lâi-goân",
|
||||
"error.title_required": "Tio̍h-ài su-li̍p piau-tôe.",
|
||||
"error.tls_error": "TLS m̄-tio̍h: %q。Nā-sī beh pàng-ba̍k TSL chèng-bêng, ē-sái tī siau-sit lâi-goân siat-tēng lāi thêng-tiong.",
|
||||
"error.unable_to_create_api_key": "Bô-hoat-tō͘ sin cheng-ka chit ê API só-sî.",
|
||||
"error.unable_to_create_category": "Bô-hoat-tō͘ sin cheng-ka chit ê lūi-pia̍t",
|
||||
"error.unable_to_create_user": "Bô-hoat-tō͘ sin cheng-ka chit ê sú-iōng-lâng",
|
||||
"error.unable_to_detect_rssbridge": "Sú-iōng RSS-Bridge sî chhē bô līm-hô siau-sit lâi-goân: %v.",
|
||||
"error.unable_to_parse_feed": "Bô-hoat-tō͘ kái-sek chit ê siau-sit lâi-goân: %v.",
|
||||
"error.unable_to_update_category": "Bô-hoat-tō͘ ōaⁿ-sin chit ê lūi-pia̍t",
|
||||
"error.unable_to_update_feed": "Bô-hoat-tō͘ ōaⁿ-sin chit ê siau-sit lâi-goân",
|
||||
"error.unable_to_update_user": "Bô-hoat-tō͘ ōaⁿ-sin chit ê sú-iōng-lâng",
|
||||
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
|
||||
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
|
||||
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
|
||||
"form.api_key.label.description": "API só-sîkhan-á",
|
||||
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
|
||||
"form.category.label.title": "Piau-tôe",
|
||||
"form.feed.fieldset.general": "Thong-iōng",
|
||||
"form.feed.fieldset.integration": "Tē-saⁿ hong ho̍k-bū",
|
||||
"form.feed.fieldset.network_settings": "Bāng-lō͘ siat-tēng",
|
||||
"form.feed.fieldset.rules": "Kui-chek",
|
||||
"form.feed.label.allow_self_signed_certificates": "ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng",
|
||||
"form.feed.label.apprise_service_urls": "Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
|
||||
"form.feed.label.blocklist_rules": "Hong-só kui-chek",
|
||||
"form.feed.label.category": "lūi-pia̍t",
|
||||
"form.feed.label.cookie": "Siat-tēng Cookies",
|
||||
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
|
||||
"form.feed.label.description": "Biâu-su̍t",
|
||||
"form.feed.label.disable_http2": "Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong",
|
||||
"form.feed.label.disabled": "Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit",
|
||||
"form.feed.label.feed_password": "Siau-sit lâi-goân bi̍t-bé",
|
||||
"form.feed.label.feed_url": "Siau-sit lâi-goân bāng-chí",
|
||||
"form.feed.label.feed_username": "Siau-sit lâi-goân kháu-chō miâ",
|
||||
"form.feed.label.fetch_via_proxy": "Thàu-kè tāi-lí lia̍h",
|
||||
"form.feed.label.hide_globally": "Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit",
|
||||
"form.feed.label.ignore_http_cache": "Pàng-ba̍k HTTP cache",
|
||||
"form.feed.label.keeplist_rules": "Pó-liû kui-chek",
|
||||
"form.feed.label.no_media_player": "Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)",
|
||||
"form.feed.label.ntfy_activate": "Thui-sàng siau-sit khì ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy ū-siat iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy koân iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy kē iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy siōng koân iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy siōng kē iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū",
|
||||
"form.feed.label.pushover_activate": "Push entries to Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Default priority",
|
||||
"form.feed.label.pushover_high_priority": "High priority",
|
||||
"form.feed.label.pushover_low_priority": "Low priority",
|
||||
"form.feed.label.pushover_max_priority": "Max priority",
|
||||
"form.feed.label.pushover_min_priority": "Minimal priority",
|
||||
"form.feed.label.pushover_priority": "Pushover message priority",
|
||||
"form.feed.label.rewrite_rules": "Têng siá kui-chek",
|
||||
"form.feed.label.scraper_rules": "Lia̍h ê kui-chek",
|
||||
"form.feed.label.site_url": "Bāng-chām bāng-chí",
|
||||
"form.feed.label.title": "Piau-tôe",
|
||||
"form.feed.label.urlrewrite_rules": "Bāng-chí têng siá kui-chek",
|
||||
"form.feed.label.user_agent": "Ngī kái sú-iōng-lâng tāi-lí",
|
||||
"form.feed.label.webhook_url": "Ngī kái webhook bāng-chí",
|
||||
"form.import.label.file": "OPML tóng-àn",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Thui sàng siau-sit khì Apprise",
|
||||
"form.integration.apprise_services_url": "Iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
|
||||
"form.integration.apprise_url": "Apprise API bāng-chí",
|
||||
"form.integration.betula_activate": "Pó-chûn siau-sit kàu Betula",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.betula_url": "Betula su-hāu-khì bāng-chí",
|
||||
"form.integration.cubox_activate": "Pó-chûn siau-sit khì Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API liân-kiat",
|
||||
"form.integration.discord_activate": "Thui-sàng siau-sit kàu Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook liân-kiat",
|
||||
"form.integration.espial_activate": "Pó-chûn siau-sit kàu Espial",
|
||||
"form.integration.espial_api_key": "Espial API só-sî",
|
||||
"form.integration.espial_endpoint": "Espial API thâu",
|
||||
"form.integration.espial_tags": "Espial khan-á",
|
||||
"form.integration.fever_activate": "Khai-sí iōng Fever API",
|
||||
"form.integration.fever_endpoint": "Fever API thâu",
|
||||
"form.integration.fever_password": "Fever bi̍t-bé",
|
||||
"form.integration.fever_username": "Fever kháu-chō miâ",
|
||||
"form.integration.googlereader_activate": "Khai-sí iōng Google Reader API",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API thâu:",
|
||||
"form.integration.googlereader_password": "Google Reader bi̍t-bé",
|
||||
"form.integration.googlereader_username": "Google Reader Kháu-chō miâ",
|
||||
"form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
|
||||
"form.integration.instapaper_password": "Instapaper bi̍t-bé",
|
||||
"form.integration.instapaper_username": "Instapaper Kháu-chō miâ",
|
||||
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API só-sî",
|
||||
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
|
||||
"form.integration.linkace_endpoint": "LinkAce API thâu",
|
||||
"form.integration.linkace_is_private": "Chù chòe su-lîn ê liân-kiat",
|
||||
"form.integration.linkace_tags": "LinkAce khan-á",
|
||||
"form.integration.linkding_activate": "Pó-chûn siau-sit kàu Linkding",
|
||||
"form.integration.linkding_api_key": "Linkding API só-sî",
|
||||
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.linkding_endpoint": "Linkding API thâu",
|
||||
"form.integration.linkding_tags": "Linkding khan-á",
|
||||
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API thâu",
|
||||
"form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
|
||||
"form.integration.matrix_bot_password": "Matrix bi̍t-bé",
|
||||
"form.integration.matrix_bot_url": "Matrix su-hāu-khìbāng-chí",
|
||||
"form.integration.matrix_bot_user": "Matrix kháu-chō miâ",
|
||||
"form.integration.notion_activate": "Pó-chûn siau-sit kàu Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.ntfy_activate": "Thui-sàng siau-sit kàu Ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API só-sî (soán thiⁿ)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon bāng-chí (soán thiⁿ)",
|
||||
"form.integration.ntfy_internal_links": "Tiám ê sî-chūn iōng lāi-pō͘ liân-kiat (soán thiⁿ)",
|
||||
"form.integration.ntfy_password": "Ntfy bi̍t-bé (soán thiⁿ)",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy bāng-chí (soán thiⁿ, ū-siat sī ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy kháu-chō miâ (soán thiⁿ)",
|
||||
"form.integration.nunux_keeper_activate": "Pó-chûn siau-sit kàu Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API só-sî",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API thâu",
|
||||
"form.integration.omnivore_activate": "Pó-chûn siau-sit kàu Omnivore",
|
||||
"form.integration.omnivore_api_key": "Omnivore API só-sî",
|
||||
"form.integration.omnivore_url": "Omnivore API thâu",
|
||||
"form.integration.pinboard_activate": "Pó-chûn siau-sit kàu Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.pinboard_tags": "Pinboard khan-á",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pocket_access_token": "Pocket token",
|
||||
"form.integration.pocket_activate": "Pó-chûn siau-sit kàu Pocket",
|
||||
"form.integration.pocket_connect_link": "Kah Pocket kháu-chō kiat chòe-hé",
|
||||
"form.integration.pocket_consumer_key": "Pocket sú-iōng-lâng só-sî",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
"form.integration.pushover_token": "Pushover application API token",
|
||||
"form.integration.pushover_user": "Pushover user key",
|
||||
"form.integration.raindrop_activate": "Pó-chûn siau-sit kàu Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "khan-á (iōng tō͘-tiám keh khui)",
|
||||
"form.integration.raindrop_token": "Raindrop Acess Token",
|
||||
"form.integration.readeck_activate": "Pó-chûn siau-sit kàu Readeck",
|
||||
"form.integration.readeck_api_key": "Readeck API só-sî",
|
||||
"form.integration.readeck_endpoint": "Readeck API thâu",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
|
||||
"form.integration.readwise_activate": "Pó-chûn siau-sit kàu Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Acess Token",
|
||||
"form.integration.readwise_api_key_link": "Chhú-tek lí ê Readwise Acess Token",
|
||||
"form.integration.rssbridge_activate": "Sin cheng-ka siau-sit lâi-goân ê sî tio̍h RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge su-hāu-khì的bāng-chí",
|
||||
"form.integration.shaarli_activate": "Pó-chûn siau-sit kàu Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API só-sî",
|
||||
"form.integration.shaarli_endpoint": "Shaarli bāng-chí",
|
||||
"form.integration.shiori_activate": "Pó-chûn siau-sit kàu Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API thâu",
|
||||
"form.integration.shiori_password": "Shiori bi̍t-bé",
|
||||
"form.integration.shiori_username": "Shiori kháu-chō miâ",
|
||||
"form.integration.slack_activate": "Thui-sàng siau-sit kàu Slack",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook liân-kiat",
|
||||
"form.integration.telegram_bot_activate": "Thui-sàng siau-sit kàu Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "Mài hián-sī khai-koan",
|
||||
"form.integration.telegram_bot_disable_notification": "Têng iōng thong-ti",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Thêng iōng bāng-ia̍h ū-lám",
|
||||
"form.integration.telegram_bot_token": "Bot Token",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.wallabag_activate": "Pó-chûn siau-sit kàu Wallabag",
|
||||
"form.integration.wallabag_client_id": "Wallabag kheh-hō͘ thâu ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag kheh-hō͘ thâu só-sî",
|
||||
"form.integration.wallabag_endpoint": "Wallabag Base URL",
|
||||
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
|
||||
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
|
||||
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
|
||||
"form.integration.webhook_activate": "Khai-sí iōng Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook bāng-chí",
|
||||
"form.prefs.fieldset.application_settings": "Èng-iōng thêng-sek siat-tēng",
|
||||
"form.prefs.fieldset.authentication_settings": "Sú-iōng-lâng giām-chèng siat-tēng",
|
||||
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
|
||||
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
|
||||
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
"form.prefs.label.categories_sorting_order": "Lūi-pia̍t hián-sī sūn-sū",
|
||||
"form.prefs.label.cjk_reading_speed": "Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)",
|
||||
"form.prefs.label.custom_css": "Chū tēng ê CSS",
|
||||
"form.prefs.label.custom_js": "Chū tēng ê JavaScript",
|
||||
"form.prefs.label.default_home_page": "Ū-siat chú-ia̍h",
|
||||
"form.prefs.label.default_reading_speed": "Kî-thaⁿ gú-giân tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī)",
|
||||
"form.prefs.label.display_mode": "Chiām-chìn sek bāng-lō͘ èng-iōng theng-sek (PWA) ê hián-sī bô͘-sek",
|
||||
"form.prefs.label.entries_per_page": "Ta̍k ia̍h siau-sit sò͘",
|
||||
"form.prefs.label.entry_order": "Siau-sit hián-sī sūn-sū ê i-kù",
|
||||
"form.prefs.label.entry_sorting": "Siau-sit sūn-sū",
|
||||
"form.prefs.label.entry_swipe": "Ē-sái tī chhiok-khòng sek êng-bō͘ ùi siau-sit iōng thoa tāng chhau-chok",
|
||||
"form.prefs.label.external_font_hosts": "Gōa-pō͘ lī-hêng lâi-goân",
|
||||
"form.prefs.label.gesture_nav": "Tī siau-sit kan sóa-ūi ê chhiú-sè",
|
||||
"form.prefs.label.keyboard_shortcuts": "Ē-sái iōng khí-pôaⁿ khoài-sok khí",
|
||||
"form.prefs.label.language": "Gú-giân",
|
||||
"form.prefs.label.mark_read_manually": "Ka-kī chhau-chok kám beh chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Kan-na tī im-sìn, sī-sìn hòng-sàng kàu 90%% ê si-chun chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
|
||||
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
|
||||
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
|
||||
"form.prefs.label.theme": "Chú-tôe",
|
||||
"form.prefs.label.timezone": "Sî-khu",
|
||||
"form.prefs.select.alphabetical": "Chiàu lī-bú pâi",
|
||||
"form.prefs.select.browser": "Tī iû-lám khì phah khui",
|
||||
"form.prefs.select.created_time": "Siau-sit kiàn-li̍p sî-kan",
|
||||
"form.prefs.select.fullscreen": "Choân êng-bō͘",
|
||||
"form.prefs.select.minimal_ui": "Siōng iông-chhun--ê",
|
||||
"form.prefs.select.none": "Bô",
|
||||
"form.prefs.select.older_first": "Ùi kū--ê khai-sí pâi",
|
||||
"form.prefs.select.publish_time": "Siau-sit hoat-pò͘ sî-kan",
|
||||
"form.prefs.select.recent_first": "Ùi sin--ê khai-sí pâi",
|
||||
"form.prefs.select.standalone": "To̍k-li̍p--ê",
|
||||
"form.prefs.select.swipe": "Iōng thoa--ê",
|
||||
"form.prefs.select.tap": "Tiám nn̄g pái",
|
||||
"form.prefs.select.unread_count": "Ah-bōe tha̍k ê sò͘-liōng",
|
||||
"form.submit.loading": "Tng leh chip-hêng…",
|
||||
"form.submit.saving": "Tng leh pó-chûn…",
|
||||
"form.user.label.admin": "Koán-lí-lâng",
|
||||
"form.user.label.confirmation": "Koh su-li̍p chi̍t pái bi̍t-bé",
|
||||
"form.user.label.password": "Bi̍t-bé",
|
||||
"form.user.label.username": "Kháu-chō miâ",
|
||||
"menu.about": "Iú-koan",
|
||||
"menu.add_feed": "Sin cheng-ka siau-sit lâi-goân",
|
||||
"menu.add_user": "Sin cheng-ka sú-iōng-lâng",
|
||||
"menu.api_keys": "API só-sî",
|
||||
"menu.categories": "Lūi-pia̍t",
|
||||
"menu.create_api_key": "Sin cheng-ka chi̍t ê API só-sî",
|
||||
"menu.create_category": "Sin cheng-ka lūi-pia̍t",
|
||||
"menu.edit_category": "Pian-chi̍p",
|
||||
"menu.edit_feed": "Pian-chi̍p",
|
||||
"menu.export": "Hōe--chhut",
|
||||
"menu.feed_entries": "Bûn-chiong",
|
||||
"menu.feeds": "Siau-sit lâi-goân",
|
||||
"menu.flush_history": "Hìⁿ-sak kì-lo̍k",
|
||||
"menu.history": "Kì-lo̍k",
|
||||
"menu.home_page": "Siú ia̍h",
|
||||
"menu.import": "Hōe--li̍p",
|
||||
"menu.integrations": "Chéng-ha̍p",
|
||||
"menu.logout": "Teng-chhut",
|
||||
"menu.mark_all_as_read": "Choân-pō͘ chù chòe tha̍k kè",
|
||||
"menu.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k kè",
|
||||
"menu.preferences": "Siat-tēng",
|
||||
"menu.refresh_all_feeds": "Tī pōe-āu têng lia̍h só͘-ū ê siau-sit lâi-goân",
|
||||
"menu.refresh_feed": "Têng lia̍h",
|
||||
"menu.search": "Chhiau-chhē",
|
||||
"menu.sessions": "Ū teng-lo̍k--ê",
|
||||
"menu.settings": "Siat-tēng",
|
||||
"menu.shared_entries": "Hun-hióng kè ê siau-sit",
|
||||
"menu.show_all_entries": "Hián-sī só͘-ū ê siau-sit",
|
||||
"menu.show_only_starred_entries": "Kan-na hián-sī siu-chông ê siau-sit",
|
||||
"menu.show_only_unread_entries": "Kan-na hián-sī ah-bōe tha̍k kè ê siau-sit",
|
||||
"menu.starred": "Siu-chông",
|
||||
"menu.title": "Tō-lám",
|
||||
"menu.unread": "Ah-bōe tha̍k",
|
||||
"menu.users": "Sú-iōng-lâng",
|
||||
"page.about.author": "Chok-chiá: ",
|
||||
"page.about.build_date": "Kiàn-tì li̍t-kî:",
|
||||
"page.about.credits": "Pán-koân",
|
||||
"page.about.global_config_options": "Choân-he̍k siat-tēng soán-hāng",
|
||||
"page.about.go_version": "Go pán-pún:",
|
||||
"page.about.license": "Pàng-koân:",
|
||||
"page.about.postgres_version": "Postgres pán-pún:",
|
||||
"page.about.title": "Iú-koan",
|
||||
"page.about.version": "Pán-pún:",
|
||||
"page.add_feed.choose_feed": "Soán-te̍k chi̍t ê Siau-sit lâi-goân",
|
||||
"page.add_feed.label.url": "Bāng-chí",
|
||||
"page.add_feed.legend.advanced_options": "Chìn-kai soán-hāng",
|
||||
"page.add_feed.no_category": "Ah bô lūi-pia̍t, chì-chió ài ū chi̍t ê",
|
||||
"page.add_feed.submit": "Chhē Siau-sit lâi-goân",
|
||||
"page.add_feed.title": "Sin cheng-ka Siau-sit lâi-goân",
|
||||
"page.api_keys.never_used": "Bô iōng kè",
|
||||
"page.api_keys.table.actions": "Chhau-chok",
|
||||
"page.api_keys.table.created_at": "Kiàn-tì li̍t-kî",
|
||||
"page.api_keys.table.description": "Biâu-su̍t",
|
||||
"page.api_keys.table.last_used_at": "Siōng-bóe pái sú-iōng",
|
||||
"page.api_keys.table.token": "Só-sî",
|
||||
"page.api_keys.title": "API só-sî",
|
||||
"page.categories_count": [
|
||||
"%d ê lūi-pia̍t"
|
||||
],
|
||||
"page.categories.entries": "Siau-sit",
|
||||
"page.categories.feed_count": [
|
||||
"Ū %d ê Siau-sit lâi-goân"
|
||||
],
|
||||
"page.categories.feeds": "Siau-sit lâi-goân",
|
||||
"page.categories.no_feed": "Ah-bô siau-sit lâi-goân",
|
||||
"page.categories.title": "Lūi-pia̍t",
|
||||
"page.category_label": "Lūi-pia̍t: %s",
|
||||
"page.edit_category.title": "Pian-chi̍p lūi-pia̍t: %s",
|
||||
"page.edit_feed.etag_header": "ETag piau-thâu:",
|
||||
"page.edit_feed.last_check": "Siōng-bóe pái kiám-cha sî-kan",
|
||||
"page.edit_feed.last_modified_header": "Siōng-bóe pái siu-kái piau-thâu:",
|
||||
"page.edit_feed.last_parsing_error": "Siōng-bóe pái kái-sek m̄-tio̍h",
|
||||
"page.edit_feed.no_header": "Bô",
|
||||
"page.edit_feed.title": "Pian-chi̍p Siau-sit lâi-goân: %s",
|
||||
"page.edit_user.title": "pian-chi̍p sú-iōng-lâng: %s",
|
||||
"page.entry.attachments": "Hù-kiāⁿ",
|
||||
"page.feeds.error_count": [
|
||||
"%d ê m̄-tio̍h"
|
||||
],
|
||||
"page.feeds.last_check": "Siōng-bóe kiám-cha sî-kan:",
|
||||
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
|
||||
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
|
||||
"page.feeds.title": "Siau-sit lâi-goân",
|
||||
"page.history.title": "Kì-lo̍k",
|
||||
"page.import.title": "Hōe-li̍p",
|
||||
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
|
||||
"page.integration.bookmarklet.help": "Lí ē-sái iōng chit ê te̍k-pia̍t ê chheh-chhiam ti̍t-chiap tēng bāng-ia̍h ê siau-sit",
|
||||
"page.integration.bookmarklet.instructions": "Kā chit ê liân-kiat thoa khì iû-lám khì ê chheh-chhiam lân",
|
||||
"page.integration.bookmarklet.name": "Siu-chông Miniflux",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API thâu",
|
||||
"page.integration.miniflux_api_password": "Bi̍t-bé",
|
||||
"page.integration.miniflux_api_password_value": "Lí ê kháu-chō ê bi̍t-bé",
|
||||
"page.integration.miniflux_api_username": "Kháu-chō miâ",
|
||||
"page.integrations.title": "Chéng-ha̍p",
|
||||
"page.keyboard_shortcuts.close_modal": "Kìm tiāu tùi-ōe thang",
|
||||
"page.keyboard_shortcuts.download_content": "Liah goân-tóe ê siau-sit lōe-iông",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Sóa khì thōng ē-kha ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Phah khui lūi-pia̍t ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Khì siau-sit lâi-goân",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Phah khui siau-sit lâi-goân ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_history": "Phah khui kì-lo̍k ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Āu-chi̍t ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Āu-chi̍t ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Téng-chi̍t ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Téng-chi̍t ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_search": "Phah khui chhiau-chhē ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Phah khui siat-tēng ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Phah khui siu-chông--ê ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Sóa khì thōng téng-koân ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Phah khui ah-bōe tha̍k--ê ia̍h",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k--kè",
|
||||
"page.keyboard_shortcuts.open_comments": "Phah khui hôe-èng liân-kiat",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Tī chit-má ê hun-ia̍h phah khui hôe-èng liân-kiat",
|
||||
"page.keyboard_shortcuts.open_item": "Phah khui soán-te̍k ê siau-sit",
|
||||
"page.keyboard_shortcuts.open_original": "Phah khui siau-sit goân-tóe ê liân-kiat",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Tī chit-má ê hun-ia̍h phah khui siau-sit goân-tóe ê liân-kiat",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Tī pōe-āu ōaⁿ-sin siau-sit lâi-goân",
|
||||
"page.keyboard_shortcuts.remove_feed": "Thâi tiāu siau-sit lâi-goân",
|
||||
"page.keyboard_shortcuts.save_article": "Pó-chûn siau-sit",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Sóa khì bāng-ia̍h siōng téng-koân",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Hián-sī khoài-sok khí",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Chhau-chok",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Bûn-chiong tō-lám",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
|
||||
"page.keyboard_shortcuts.title": "Khoài-sok khí",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Chhet-li̍p siu-chông chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
|
||||
"page.login.google_signin": "Sú-iōng Google teng-lo̍k",
|
||||
"page.login.oidc_signin": "Sú-iōng %s teng-lo̍k",
|
||||
"page.login.title": "teng-lo̍k",
|
||||
"page.login.webauthn_login": "Sú-iōng bi̍t-bé teng-lo̍k",
|
||||
"page.login.webauthn_login.error": "Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k",
|
||||
"page.login.webauthn_login.help": "Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.",
|
||||
"page.new_api_key.title": "Sin ê API só-sî",
|
||||
"page.new_category.title": "Sin lūi-pia̍t",
|
||||
"page.new_user.title": "Sin sú-iōng-lâng",
|
||||
"page.offline.message": "Lí í-keng lî-sòaⁿ",
|
||||
"page.offline.refresh_page": "Chhì-khòaⁿ-māi têng tha̍k bāng-ia̍h",
|
||||
"page.offline.title": "Lî-sòaⁿ bô͘-sek",
|
||||
"page.read_entry_count": [
|
||||
"%d ê tha̍k kè ê siau-sit"
|
||||
],
|
||||
"page.search.title": "Chhiau-chhē kiat-kó",
|
||||
"page.sessions.table.actions": "Chhau-chok",
|
||||
"page.sessions.table.current_session": "Chit-má teng-lo̍k--ê",
|
||||
"page.sessions.table.date": "Li̍t-kî",
|
||||
"page.sessions.table.ip": "IP tōe-chí",
|
||||
"page.sessions.table.user_agent": "Sú-iōng-lâng tāi-lí",
|
||||
"page.sessions.title": "Ū teng-lo̍k--ê",
|
||||
"page.settings.link_google_account": "Kah góa ê Google kháu-chō kiat chòe-hé",
|
||||
"page.settings.link_oidc_account": "Kah góa ê %s kháu-chō kiat chòe-hé",
|
||||
"page.settings.title": "Siat-tēng",
|
||||
"page.settings.unlink_google_account": "Phah khui kah góa ê Google kháu-chō ê kiat",
|
||||
"page.settings.unlink_oidc_account": "Phah khui kah góa ê %s kháu-chō ê kiat",
|
||||
"page.settings.webauthn.actions": "Chhau-chok",
|
||||
"page.settings.webauthn.added_on": "Sin cheng-ka ê sî-kan",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Thâi tiāu %d ê Passkey"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Siōng-bóe pái sú-iōng sî-kan",
|
||||
"page.settings.webauthn.passkey_name": "Passkey miâ",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.register": "Chù-chheh Passkey",
|
||||
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
|
||||
"page.shared_entries_count": [
|
||||
"Í-keng hun-hióng %d ê siau-sit"
|
||||
],
|
||||
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
|
||||
"page.starred_entry_count": [
|
||||
"%d ê siu-chông ê siau-sit"
|
||||
],
|
||||
"page.starred.title": "Siu-chông",
|
||||
"page.total_entry_count": [
|
||||
"Lóng-chóng %d ê siau-sit"
|
||||
],
|
||||
"page.unread_entry_count": [
|
||||
"%d ê siau-sit ah-bōe tha̍k"
|
||||
],
|
||||
"page.unread.title": "Ah-bōe tha̍k",
|
||||
"page.users.actions": "chhau-chok",
|
||||
"page.users.admin.no": "Hóⁿ",
|
||||
"page.users.admin.yes": "Sī",
|
||||
"page.users.is_admin": "Koán-lí-lâng",
|
||||
"page.users.last_login": "Siōng-bóe pái teng-lo̍k",
|
||||
"page.users.never_logged": "Chū-lâi bô teng-lo̍k kè",
|
||||
"page.users.title": "Sú-iōng-lâng",
|
||||
"page.users.username": "Sú-iōng-lâng miâ",
|
||||
"page.webauthn_rename.title": "Tiông-sin hō͘ miâ Passkey",
|
||||
"pagination.first": "Thâu-chi̍t ia̍h",
|
||||
"pagination.last": "Siōng-bóe ia̍h",
|
||||
"pagination.next": "Āu-chi̍t ia̍h",
|
||||
"pagination.previous": "Téng-chi̍t ia̍h",
|
||||
"search.label": "Chhiau-chhē",
|
||||
"search.placeholder": "Chhiau-chhē...",
|
||||
"search.submit": "Chhiau-chhē",
|
||||
"skip_to_content": "Sóa kah chú-iàu ê lōe-iông",
|
||||
"time_elapsed.days": [
|
||||
"%d kang chêng"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d tiám-cheng chêng"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"%d hun-cheng chêng"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d kò ge̍h chêng"
|
||||
],
|
||||
"time_elapsed.not_yet": "ah-bōe",
|
||||
"time_elapsed.now": "tú-chiah",
|
||||
"time_elapsed.weeks": [
|
||||
"%d lé-pài chêng"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d nî chêng"
|
||||
],
|
||||
"time_elapsed.yesterday": "cha-hng",
|
||||
"tooltip.keyboard_shortcuts": "Khoài-sok khí:%s",
|
||||
"tooltip.logged_user": "Chit-má teng-lo̍k--ê: %s"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ type SessionData struct {
|
||||
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
|
||||
}
|
||||
|
||||
func (s SessionData) String() string {
|
||||
func (s *SessionData) String() string {
|
||||
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, PocketTkn=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
|
||||
s.CSRF,
|
||||
s.OAuth2State,
|
||||
@@ -40,7 +40,7 @@ func (s SessionData) String() string {
|
||||
}
|
||||
|
||||
// Value converts the session data to JSON.
|
||||
func (s SessionData) Value() (driver.Value, error) {
|
||||
func (s *SessionData) Value() (driver.Value, error) {
|
||||
j, err := json.Marshal(s)
|
||||
return j, err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type EnclosureUpdateRequest struct {
|
||||
}
|
||||
|
||||
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
|
||||
func (e Enclosure) Html5MimeType() string {
|
||||
func (e *Enclosure) Html5MimeType() string {
|
||||
if e.MimeType == "video/m4v" {
|
||||
return "video/x-m4v"
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" {
|
||||
if proxyOption != "none" {
|
||||
for i := range el {
|
||||
if urllib.IsHTTPS(el[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
|
||||
@@ -53,8 +53,11 @@ type Feed struct {
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
PushoverEnabled bool `json:"pushover_enabled,omitempty"`
|
||||
PushoverPriority int `json:"pushover_priority,omitempty"`
|
||||
|
||||
// Non-persisted attributes
|
||||
Category *Category `json:"category,omitempty"`
|
||||
|
||||
@@ -104,6 +104,16 @@ type Integration struct {
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
NtfyInternalLinks bool
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
DiscordEnabled bool
|
||||
DiscordWebhookLink string
|
||||
SlackEnabled bool
|
||||
SlackWebhookLink string
|
||||
PushoverEnabled bool
|
||||
PushoverUser string
|
||||
PushoverToken string
|
||||
PushoverDevice string
|
||||
PushoverPrefix 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,9 +354,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
|
||||
// Workaround for dates that don't use GMT.
|
||||
if strings.HasSuffix(ds, "PST") || strings.HasSuffix(ds, "PDT") {
|
||||
loc, _ = time.LoadLocation("America/Los_Angeles")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
|
||||
} else if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
|
||||
loc, _ = time.LoadLocation("America/New_York")
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package encoding // import "miniflux.app/v2/internal/reader/encoding"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -23,7 +24,11 @@ import (
|
||||
// - Feeds with encoding specified only in XML document and not in HTTP header
|
||||
// - Feeds with wrong encoding defined and already in UTF-8
|
||||
func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
|
||||
buffer, _ := io.ReadAll(input)
|
||||
buffer, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`encoding: unable to read input: %w`, err)
|
||||
}
|
||||
|
||||
r := bytes.NewReader(buffer)
|
||||
|
||||
// The document is already UTF-8, do not do anything (avoid double-encoding).
|
||||
@@ -35,3 +40,24 @@ func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
|
||||
// Transform document to UTF-8 from the specified encoding in XML prolog.
|
||||
return charset.NewReaderLabel(charsetLabel, r)
|
||||
}
|
||||
|
||||
// NewCharsetReader returns an io.Reader that converts the content of r to UTF-8.
|
||||
func NewCharsetReader(r io.Reader, contentType string) (io.Reader, error) {
|
||||
buffer, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`encoding: unable to read input: %w`, err)
|
||||
}
|
||||
|
||||
internalReader := bytes.NewReader(buffer)
|
||||
|
||||
// The document is already UTF-8, do not do anything.
|
||||
if utf8.Valid(buffer) {
|
||||
return internalReader, nil
|
||||
}
|
||||
|
||||
// Transform document to UTF-8 from the specified encoding in Content-Type header.
|
||||
// Note that only the first 1024 bytes are used to detect the encoding.
|
||||
// If the <meta charset> tag is not found in the first 1024 bytes, charset.DetermineEncoding returns "windows-1252" resulting in encoding issues.
|
||||
// See https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding
|
||||
return charset.NewReader(internalReader, contentType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package encoding // import "miniflux.app/v2/internal/reader/encoding"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestCharsetReaderWithUTF8(t *testing.T) {
|
||||
file := "testdata/utf8.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("UTF-8", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithISO88591(t *testing.T) {
|
||||
file := "testdata/iso-8859-1.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("ISO-8859-1", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithWindows1252(t *testing.T) {
|
||||
file := "testdata/windows-1252.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("windows-1252", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Euro €"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithInvalidProlog(t *testing.T) {
|
||||
file := "testdata/invalid-prolog.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("invalid", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithUTF8DocumentWithIncorrectProlog(t *testing.T) {
|
||||
file := "testdata/utf8-incorrect-prolog.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("ISO-8859-1", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithWindows1252DocumentWithIncorrectProlog(t *testing.T) {
|
||||
file := "testdata/windows-1252-incorrect-prolog.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("windows-1252", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Euro €"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithUTF8Document(t *testing.T) {
|
||||
file := "testdata/utf8.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html; charset=UTF-8")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithUTF8DocumentAndNoContentEncoding(t *testing.T) {
|
||||
file := "testdata/utf8.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithISO88591Document(t *testing.T) {
|
||||
file := "testdata/iso-8859-1.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html; charset=ISO-8859-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithISO88591DocumentAndNoContentType(t *testing.T) {
|
||||
file := "testdata/iso-8859-1.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithISO88591DocumentWithMetaAfter1024Bytes(t *testing.T) {
|
||||
file := "testdata/iso-8859-1-meta-after-1024.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithUTF8DocumentWithMetaAfter1024Bytes(t *testing.T) {
|
||||
file := "testdata/utf8-meta-after-1024.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="invalid"?>
|
||||
<feed>
|
||||
<title>테스트 피드</title>
|
||||
<entry>
|
||||
<title>Café</title>
|
||||
</entry>
|
||||
</feed>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!---
|
||||
|
||||
This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.
|
||||
|
||||
This comment is used to pad the file to 1024 bytes.
|
||||
|
||||
The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.
|
||||
|
||||
---
|
||||
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
|
||||
-->
|
||||
<head>
|
||||
<meta charset="iso-8859-1">
|
||||
<title>Frédéric</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="iso-8859-1">
|
||||
<title>Frédéric</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Café</title>
|
||||
<description>Présentation</description>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1"?>
|
||||
<feed>
|
||||
<title>테스트 피드</title>
|
||||
<entry>
|
||||
<title>Café</title>
|
||||
</entry>
|
||||
</feed>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!---
|
||||
|
||||
This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.
|
||||
|
||||
This comment is used to pad the file to 1024 bytes.
|
||||
|
||||
The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.
|
||||
|
||||
---
|
||||
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
|
||||
-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frédéric</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Café</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed>
|
||||
<title>테스트 피드</title>
|
||||
<entry>
|
||||
<title>Café</title>
|
||||
</entry>
|
||||
</feed>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="windows-1252"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Euro €</title>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="windows-1252"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Euro €</title>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -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()
|
||||
|
||||
@@ -21,12 +21,12 @@ import (
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/encoding"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
type IconFinder struct {
|
||||
@@ -222,7 +222,7 @@ func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
case "image/gif":
|
||||
src, err = gif.Decode(r)
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil || src == nil {
|
||||
slog.Warn("unable to decode the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
|
||||
"link[rel='apple-touch-icon-precomposed.png']",
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := charset.NewReader(body, contentType)
|
||||
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ type opmlOutline struct {
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
type opmlOutlineXml opmlOutline
|
||||
|
||||
outlineType := ""
|
||||
if outline.IsSubscription() {
|
||||
if o.IsSubscription() {
|
||||
outlineType = "rss"
|
||||
}
|
||||
|
||||
@@ -47,16 +47,16 @@ func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) er
|
||||
opmlOutlineXml
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
}{
|
||||
opmlOutlineXml: opmlOutlineXml(outline),
|
||||
opmlOutlineXml: opmlOutlineXml(o),
|
||||
Type: outlineType,
|
||||
}, start)
|
||||
}
|
||||
|
||||
func (o *opmlOutline) IsSubscription() bool {
|
||||
func (o opmlOutline) IsSubscription() bool {
|
||||
return strings.TrimSpace(o.FeedURL) != ""
|
||||
}
|
||||
|
||||
func (o *opmlOutline) GetTitle() string {
|
||||
func (o opmlOutline) GetTitle() string {
|
||||
if o.Title != "" {
|
||||
return o.Title
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func (o *opmlOutline) GetTitle() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (o *opmlOutline) GetSiteURL() string {
|
||||
func (o opmlOutline) GetSiteURL() string {
|
||||
if o.SiteURL != "" {
|
||||
return o.SiteURL
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.BlockFilterEntryRules != "" {
|
||||
rules := strings.Split(user.BlockFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
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":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if feed.BlocklistRules == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.BlocklistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledBlocklist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.BlocklistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.KeepFilterEntryRules != "" {
|
||||
rules := strings.Split(user.KeepFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
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":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Allowing entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if feed.KeeplistRules == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.KeeplistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledKeeplist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.KeeplistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
@@ -127,157 +125,17 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
|
||||
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
|
||||
|
||||
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
|
||||
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
}
|
||||
|
||||
if user.ShowReadingTime && shouldFetchYouTubeWatchTimeInBulk() {
|
||||
fetchYouTubeWatchTimeInBulk(filteredEntries)
|
||||
}
|
||||
|
||||
feed.Entries = filteredEntries
|
||||
}
|
||||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.BlockFilterEntryRules != "" {
|
||||
rules := strings.Split(user.BlockFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
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":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if feed.BlocklistRules == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.BlocklistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledBlocklist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.BlocklistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.KeepFilterEntryRules != "" {
|
||||
rules := strings.Split(user.KeepFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
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":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Allowing entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if feed.KeeplistRules == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.KeeplistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledKeeplist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.KeeplistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
|
||||
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
|
||||
startTime := time.Now()
|
||||
@@ -358,94 +216,6 @@ func rewriteEntryURL(feed *model.Feed, entry *model.Entry) string {
|
||||
return rewrittenURL
|
||||
}
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldFetchYouTubeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchYouTubeWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch YouTube watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchNebulaWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchNebulaWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchOdyseeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchOdyseeWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Odysee watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchBilibiliWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchBilibiliWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Bilibili watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YT error case and non-YT entries.
|
||||
if entry.ReadingTime == 0 {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
func isRecentEntry(entry *model.Entry) bool {
|
||||
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
|
||||
return true
|
||||
@@ -468,44 +238,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
// Define a type for watch time fetching functions
|
||||
type watchTimeFetcher func(string) (int, error)
|
||||
|
||||
// Define watch time fetching scenarios
|
||||
watchTimeScenarios := []struct {
|
||||
shouldFetch func(*model.Entry) bool
|
||||
fetchFunc watchTimeFetcher
|
||||
platform string
|
||||
}{
|
||||
{shouldFetchYouTubeWatchTimeForSingleEntry, fetchYouTubeWatchTimeForSingleEntry, "YouTube"},
|
||||
{shouldFetchNebulaWatchTime, fetchNebulaWatchTime, "Nebula"},
|
||||
{shouldFetchOdyseeWatchTime, fetchOdyseeWatchTime, "Odysee"},
|
||||
{shouldFetchBilibiliWatchTime, fetchBilibiliWatchTime, "Bilibili"},
|
||||
}
|
||||
|
||||
// Iterate through scenarios and attempt to fetch watch time
|
||||
for _, scenario := range watchTimeScenarios {
|
||||
if scenario.shouldFetch(entry) {
|
||||
if entryIsNew {
|
||||
if watchTime, err := scenario.fetchFunc(entry.URL); err != nil {
|
||||
slog.Warn("Unable to fetch watch time",
|
||||
slog.String("platform", scenario.platform),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
entry.ReadingTime = watchTime
|
||||
}
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to text-based reading time estimation
|
||||
if entry.ReadingTime == 0 && entry.Content != "" {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@@ -25,24 +26,30 @@ var (
|
||||
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
|
||||
)
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
func isYouTubeVideoURL(websiteURL string) bool {
|
||||
return len(youtubeRegex.FindStringSubmatch(websiteURL)) == 2
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
if config.Opts.YouTubeApiKey() == "" {
|
||||
return fetchYouTubeWatchTimeFromWebsite(websiteURL)
|
||||
} else {
|
||||
return fetchYouTubeWatchTimeFromApi(websiteURL)
|
||||
func getVideoIDFromYouTubeURL(websiteURL string) string {
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsedWebsiteURL.Query().Get("v")
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() == "" && isYouTubeVideoURL(entry.URL)
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTimeInBulk() bool {
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() != ""
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
|
||||
slog.Debug("Fetching YouTube watch time for a single entry", slog.String("website_url", websiteURL))
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
@@ -60,31 +67,59 @@ func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
htmlDuration, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
return 0, errors.New("youtube: duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
parsedDuration, err := parseISO8601(htmlDuration)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
return 0, fmt.Errorf("youtube: unable to parse duration %s: %v", htmlDuration, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
return int(parsedDuration.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
func fetchYouTubeWatchTimeInBulk(entries []*model.Entry) {
|
||||
var videosEntriesMapping = make(map[string]*model.Entry)
|
||||
var videoIDs []string
|
||||
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse URL: %v", err)
|
||||
for _, entry := range entries {
|
||||
if !isYouTubeVideoURL(entry.URL) {
|
||||
continue
|
||||
}
|
||||
|
||||
youtubeVideoID := getVideoIDFromYouTubeURL(entry.URL)
|
||||
if youtubeVideoID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
videosEntriesMapping[getVideoIDFromYouTubeURL(entry.URL)] = entry
|
||||
videoIDs = append(videoIDs, youtubeVideoID)
|
||||
}
|
||||
|
||||
if len(videoIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
watchTimeMap, err := fetchYouTubeWatchTimeFromApiInBulk(videoIDs)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch YouTube watch time in bulk", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
|
||||
for videoID, watchTime := range watchTimeMap {
|
||||
if entry, ok := videosEntriesMapping[videoID]; ok {
|
||||
entry.ReadingTime = int(watchTime.Minutes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Duration, error) {
|
||||
slog.Debug("Fetching YouTube watch time in bulk", slog.Any("video_ids", videoIDs))
|
||||
|
||||
apiQuery := url.Values{}
|
||||
apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
|
||||
apiQuery.Set("id", strings.Join(videoIDs, ","))
|
||||
apiQuery.Set("key", config.Opts.YouTubeApiKey())
|
||||
apiQuery.Set("part", "contentDetails")
|
||||
|
||||
@@ -95,37 +130,33 @@ func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
RawQuery: apiQuery.Encode(),
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
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"`
|
||||
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.Any("error", localizedError.Error()))
|
||||
return nil, localizedError.Error()
|
||||
}
|
||||
|
||||
var videos youtubeVideoListResponse
|
||||
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
|
||||
return 0, fmt.Errorf("unable to decode JSON: %v", err)
|
||||
return nil, fmt.Errorf("youtube: unable to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if n := len(videos.Items); n != 1 {
|
||||
return 0, fmt.Errorf("invalid items length: %d", n)
|
||||
watchTimeMap := make(map[string]time.Duration)
|
||||
for _, video := range videos.Items {
|
||||
duration, err := parseISO8601(video.ContentDetails.Duration)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to parse ISO8601 duration", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
watchTimeMap[video.ID] = duration
|
||||
}
|
||||
|
||||
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
|
||||
return watchTimeMap, nil
|
||||
}
|
||||
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
@@ -135,7 +166,7 @@ func parseISO8601(from string) (time.Duration, error) {
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
return 0, errors.New("youtube: could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
@@ -151,15 +182,24 @@ func parseISO8601(from string) (time.Duration, error) {
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
d += time.Duration(val) * time.Hour
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
d += time.Duration(val) * time.Minute
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
d += time.Duration(val) * time.Second
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
return 0, fmt.Errorf("youtube: unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
type youtubeVideoListResponse struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
ContentDetails struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"contentDetails"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
@@ -36,3 +36,37 @@ func TestParseISO8601(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetYouTubeVideoIDFromURL(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0", "HLrqNhgdiC0"},
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be", "HLrqNhgdiC0"},
|
||||
{"https://example.org/test", ""},
|
||||
}
|
||||
for _, tc := range scenarios {
|
||||
result := getVideoIDFromYouTubeURL(tc.url)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %q for url %q`, result, tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsYouTubeVideoURL(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0", true},
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be", true},
|
||||
{"https://example.org/test", false},
|
||||
}
|
||||
for _, tc := range scenarios {
|
||||
result := isYouTubeVideoURL(tc.url)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for url %q`, result, tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := min(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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
|
||||
@@ -26,6 +27,24 @@ var (
|
||||
textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
||||
)
|
||||
|
||||
// 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 {
|
||||
@@ -436,3 +455,55 @@ func removeTables(entryContent string) string {
|
||||
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 {
|
||||
@@ -94,7 +91,9 @@ func (rule rule) applyRule(entryURL string, entry *model.Entry) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,13 +173,13 @@ func findFeedAuthor(rssChannel *RSSChannel) string {
|
||||
}
|
||||
|
||||
func findEntryTitle(rssItem *RSSItem) string {
|
||||
title := rssItem.Title
|
||||
title := rssItem.Title.Content
|
||||
|
||||
if rssItem.DublinCoreTitle != "" {
|
||||
title = rssItem.DublinCoreTitle
|
||||
}
|
||||
|
||||
return html.UnescapeString(strings.TrimSpace(title))
|
||||
return html.UnescapeString(html.UnescapeString(strings.TrimSpace(title)))
|
||||
}
|
||||
|
||||
func findEntryURL(rssItem *RSSItem) string {
|
||||
|
||||
+178
-133
@@ -311,6 +311,184 @@ func TestParseEntryWithDCTitleOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example Feed</title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != "Example \u00a0 Feed" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title><![CDATA[Jenny’s Newsletter]]></title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != `Jenny’s Newsletter` {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title></example></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "</example>" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithNumericCharacterReference(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>Σ ß</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Σ ß" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>&#39;Text&#39;</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "'Text'" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithWhitespaces(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title>
|
||||
Some Title
|
||||
</title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Some Title" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithCDATA(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title><![CDATA[This is a title]]></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "This is a title" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithInnerHTML(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title>Test: <b>bold</b></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Test: bold" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithoutLink(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
@@ -997,32 +1175,6 @@ func TestParseEntryWithFeedBurnerLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryTitleWithWhitespaces(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title>
|
||||
Some Title
|
||||
</title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Some Title" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithEnclosures(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
@@ -1380,113 +1532,6 @@ func TestParseInvalidXml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example Feed</title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != "Example \u00a0 Feed" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title><![CDATA[Jenny’s Newsletter]]></title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != `Jenny’s Newsletter` {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title></example></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "</example>" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithNumericCharacterReference(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>Σ ß</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Σ ß" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>&#39;Text&#39;</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "'Text'" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedLinkWithInvalidCharacterEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
|
||||
@@ -111,7 +111,7 @@ type RSSImage struct {
|
||||
|
||||
type RSSItem struct {
|
||||
// Title is the title of the item.
|
||||
Title string `xml:"rss title"`
|
||||
Title InnerContent `xml:"rss title"`
|
||||
|
||||
// Link is the URL of the item.
|
||||
Link string `xml:"rss link"`
|
||||
@@ -198,3 +198,28 @@ type RSSSource struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type InnerContent struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
func (ic *InnerContent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var content strings.Builder
|
||||
|
||||
for {
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch t := token.(type) {
|
||||
case xml.CharData:
|
||||
content.Write(t)
|
||||
case xml.EndElement:
|
||||
if t == start.End() {
|
||||
ic.Content = strings.TrimSpace(content.String())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,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": {},
|
||||
@@ -65,7 +66,7 @@ var (
|
||||
"sup": {"id"},
|
||||
"table": {},
|
||||
"td": {"rowspan", "colspan"},
|
||||
"tfooter": {},
|
||||
"tfoot": {},
|
||||
"th": {"rowspan", "colspan"},
|
||||
"thead": {},
|
||||
"time": {"datetime"},
|
||||
@@ -126,9 +127,11 @@ func Sanitize(baseURL, input string) string {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
// Rewrite the start tag with allowed attributes.
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
|
||||
} else {
|
||||
buffer.WriteString(token.String())
|
||||
// Rewrite the start tag without any attributes.
|
||||
buffer.WriteString("<" + tagName + ">")
|
||||
}
|
||||
|
||||
tagStack = append(tagStack, tagName)
|
||||
@@ -137,7 +140,7 @@ func Sanitize(baseURL, input string) string {
|
||||
case html.EndTagToken:
|
||||
if len(blockedStack) == 0 {
|
||||
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString(token.String())
|
||||
buffer.WriteString("</" + tagName + ">")
|
||||
}
|
||||
} else {
|
||||
if blockedStack[len(blockedStack)-1] == tagName {
|
||||
@@ -154,7 +157,7 @@ func Sanitize(baseURL, input string) string {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
|
||||
} else {
|
||||
buffer.WriteString(token.String())
|
||||
buffer.WriteString("<" + tagName + "/>")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,9 +296,9 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return slices.Contains(attributes, "href")
|
||||
case "iframe", "img":
|
||||
case "iframe":
|
||||
return slices.Contains(attributes, "src")
|
||||
case "source":
|
||||
case "source", "img":
|
||||
return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset")
|
||||
default:
|
||||
return true
|
||||
|
||||
@@ -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)
|
||||
@@ -94,7 +119,7 @@ func TestImgWithDataURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithSrcset(t *testing.T) {
|
||||
func TestImgWithSrcsetAttribute(t *testing.T) {
|
||||
input := `<img srcset="example-320w.jpg, example-480w.jpg 1.5x, example-640w.jpg 2x, example-640w.jpg 640w" src="example-640w.jpg" alt="Example">`
|
||||
expected := `<img srcset="http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w" src="http://example.org/example-640w.jpg" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
@@ -104,6 +129,16 @@ func TestImgWithSrcset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithSrcsetAndNoSrcAttribute(t *testing.T) {
|
||||
input := `<img srcset="example-320w.jpg, example-480w.jpg 1.5x, example-640w.jpg 2x, example-640w.jpg 640w" alt="Example">`
|
||||
expected := `<img srcset="http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceWithSrcsetAndMedia(t *testing.T) {
|
||||
input := `<picture><source media="(min-width: 800px)" srcset="elva-800w.jpg"></picture>`
|
||||
expected := `<picture><source media="(min-width: 800px)" srcset="http://example.org/elva-800w.jpg"></picture>`
|
||||
@@ -660,3 +695,13 @@ func TestHiddenParagraph(t *testing.T) {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributesAreStripped(t *testing.T) {
|
||||
input := `<p style="color: red;">Some text.<hr style="color: blue"/>Test.</p>`
|
||||
expected := `<p>Some text.<hr/>Test.</p>`
|
||||
|
||||
output := Sanitize("http://example.org/", input)
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ import "strings"
|
||||
|
||||
func TruncateHTML(input string, max int) string {
|
||||
text := StripTags(input)
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = strings.ReplaceAll(text, "\t", " ")
|
||||
text = strings.ReplaceAll(text, " ", " ")
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
// Collapse multiple spaces into a single space
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
|
||||
// Convert to runes to be safe with unicode
|
||||
runes := []rune(text)
|
||||
|
||||
@@ -62,3 +62,53 @@ func TestTruncateHTMLWithMultilineTextLowerThanLimit(t *testing.T) {
|
||||
t.Errorf(`Wrong output: %q != %q`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLWithMultipleSpaces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "multiple spaces",
|
||||
input: "hello world test",
|
||||
maxLen: 20,
|
||||
expected: "hello world test",
|
||||
},
|
||||
{
|
||||
name: "tabs and newlines",
|
||||
input: "hello\t\tworld\n\ntest",
|
||||
maxLen: 20,
|
||||
expected: "hello world test",
|
||||
},
|
||||
{
|
||||
name: "truncation with unicode",
|
||||
input: "hello world 你好",
|
||||
maxLen: 11,
|
||||
expected: "hello world…",
|
||||
},
|
||||
{
|
||||
name: "html stripping",
|
||||
input: "<p>hello <b>world</b> test</p>",
|
||||
maxLen: 20,
|
||||
expected: "hello world test",
|
||||
},
|
||||
{
|
||||
name: "no truncation needed",
|
||||
input: "hello world",
|
||||
maxLen: 20,
|
||||
expected: "hello world",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TruncateHTML(tt.input, tt.maxLen)
|
||||
if result != tt.expected {
|
||||
t.Errorf("TruncateHTML(%q, %d) = %q, want %q",
|
||||
tt.input, tt.maxLen, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var predefinedRules = map[string]string{
|
||||
"slate.fr": ".field-items",
|
||||
"smbc-comics.com": "div#cc-comicbody, div#aftercomic",
|
||||
"swordscomic.com": "img#comic-image, div#info-frame.tab-content-area",
|
||||
"techcrunch.com": "div.article-entry",
|
||||
"techcrunch.com": "div.entry-content",
|
||||
"theoatmeal.com": "div#comic",
|
||||
"theregister.com": "#top-col-story h2, #body",
|
||||
"theverge.com": "h2.inline:nth-child(2),h2.duet--article--dangerously-set-cms-markup,figure.w-full,div.duet--article--article-body-component",
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/reader/encoding"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/reader/readability"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
func ScrapeWebsite(requestBuilder *fetcher.RequestBuilder, pageURL, rules string) (baseURL string, extractedContent string, err error) {
|
||||
@@ -39,10 +39,11 @@ func ScrapeWebsite(requestBuilder *fetcher.RequestBuilder, pageURL, rules string
|
||||
rules = getPredefinedScraperRules(pageURL)
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := charset.NewReader(
|
||||
htmlDocumentReader, err := encoding.NewCharsetReader(
|
||||
responseHandler.Body(config.Opts.HTTPClientMaxBodySize()),
|
||||
responseHandler.ContentType(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("scraper: unable to read HTML document with charset reader: %v", err)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
"miniflux.app/v2/internal/integration/rssbridge"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/encoding"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/reader/parser"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -136,7 +136,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
"link[type='application/feed+json']": parser.FormatJSON,
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := charset.NewReader(body, contentType)
|
||||
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
@@ -228,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
|
||||
}
|
||||
|
||||
|
||||
@@ -12,23 +12,24 @@ import (
|
||||
// Interesting lists:
|
||||
// https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/TrackParamFilter/sections/general_url.txt
|
||||
// https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/query-stripping/records
|
||||
// https://github.com/Smile4ever/Neat-URL/blob/master/data/default-params-by-category.json
|
||||
// https://github.com/brave/brave-core/blob/master/components/query_filter/utils.cc
|
||||
var trackingParams = map[string]bool{
|
||||
// https://en.wikipedia.org/wiki/UTM_parameters#Parameters
|
||||
"utm_source": true,
|
||||
"utm_medium": true,
|
||||
"utm_campaign": true,
|
||||
"utm_term": true,
|
||||
"utm_content": true,
|
||||
|
||||
// Facebook Click Identifiers
|
||||
"fbclid": true,
|
||||
"_openstat": true,
|
||||
"fbclid": true,
|
||||
"_openstat": true,
|
||||
"fb_action_ids": true,
|
||||
"fb_action_types": true,
|
||||
"fb_ref": true,
|
||||
"fb_source": true,
|
||||
"fb_comment_id": true,
|
||||
|
||||
// Google Click Identifiers
|
||||
"gclid": true,
|
||||
"dclid": true,
|
||||
"gbraid": true,
|
||||
"wbraid": true,
|
||||
"gclsrc": true,
|
||||
|
||||
// Yandex Click Identifiers
|
||||
"yclid": true,
|
||||
@@ -53,6 +54,7 @@ var trackingParams = map[string]bool{
|
||||
"__hssc": true,
|
||||
"__hstc": true,
|
||||
"__hsfp": true,
|
||||
"_hsmi": true,
|
||||
"hsctatracking": true,
|
||||
|
||||
// Olytics
|
||||
@@ -61,10 +63,21 @@ var trackingParams = map[string]bool{
|
||||
"oly_enc_id": true,
|
||||
|
||||
// Vero Click Identifier
|
||||
"vero_id": true,
|
||||
"vero_id": true,
|
||||
"vero_conv": true,
|
||||
|
||||
// Marketo email tracking
|
||||
"mkt_tok": true,
|
||||
|
||||
// Adobe email tracking
|
||||
"sc_cid": true,
|
||||
|
||||
// Beehiiv
|
||||
"_bhlid": true,
|
||||
|
||||
// Branch.io
|
||||
"_branch_match_id": true,
|
||||
"_branch_referrer": true,
|
||||
}
|
||||
|
||||
func RemoveTrackingParameters(inputURL string) (string, error) {
|
||||
@@ -82,7 +95,8 @@ func RemoveTrackingParameters(inputURL string) (string, error) {
|
||||
|
||||
// Remove tracking parameters
|
||||
for param := range queryParams {
|
||||
if trackingParams[strings.ToLower(param)] {
|
||||
lowerParam := strings.ToLower(param)
|
||||
if trackingParams[lowerParam] || strings.HasPrefix(lowerParam, "utm_") {
|
||||
queryParams.Del(param)
|
||||
hasTrackers = true
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ import (
|
||||
func NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {
|
||||
var decoder *xml.Decoder
|
||||
buffer, _ := io.ReadAll(data)
|
||||
enc := procInst("encoding", string(buffer))
|
||||
if enc != "" && enc != "utf-8" && enc != "UTF-8" && !strings.EqualFold(enc, "utf-8") {
|
||||
// filter invalid chars later within decoder.CharsetReader
|
||||
data.Seek(0, io.SeekStart)
|
||||
decoder = xml.NewDecoder(data)
|
||||
} else {
|
||||
enc := getEncoding(buffer)
|
||||
if enc == "" || strings.EqualFold(enc, "utf-8") {
|
||||
// filter invalid chars now, since decoder.CharsetReader not called for utf-8 content
|
||||
filteredBytes := bytes.Map(filterValidXMLChar, buffer)
|
||||
decoder = xml.NewDecoder(bytes.NewReader(filteredBytes))
|
||||
} else {
|
||||
// filter invalid chars later within decoder.CharsetReader
|
||||
data.Seek(0, io.SeekStart)
|
||||
decoder = xml.NewDecoder(data)
|
||||
}
|
||||
|
||||
decoder.Entity = xml.HTMLEntity
|
||||
@@ -60,27 +60,24 @@ func filterValidXMLChar(r rune) rune {
|
||||
return -1
|
||||
}
|
||||
|
||||
// This function is copied from encoding/xml package,
|
||||
// procInst parses the `param="..."` or `param='...'`
|
||||
// value out of the provided string, returning "" if not found.
|
||||
func procInst(param, s string) string {
|
||||
// This function is copied from encoding/xml's procInst and adapted for []bytes instead of string
|
||||
func getEncoding(b []byte) string {
|
||||
// TODO: this parsing is somewhat lame and not exact.
|
||||
// It works for all actual cases, though.
|
||||
param += "="
|
||||
idx := strings.Index(s, param)
|
||||
idx := bytes.Index(b, []byte("encoding="))
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
v := s[idx+len(param):]
|
||||
if v == "" {
|
||||
v := b[idx+len("encoding="):]
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
if v[0] != '\'' && v[0] != '"' {
|
||||
return ""
|
||||
}
|
||||
idx = strings.IndexRune(v[1:], rune(v[0]))
|
||||
idx = bytes.IndexRune(v[1:], rune(v[0]))
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
return v[1 : idx+1]
|
||||
return string(v[1 : idx+1])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
@@ -40,6 +40,14 @@ func (s *Storage) FeedExists(userID, feedID int64) bool {
|
||||
return result
|
||||
}
|
||||
|
||||
// CategoryFeedExists returns true if the given feed exists that belongs to the given category.
|
||||
func (s *Storage) CategoryFeedExists(userID, categoryID, feedID int64) bool {
|
||||
var result bool
|
||||
query := `SELECT true FROM feeds WHERE user_id=$1 AND category_id=$2 AND id=$3`
|
||||
s.db.QueryRow(query, userID, categoryID, feedID).Scan(&result)
|
||||
return result
|
||||
}
|
||||
|
||||
// FeedURLExists checks if feed URL already exists.
|
||||
func (s *Storage) FeedURLExists(userID int64, feedURL string) bool {
|
||||
var result bool
|
||||
@@ -238,11 +246,12 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
|
||||
url_rewrite_rules,
|
||||
no_media_player,
|
||||
apprise_service_urls,
|
||||
webhook_url,
|
||||
disable_http2,
|
||||
description
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)
|
||||
RETURNING
|
||||
id
|
||||
`
|
||||
@@ -272,6 +281,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
|
||||
feed.UrlRewriteRules,
|
||||
feed.NoMediaPlayer,
|
||||
feed.AppriseServiceURLs,
|
||||
feed.WebhookURL,
|
||||
feed.DisableHTTP2,
|
||||
feed.Description,
|
||||
).Scan(&feed.ID)
|
||||
@@ -346,12 +356,15 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
url_rewrite_rules=$25,
|
||||
no_media_player=$26,
|
||||
apprise_service_urls=$27,
|
||||
disable_http2=$28,
|
||||
description=$29,
|
||||
ntfy_enabled=$30,
|
||||
ntfy_priority=$31
|
||||
webhook_url=$28,
|
||||
disable_http2=$29,
|
||||
description=$30,
|
||||
ntfy_enabled=$31,
|
||||
ntfy_priority=$32,
|
||||
pushover_enabled=$33,
|
||||
pushover_priority=$34
|
||||
WHERE
|
||||
id=$32 AND user_id=$33
|
||||
id=$35 AND user_id=$36
|
||||
`
|
||||
_, err = s.db.Exec(query,
|
||||
feed.FeedURL,
|
||||
@@ -381,10 +394,13 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
feed.UrlRewriteRules,
|
||||
feed.NoMediaPlayer,
|
||||
feed.AppriseServiceURLs,
|
||||
feed.WebhookURL,
|
||||
feed.DisableHTTP2,
|
||||
feed.Description,
|
||||
feed.NtfyEnabled,
|
||||
feed.NtfyPriority,
|
||||
feed.PushoverEnabled,
|
||||
feed.PushoverPriority,
|
||||
feed.ID,
|
||||
feed.UserID,
|
||||
)
|
||||
|
||||
@@ -165,9 +165,12 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
fi.icon_id,
|
||||
u.timezone,
|
||||
f.apprise_service_urls,
|
||||
f.webhook_url,
|
||||
f.disable_http2,
|
||||
f.ntfy_enabled,
|
||||
f.ntfy_priority
|
||||
f.ntfy_priority,
|
||||
f.pushover_enabled,
|
||||
f.pushover_priority
|
||||
FROM
|
||||
feeds f
|
||||
LEFT JOIN
|
||||
@@ -235,9 +238,12 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
&iconID,
|
||||
&tz,
|
||||
&feed.AppriseServiceURLs,
|
||||
&feed.WebhookURL,
|
||||
&feed.DisableHTTP2,
|
||||
&feed.NtfyEnabled,
|
||||
&feed.NtfyPriority,
|
||||
&feed.PushoverEnabled,
|
||||
&feed.PushoverPriority,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user