Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b683756d8e | |||
| 3dfc70cee6 | |||
| 91b4a7d35f | |||
| 2fbe2df086 | |||
| 964698f363 | |||
| e34af65ae9 | |||
| e99a675912 | |||
| d96ad4ddef | |||
| 4272932402 | |||
| a60996e666 | |||
| a09ddbbaf4 | |||
| 92db691344 | |||
| a334c8e691 | |||
| bf1c851093 | |||
| c787bb5b48 | |||
| 1a81866bb9 | |||
| c4278821cb | |||
| 4498ba10e8 | |||
| a46e702536 | |||
| f0e8323f19 | |||
| a0106c9ffc | |||
| f98d5de484 | |||
| ee5e18ea9f | |||
| 3ef2522c62 | |||
| 839c4ad044 | |||
| 9f3a8e7f1b | |||
| e54825bf02 | |||
| 07f6d397d4 | |||
| f33e76eb8c | |||
| 84e97826d8 | |||
| 839fc3843a | |||
| 0bab8fac8e | |||
| 0cf1a40276 | |||
| 91479bc0ee | |||
| 251821289c | |||
| cac0bc682f | |||
| a733c14c61 | |||
| b68b05c64c | |||
| 5ce3f24838 | |||
| 48ddc02ba8 | |||
| fe9f1bba16 | |||
| 740fa4a5d2 | |||
| 8a38f54ef5 | |||
| ae432bc9c6 | |||
| 96f7e8bae0 | |||
| 1f35ed1675 | |||
| d6deac1810 | |||
| b692768730 | |||
| 2178580a75 | |||
| b52f61cc77 | |||
| 3388f8e376 | |||
| 83ceb20c1c | |||
| c06850ca34 | |||
| d856c02fbb | |||
| a33b1adf13 | |||
| a631bd527d | |||
| ca62b0b36b | |||
| 7d6a4243c1 | |||
| d056aa1f73 | |||
| 018e24404e | |||
| 4d3ee0d15d | |||
| 797450986b | |||
| 93bc9ce24d | |||
| 9233568da3 | |||
| fb075b60b5 | |||
| 2c4c845cd2 | |||
| 2caabbe939 | |||
| 771f9d2b5f | |||
| 647c66e70a | |||
| b205b5aad0 | |||
| 4ab0d9422d | |||
| 38b80d96ea | |||
| 35edd8ea92 | |||
| f0cb041885 | |||
| fdd1b3f18e | |||
| 6e870cdccc | |||
| 194f517be8 | |||
| 11fd1c935e | |||
| 47e1111908 | |||
| c5b812eb7b | |||
| 53be550e8a | |||
| d0d693a6ef | |||
| 1b8c45d162 | |||
| 19ce519836 | |||
| 3e0d5de7a3 |
@@ -3,5 +3,5 @@ Do you follow the guidelines?
|
||||
- [ ] I have tested my changes
|
||||
- [ ] There is no breaking changes
|
||||
- [ ] I really tested my changes and there is no regression
|
||||
- [ ] Ideally, my commit messages use the same convention as the Go project: https://go.dev/doc/contribute#commit_messages
|
||||
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
|
||||
@@ -8,35 +8,8 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
jobs:
|
||||
test-docker-images:
|
||||
if: github.event.pull_request
|
||||
name: Test Images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Build Alpine image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
push: false
|
||||
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
|
||||
- name: Test Alpine Docker image
|
||||
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
|
||||
- name: Build Distroless image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
push: false
|
||||
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
|
||||
- name: Test Distroless Docker image
|
||||
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
|
||||
|
||||
publish-docker-images:
|
||||
if: ${{ ! github.event.pull_request }}
|
||||
name: Publish Images
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,33 +19,33 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tag
|
||||
id: docker_alpine_tag
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
||||
DOCKER_VERSION=dev
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
DOCKER_VERSION=nightly
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
ghcr.io/${{ github.repository_owner }}/miniflux
|
||||
quay.io/${{ github.repository_owner }}/miniflux
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=schedule,pattern=nightly
|
||||
type=semver,pattern={{raw}}
|
||||
|
||||
- name: Generate Distroless Docker tag
|
||||
id: docker_distroless_tag
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
||||
DOCKER_VERSION=dev-distroless
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
DOCKER_VERSION=nightly-distroless
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
|
||||
fi
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
ghcr.io/${{ github.repository_owner }}/miniflux
|
||||
quay.io/${{ github.repository_owner }}/miniflux
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=schedule,pattern=nightly
|
||||
type=semver,pattern={{raw}}
|
||||
flavor: |
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -81,12 +54,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -94,6 +69,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
@@ -101,19 +77,19 @@ jobs:
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
- run: "go vet ./..."
|
||||
- uses: golangci/golangci-lint-action@v4
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
|
||||
@@ -1,3 +1,72 @@
|
||||
Version 2.1.4 (July 9, 2024)
|
||||
----------------------------
|
||||
|
||||
* test: add unit tests for `IsModified()` behaviour
|
||||
* refactor: improve YouTube page feed detection
|
||||
* fix(ui): settings form is not populated correctly after validation errors
|
||||
* fix(ui): playback speed indicator precision
|
||||
* fix(ui): playback speed indicator on shared entries
|
||||
* fix(integration): preserve existing Pinboard bookmarks
|
||||
* fix(googlereader): set `CrawlTimeMsec` to the correct precision
|
||||
* fix(build): failed to solve container image `arm64v8/golang:1.22-bookworm`
|
||||
* fix(build): add `distroless` suffix on `latest` tag in GitHub workflow
|
||||
* fix: use `ETag` as a stronger validator than `Last-Modified`
|
||||
* fix: update `theverge.com` rewrite rule to avoid duplicate image
|
||||
* fix: incorrect Go package comment `reader/readingtime`
|
||||
* fix: error out for improper rewrite regexp when processing feed entries
|
||||
* fix: ensures that session cookies are not expiring before the session is cleaned up from the database as per `CLEANUP_REMOVE_SESSIONS_DAYS`
|
||||
* fix: `<img>` aspect ratio with `height: auto`
|
||||
* feat(ui): add `viewport-fit=cover`
|
||||
* feat(sanitizer): add support for HTML hidden attribute
|
||||
* feat(locale): update French translations
|
||||
* feat(integration): add Raindrop integration
|
||||
* feat(integration): add feed name to Telegram message
|
||||
* feat(integration): add Betula integration
|
||||
* feat: use of insecure TLS ciphers when "Allow self-signed or invalid certificates" is enabled to workaround some broken websites
|
||||
* feat: discover feeds from a Youtube playlist pages
|
||||
* feat: add navigation to last/first page
|
||||
* feat: add global block and keep filters
|
||||
* feat: add description field to feed settings
|
||||
* feat: add `pitchfork.com` scraping rule
|
||||
* feat: add `FETCH_NEBULA_WATCH_TIME` config option
|
||||
* Bump `github.com/PuerkitoBio/goquery` from` 1.9.1` to` 1.9.2`
|
||||
* Bump `github.com/prometheus/client_golang` from `1.19.0` to `1.19.1`
|
||||
* build(deps): bump `library/alpine` in `/packaging/docker/alpine`
|
||||
* build(deps): bump `golangci/golangci-lint-action` from `4` to `6`
|
||||
* build(deps): bump `golang.org/x/term` from `0.19.0` to `0.22.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.19.0` to `0.21.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.22.0` to `0.27.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.24.0` to `0.25.0`
|
||||
* build(deps): bump `github.com/yuin/goldmark` from `1.7.1` to `1.7.4`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.20` to `2.20.36`
|
||||
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.10.0` to `3.11.0`
|
||||
* build(deps): bump `docker/build-push-action` from `5` to `6`
|
||||
|
||||
Version 2.1.3 (April 27, 2024)
|
||||
------------------------------
|
||||
|
||||
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
|
||||
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
|
||||
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
|
||||
* `finder`: find feeds from YouTube playlist
|
||||
* `http/response`: add brotli compression support
|
||||
* `integration/matrix`: fix function name in comment
|
||||
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
|
||||
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
|
||||
* `reader/fetcher`: add brotli content encoding support
|
||||
* `reader/processor`: minimize feed entries HTML content
|
||||
* `reader/rewrite`: add a rule for `oglaf.com`
|
||||
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
|
||||
* `ui`: add seek and speed controls to media player
|
||||
* `ui`: add tag entries page
|
||||
* `ui`: fix JavaScript error when clicking on unread counter
|
||||
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
|
||||
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
|
||||
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
|
||||
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
|
||||
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
|
||||
|
||||
Version 2.1.2 (March 30, 2024)
|
||||
------------------------------
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DEB_IMG_ARCH := amd64
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DOCKER_PLATFORM := amd64
|
||||
|
||||
export PGPASSWORD := postgres
|
||||
|
||||
@@ -163,15 +163,15 @@ rpm: clean
|
||||
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
|
||||
|
||||
debian:
|
||||
@ docker build --load \
|
||||
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
|
||||
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
|
||||
@ docker buildx build --load \
|
||||
--platform linux/$(DOCKER_PLATFORM) \
|
||||
-t miniflux-deb-builder \
|
||||
-f packaging/debian/Dockerfile \
|
||||
.
|
||||
@ docker run --rm \
|
||||
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
|
||||
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
|
||||
-v ${PWD}:/pkg miniflux-deb-builder
|
||||
|
||||
debian-packages: clean
|
||||
$(MAKE) debian DEB_IMG_ARCH=amd64
|
||||
$(MAKE) debian DEB_IMG_ARCH=arm64v8
|
||||
$(MAKE) debian DEB_IMG_ARCH=arm32v7
|
||||
$(MAKE) debian DOCKER_PLATFORM=amd64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm/v7
|
||||
|
||||
@@ -42,6 +42,8 @@ type User struct {
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
@@ -82,6 +84,8 @@ type UserModificationRequest struct {
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
|
||||
@@ -3,20 +3,21 @@ module miniflux.app/v2
|
||||
// +heroku goVersion go1.22
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.1
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/coreos/go-oidc/v3 v3.10.0
|
||||
github.com/andybalholm/brotli v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/tdewolff/minify/v2 v2.20.19
|
||||
github.com/yuin/goldmark v1.7.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/net v0.22.0
|
||||
golang.org/x/oauth2 v0.18.0
|
||||
golang.org/x/term v0.18.0
|
||||
golang.org/x/text v0.14.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/tdewolff/minify/v2 v2.20.36
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
golang.org/x/crypto v0.25.0
|
||||
golang.org/x/net v0.27.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
golang.org/x/term v0.22.0
|
||||
golang.org/x/text v0.16.0
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
)
|
||||
|
||||
@@ -31,17 +32,15 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.12 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
|
||||
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
|
||||
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/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.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
@@ -41,8 +38,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
@@ -51,22 +48,22 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo=
|
||||
github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
|
||||
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
|
||||
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/minify/v2 v2.20.36 h1:uhbCO5NNS0UgJfEyE/ZR+xU5DL9Dz0ngrJ8W9A6coCQ=
|
||||
github.com/tdewolff/minify/v2 v2.20.36/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
|
||||
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
|
||||
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
|
||||
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -74,10 +71,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -88,32 +85,26 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -58,7 +57,7 @@ func (c *integrationTestConfig) isConfigured() bool {
|
||||
}
|
||||
|
||||
func (c *integrationTestConfig) genRandomUsername() string {
|
||||
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Intn(math.MaxInt64))
|
||||
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Int())
|
||||
}
|
||||
|
||||
func TestIncorrectEndpoint(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
@@ -82,6 +84,18 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
|
||||
if userModificationRequest.BlockFilterEntryRules != nil {
|
||||
*userModificationRequest.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.BlockFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.BlockFilterEntryRules = strings.ReplaceAll(*userModificationRequest.BlockFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
if userModificationRequest.KeepFilterEntryRules != nil {
|
||||
*userModificationRequest.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.KeepFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.KeepFilterEntryRules = strings.ReplaceAll(*userModificationRequest.KeepFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
|
||||
@@ -2021,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNebulaWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.FetchNebulaWatchTime()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOdyseeWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
|
||||
|
||||
@@ -56,6 +56,7 @@ const (
|
||||
defaultMediaResourceTypes = "image"
|
||||
defaultMediaProxyURL = ""
|
||||
defaultFilterEntryMaxAgeDays = 0
|
||||
defaultFetchNebulaWatchTime = false
|
||||
defaultFetchOdyseeWatchTime = false
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||
@@ -140,6 +141,7 @@ type Options struct {
|
||||
mediaProxyMode string
|
||||
mediaProxyResourceTypes []string
|
||||
mediaProxyCustomURL string
|
||||
fetchNebulaWatchTime bool
|
||||
fetchOdyseeWatchTime bool
|
||||
fetchYouTubeWatchTime bool
|
||||
filterEntryMaxAgeDays int
|
||||
@@ -216,6 +218,7 @@ func NewOptions() *Options {
|
||||
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||
@@ -486,6 +489,12 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||
return o.youTubeEmbedUrlOverride
|
||||
}
|
||||
|
||||
// FetchNebulaWatchTime returns true if the Nebula video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchNebulaWatchTime() bool {
|
||||
return o.fetchNebulaWatchTime
|
||||
}
|
||||
|
||||
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchOdyseeWatchTime() bool {
|
||||
@@ -647,6 +656,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
|
||||
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
|
||||
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
|
||||
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
|
||||
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
|
||||
"HTTPS": o.HTTPS,
|
||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||
|
||||
@@ -259,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
|
||||
@@ -882,4 +882,43 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// Entry URLs can exceeds btree maximum size
|
||||
// Checking entry existence is now using entries_feed_id_status_hash_idx index
|
||||
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users
|
||||
ADD COLUMN block_filter_entry_rules text not null default '',
|
||||
ADD COLUMN keep_filter_entry_rules text not null default ''
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN betula_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN betula_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN betula_enabled bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1021,10 +1021,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
ID: fmt.Sprintf(EntryIDLong, entry.ID),
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
|
||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
|
||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
|
||||
Published: entry.Date.Unix(),
|
||||
Updated: entry.Date.Unix(),
|
||||
Updated: entry.ChangedAt.Unix(),
|
||||
Categories: categories,
|
||||
Canonical: []contentHREF{
|
||||
{
|
||||
|
||||
@@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
// Cookie names.
|
||||
const (
|
||||
CookieAppSessionID = "MinifluxAppSessionID"
|
||||
CookieUserSessionID = "MinifluxUserSessionID"
|
||||
|
||||
// Cookie duration in days.
|
||||
cookieDuration = 30
|
||||
)
|
||||
|
||||
// New creates a new cookie.
|
||||
@@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
const compressionThreshold = 1024
|
||||
@@ -110,8 +112,15 @@ func (b *Builder) writeHeaders() {
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
b.headers["Content-Encoding"] = "br"
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
defer brotliWriter.Close()
|
||||
brotliWriter.Write(data)
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.writeHeaders()
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
func TestBuildResponseWithBrotliCompression(t *testing.T) {
|
||||
body := strings.Repeat("a", compressionThreshold+1)
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
@@ -245,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expected := "br"
|
||||
actual := resp.Header.Get("Content-Encoding")
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
body := strings.Repeat("a", compressionThreshold+1)
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expected := "gzip"
|
||||
actual := resp.Header.Get("Content-Encoding")
|
||||
if actual != expected {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package betula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
url string
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(url, token string) *Client {
|
||||
return &Client{url: url, token: token}
|
||||
}
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) error {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.url, "/save-link")
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to generate save-link endpoint: %v", err)
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("url", entryURL)
|
||||
values.Add("title", entryTitle)
|
||||
values.Add("tags", strings.Join(tags, ","))
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("betula: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/espial"
|
||||
"miniflux.app/v2/internal/integration/instapaper"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
@@ -31,6 +33,30 @@ import (
|
||||
|
||||
// SendEntry sends the entry to third-party providers when the user click on "Save".
|
||||
func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
if userIntegrations.BetulaEnabled {
|
||||
slog.Debug("Sending entry to Betula",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := betula.NewClient(userIntegrations.BetulaURL, userIntegrations.BetulaToken)
|
||||
err := client.CreateBookmark(
|
||||
entry.URL,
|
||||
entry.Title,
|
||||
entry.Tags,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Unable to send entry to Betula",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PinboardEnabled {
|
||||
slog.Debug("Sending entry to Pinboard",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -359,6 +385,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.OmnivoreEnabled {
|
||||
slog.Debug("Sending entry to Omnivore",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -376,6 +403,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.RaindropEnabled {
|
||||
slog.Debug("Sending entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
|
||||
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// PushEntry pushes entries to matrix chat using integration settings provided
|
||||
// PushEntries pushes entries to matrix chat using integration settings provided
|
||||
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
|
||||
client := NewClient(matrixBaseURL)
|
||||
discovery, err := client.DiscoverEndpoints()
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -12,6 +14,9 @@ import (
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
var errPostNotFound = fmt.Errorf("pinboard: post not found")
|
||||
var errMissingCredentials = fmt.Errorf("pinboard: missing auth token")
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
@@ -24,20 +29,27 @@ func NewClient(authToken string) *Client {
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
|
||||
if c.authToken == "" {
|
||||
return fmt.Errorf("pinboard: missing auth token")
|
||||
return errMissingCredentials
|
||||
}
|
||||
|
||||
toRead := "no"
|
||||
// We check if the url is already bookmarked to avoid overriding existing data.
|
||||
post, err := c.getBookmark(entryURL)
|
||||
|
||||
if err != nil && errors.Is(err, errPostNotFound) {
|
||||
post = NewPost(entryURL, entryTitle)
|
||||
} else if err != nil {
|
||||
// In case of any other error, we return immediately to avoid overriding existing data.
|
||||
return err
|
||||
}
|
||||
|
||||
post.addTag(pinboardTags)
|
||||
if markAsUnread {
|
||||
toRead = "yes"
|
||||
post.SetToread()
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("auth_token", c.authToken)
|
||||
values.Add("url", entryURL)
|
||||
values.Add("description", entryTitle)
|
||||
values.Add("tags", pinboardTags)
|
||||
values.Add("toread", toRead)
|
||||
post.AddValues(values)
|
||||
|
||||
apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
|
||||
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
|
||||
@@ -61,3 +73,46 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBookmark fetches a bookmark from Pinboard. https://www.pinboard.in/api/#posts_get
|
||||
func (c *Client) getBookmark(entryURL string) (*Post, error) {
|
||||
if c.authToken == "" {
|
||||
return nil, errMissingCredentials
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("auth_token", c.authToken)
|
||||
values.Add("url", entryURL)
|
||||
|
||||
apiEndpoint := "https://api.pinboard.in/v1/posts/get?" + values.Encode()
|
||||
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("pinboard: unable to fetch bookmark, status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
var results posts
|
||||
err = xml.NewDecoder(response.Body).Decode(&results)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable to decode XML: %v", err)
|
||||
}
|
||||
|
||||
if len(results.Posts) == 0 {
|
||||
return nil, errPostNotFound
|
||||
}
|
||||
|
||||
return &results.Posts[0], nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
|
||||
type Post struct {
|
||||
XMLName xml.Name `xml:"post"`
|
||||
Url string `xml:"href,attr"`
|
||||
Description string `xml:"description,attr"`
|
||||
Tags string `xml:"tag,attr"`
|
||||
Extended string `xml:"extended,attr"`
|
||||
Date time.Time `xml:"time,attr"`
|
||||
Shared string `xml:"shared,attr"`
|
||||
Toread string `xml:"toread,attr"`
|
||||
}
|
||||
|
||||
// Posts A result of a Pinboard API call
|
||||
type posts struct {
|
||||
XMLName xml.Name `xml:"posts"`
|
||||
Posts []Post `xml:"post"`
|
||||
}
|
||||
|
||||
func NewPost(url string, description string) *Post {
|
||||
return &Post{
|
||||
Url: url,
|
||||
Description: description,
|
||||
Date: time.Now(),
|
||||
Toread: "no",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Post) addTag(tag string) {
|
||||
if !strings.Contains(p.Tags, tag) {
|
||||
p.Tags += " " + tag
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Post) SetToread() {
|
||||
p.Toread = "yes"
|
||||
}
|
||||
|
||||
func (p *Post) AddValues(values url.Values) {
|
||||
values.Add("url", p.Url)
|
||||
values.Add("description", p.Description)
|
||||
values.Add("tags", p.Tags)
|
||||
if p.Toread != "" {
|
||||
values.Add("toread", p.Toread)
|
||||
}
|
||||
if p.Shared != "" {
|
||||
values.Add("shared", p.Shared)
|
||||
}
|
||||
values.Add("dt", p.Date.Format(time.RFC3339))
|
||||
values.Add("extended", p.Extended)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
collectionID string
|
||||
tags []string
|
||||
}
|
||||
|
||||
func NewClient(token, collectionID, tags string) *Client {
|
||||
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
|
||||
}
|
||||
|
||||
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
|
||||
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
if c.token == "" {
|
||||
return fmt.Errorf("raindrop: missing token")
|
||||
}
|
||||
|
||||
var request *http.Request
|
||||
requestBodyJson, err := json.Marshal(&raindrop{
|
||||
Link: entryURL,
|
||||
Title: entryTitle,
|
||||
Collection: collection{Id: c.collectionID},
|
||||
Tags: c.tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type collection struct {
|
||||
Id string `json:"$id"`
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
|
||||
formattedText := fmt.Sprintf(
|
||||
`<a href=%q>%s</a>`,
|
||||
`<b>%s</b> - <a href=%q>%s</a>`,
|
||||
feed.Title,
|
||||
entry.URL,
|
||||
entry.Title,
|
||||
)
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Suche",
|
||||
"search.placeholder": "Suche...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Nächste",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Vorherige",
|
||||
"entry.status.unread": "Ungelesen",
|
||||
"entry.status.read": "Gelesen",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
|
||||
"alert.no_category": "Es ist keine Kategorie vorhanden.",
|
||||
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
|
||||
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
|
||||
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
|
||||
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
|
||||
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
|
||||
@@ -299,6 +302,14 @@
|
||||
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
|
||||
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
|
||||
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
|
||||
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
|
||||
"error.feed_already_exists": "Dieser Feed existiert bereits.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Titel",
|
||||
"form.feed.label.site_url": "URL der Webseite",
|
||||
"form.feed.label.feed_url": "URL des Abonnements",
|
||||
"form.feed.label.description": "Beschreibung",
|
||||
"form.feed.label.category": "Kategorie",
|
||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||
"form.feed.label.feed_username": "Benutzername des Abonnements",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML Datei",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Fever API aktivieren",
|
||||
"form.integration.fever_username": "Fever Benutzername",
|
||||
"form.integration.fever_password": "Fever Passwort",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
|
||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs"
|
||||
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Αναζήτηση",
|
||||
"search.placeholder": "Αναζήτηση...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Επόμενη",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Προηγούμενη",
|
||||
"entry.status.unread": "Μη αναγνωσμένο",
|
||||
"entry.status.read": "Αναγνωσμένο",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
||||
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
||||
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
|
||||
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
|
||||
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
||||
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
|
||||
@@ -299,6 +302,14 @@
|
||||
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
|
||||
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
|
||||
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
|
||||
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
|
||||
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
|
||||
@@ -318,6 +329,7 @@
|
||||
"form.feed.label.title": "Τίτλος",
|
||||
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
|
||||
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
|
||||
"form.feed.label.description": "Περιγραφή",
|
||||
"form.feed.label.category": "Κατηγορία",
|
||||
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
||||
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "Αρχείο OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Ενεργοποιήστε το Fever API",
|
||||
"form.integration.fever_username": "Όνομα Χρήστη Fever",
|
||||
"form.integration.fever_password": "Κωδικός Πρόσβασης Fever",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
|
||||
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους"
|
||||
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Search",
|
||||
"search.placeholder": "Search…",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Next",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Previous",
|
||||
"entry.status.unread": "Unread",
|
||||
"entry.status.read": "Read",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "There are no starred entries.",
|
||||
"alert.no_category": "There is no category.",
|
||||
"alert.no_category_entry": "There are no entries in this category.",
|
||||
"alert.no_tag_entry": "There are no entries matching this tag.",
|
||||
"alert.no_feed_entry": "There are no entries for this feed.",
|
||||
"alert.no_feed": "You don’t have any feeds.",
|
||||
"alert.no_feed_in_category": "There is no feed for this category.",
|
||||
@@ -299,6 +302,14 @@
|
||||
"error.password_min_length": "The password must have at least 6 characters.",
|
||||
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
|
||||
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "The number of entries per page is not valid.",
|
||||
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
|
||||
"error.feed_already_exists": "This feed already exists.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Title",
|
||||
"form.feed.label.site_url": "Site URL",
|
||||
"form.feed.label.feed_url": "Feed URL",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.category": "Category",
|
||||
"form.feed.label.crawler": "Fetch original content",
|
||||
"form.feed.label.feed_username": "Feed Username",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML file",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Activate Fever API",
|
||||
"form.integration.fever_username": "Fever Username",
|
||||
"form.integration.fever_password": "Fever Password",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||
"form.integration.matrix_bot_url": "Matrix server URL",
|
||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Save entries to readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
|
||||
"error.settings_media_playback_rate_range": "Playback speed is out of range"
|
||||
"error.settings_media_playback_rate_range": "Playback speed is out of range",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Búsqueda...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Ultimo",
|
||||
"pagination.next": "Siguiente",
|
||||
"pagination.first": "Primero",
|
||||
"pagination.previous": "Anterior",
|
||||
"entry.status.unread": "No leído",
|
||||
"entry.status.read": "Leído",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "No hay marcador en este momento.",
|
||||
"alert.no_category": "No hay categoría.",
|
||||
"alert.no_category_entry": "No hay artículos en esta categoría.",
|
||||
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
|
||||
"alert.no_feed_entry": "No hay artículos para esta fuente.",
|
||||
"alert.no_feed": "No tienes fuentes.",
|
||||
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
|
||||
@@ -292,6 +295,14 @@
|
||||
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
|
||||
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
|
||||
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "El número de artículos por página no es válido.",
|
||||
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
|
||||
"error.feed_already_exists": "Este feed ya existe.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.site_url": "URL del sitio",
|
||||
"form.feed.label.feed_url": "URL de la fuente",
|
||||
"form.feed.label.description": "Descripción",
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.crawler": "Obtener rastreador original",
|
||||
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "Archivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Activar API de Fever",
|
||||
"form.integration.fever_username": "Nombre de usuario de Fever",
|
||||
"form.integration.fever_password": "Contraseña de Fever",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
||||
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
|
||||
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango"
|
||||
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Haku",
|
||||
"search.placeholder": "Hae...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Seuraava",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Edellinen",
|
||||
"entry.status.unread": "Lukematon",
|
||||
"entry.status.read": "Luettu",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||
"alert.no_category": "Ei ole kategoriaa.",
|
||||
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
|
||||
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
|
||||
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
|
||||
"alert.no_feed": "Sinulla ei ole tilauksia.",
|
||||
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
|
||||
@@ -299,6 +302,14 @@
|
||||
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
|
||||
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
|
||||
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
|
||||
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
|
||||
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
|
||||
@@ -318,6 +329,7 @@
|
||||
"form.feed.label.title": "Otsikko",
|
||||
"form.feed.label.site_url": "Sivuston URL-osoite",
|
||||
"form.feed.label.feed_url": "Syötteen URL-osoite",
|
||||
"form.feed.label.description": "Kuvaus",
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
|
||||
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML-tiedosto",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Ota Fever API käyttöön",
|
||||
"form.integration.fever_username": "Fever-käyttäjätunnus",
|
||||
"form.integration.fever_password": "Fever-salasana",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
||||
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
||||
"form.integration.readeck_api_key": "Readeck API-avain",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
|
||||
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella"
|
||||
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Recherche",
|
||||
"search.placeholder": "Recherche...",
|
||||
"search.submit": "Rechercher",
|
||||
"pagination.last": "Dernière page",
|
||||
"pagination.next": "Suivant",
|
||||
"pagination.first": "Première page",
|
||||
"pagination.previous": "Précédent",
|
||||
"entry.status.unread": "Non lu",
|
||||
"entry.status.read": "Lu",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
|
||||
"alert.no_category": "Il n'y a aucune catégorie.",
|
||||
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
|
||||
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
|
||||
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
|
||||
"alert.no_feed": "Vous n'avez aucun abonnement.",
|
||||
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
|
||||
@@ -292,6 +295,14 @@
|
||||
"error.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
|
||||
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
|
||||
"error.settings_reading_speed_is_positive": "Les vitesses de lecture doivent être des entiers positifs.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Règle de blocage invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
|
||||
"error.settings_block_rule_separator_required": "Règle de blocage invalide : le motif de la règle n°%d doit être séparé par un '='",
|
||||
"error.settings_block_rule_regex_required": "Règle de blocage invalide : le motif de la règle n°%d n'est pas fourni",
|
||||
"error.settings_block_rule_invalid_regex": "Règle de blocage invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Règle de conservation invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
|
||||
"error.settings_keep_rule_separator_required": "Règle de conservation invalide : le motif de la règle n°%d doit être séparé par un '='",
|
||||
"error.settings_keep_rule_regex_required": "Règle de conservation invalide : le motif de la règle n°%d n'est pas fourni",
|
||||
"error.settings_keep_rule_invalid_regex": "Règle de conservation invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
|
||||
"error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.",
|
||||
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
|
||||
"error.feed_already_exists": "Ce flux existe déjà.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Titre",
|
||||
"form.feed.label.site_url": "URL du site web",
|
||||
"form.feed.label.feed_url": "URL du flux",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.category": "Catégorie",
|
||||
"form.feed.label.crawler": "Récupérer le contenu original",
|
||||
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Paramètres de l'application",
|
||||
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
|
||||
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
|
||||
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
|
||||
"form.import.label.file": "Fichier OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Sauvegarder les entrées dans Betula",
|
||||
"form.integration.betula_url": "URL du serveur Betula",
|
||||
"form.integration.betula_token": "Jeton de sécurité de l'API de Betula",
|
||||
"form.integration.fever_activate": "Activer l'API de Fever",
|
||||
"form.integration.fever_username": "Nom d'utilisateur pour l'API de Fever",
|
||||
"form.integration.fever_password": "Mot de passe pour l'API de Fever",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||
"form.integration.matrix_bot_url": "URL du serveur Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
|
||||
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
|
||||
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites"
|
||||
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
|
||||
"enclosure_media_controls.seek" : "Avancer/Reculer :",
|
||||
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
|
||||
"enclosure_media_controls.speed" : "Vitesse :",
|
||||
"enclosure_media_controls.speed.faster" : "Accélérer",
|
||||
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Ralentir",
|
||||
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Réinitialiser",
|
||||
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "खोजे",
|
||||
"search.placeholder": "खोजे...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "अगला",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "पिछला",
|
||||
"entry.status.unread": "अपठित",
|
||||
"entry.status.read": "पढ़े",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_category": "कोई श्रेणी नहीं है।",
|
||||
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
|
||||
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
||||
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
|
||||
@@ -299,6 +302,14 @@
|
||||
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
|
||||
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
|
||||
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
|
||||
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
|
||||
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "शीर्षक",
|
||||
"form.feed.label.site_url": "साइट यूआरएल",
|
||||
"form.feed.label.feed_url": "फ़ीड यूआरएल",
|
||||
"form.feed.label.description": "विवरण",
|
||||
"form.feed.label.category": "श्रेणी",
|
||||
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
||||
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "ओपीएमएल फ़ाइल",
|
||||
"form.import.label.url": "यूआरएल",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "फीवर एपीआई सक्रिय करें",
|
||||
"form.integration.fever_username": "फीवर उपयोगकर्ता नाम",
|
||||
"form.integration.fever_password": "फीवर पासवर्ड",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
|
||||
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है"
|
||||
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
"search.placeholder": "Cari...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Berikutnya",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Sebelumnya",
|
||||
"entry.status.unread": "Belum dibaca",
|
||||
"entry.status.read": "Telah dibaca",
|
||||
@@ -248,6 +250,7 @@
|
||||
"alert.no_bookmark": "Tidak ada markah.",
|
||||
"alert.no_category": "Tidak ada kategori.",
|
||||
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
|
||||
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
|
||||
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
|
||||
"alert.no_feed": "Anda tidak memiliki langganan.",
|
||||
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
|
||||
@@ -289,6 +292,14 @@
|
||||
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.",
|
||||
"error.settings_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
|
||||
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
|
||||
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
|
||||
"error.feed_already_exists": "Umpan ini sudah ada.",
|
||||
@@ -306,6 +317,7 @@
|
||||
"form.feed.label.title": "Judul",
|
||||
"form.feed.label.site_url": "URL Situs",
|
||||
"form.feed.label.feed_url": "URL Umpan",
|
||||
"form.feed.label.description": "Deskripsi",
|
||||
"form.feed.label.category": "Kategori",
|
||||
"form.feed.label.crawler": "Ambil konten asli",
|
||||
"form.feed.label.feed_username": "Nama Pengguna Umpan",
|
||||
@@ -368,8 +380,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "Berkas OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Aktifkan API Fever",
|
||||
"form.integration.fever_username": "Nama Pengguna Fever",
|
||||
"form.integration.fever_password": "Kata Sandi Fever",
|
||||
@@ -441,6 +457,10 @@
|
||||
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
||||
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
||||
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
||||
"form.integration.readeck_api_key": "Kunci API Readeck",
|
||||
@@ -511,5 +531,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
|
||||
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan"
|
||||
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
"search.placeholder": "Cerca...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Successivo",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Precedente",
|
||||
"entry.status.unread": "Da leggere",
|
||||
"entry.status.read": "Letto",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Nessun preferito disponibile.",
|
||||
"alert.no_category": "Nessuna categoria disponibile.",
|
||||
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
|
||||
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
|
||||
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
|
||||
"alert.no_feed": "Nessun feed disponibile.",
|
||||
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
|
||||
@@ -292,6 +295,14 @@
|
||||
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
|
||||
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
|
||||
"error.settings_reading_speed_is_positive": "Le velocità di lettura devono essere numeri interi positivi.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
|
||||
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
|
||||
"error.feed_already_exists": "Questo feed esiste già.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Titolo",
|
||||
"form.feed.label.site_url": "URL del sito",
|
||||
"form.feed.label.feed_url": "URL del feed",
|
||||
"form.feed.label.description": "Descrizione",
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.crawler": "Scarica il contenuto integrale",
|
||||
"form.feed.label.feed_username": "Nome utente del feed",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "File OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Abilita l'API di Fever",
|
||||
"form.integration.fever_username": "Nome utente dell'account Fever",
|
||||
"form.integration.fever_password": "Password dell'account Fever",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del server Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
|
||||
"form.integration.readeck_api_key": "API key dell'account Readeck",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
|
||||
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo"
|
||||
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "検索",
|
||||
"search.placeholder": "…を検索",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "次",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "前",
|
||||
"entry.status.unread": "未読にする",
|
||||
"entry.status.read": "既読にする",
|
||||
@@ -248,6 +250,7 @@
|
||||
"alert.no_bookmark": "現在星付きはありません。",
|
||||
"alert.no_category": "カテゴリが存在しません。",
|
||||
"alert.no_category_entry": "このカテゴリには記事がありません。",
|
||||
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
|
||||
"alert.no_feed_entry": "このフィードには記事がありません。",
|
||||
"alert.no_feed": "何も購読していません。",
|
||||
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
|
||||
@@ -289,6 +292,14 @@
|
||||
"error.password_min_length": "パスワードは6文字以上である必要があります。",
|
||||
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。",
|
||||
"error.settings_reading_speed_is_positive": "読書速度は正の整数である必要があります。",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "ページあたりの記事数が無効です。",
|
||||
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
|
||||
"error.feed_already_exists": "このフィードは既に存在します。",
|
||||
@@ -306,6 +317,7 @@
|
||||
"form.feed.label.title": "タイトル",
|
||||
"form.feed.label.site_url": "サイト URL",
|
||||
"form.feed.label.feed_url": "フィード URL",
|
||||
"form.feed.label.description": "説明",
|
||||
"form.feed.label.category": "カテゴリ",
|
||||
"form.feed.label.crawler": "オリジナルの内容を取得",
|
||||
"form.feed.label.feed_username": "フィードのユーザー名",
|
||||
@@ -368,8 +380,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML ファイル",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Fever API を有効にする",
|
||||
"form.integration.fever_username": "Fever のユーザー名",
|
||||
"form.integration.fever_password": "Fever のパスワード",
|
||||
@@ -441,6 +457,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
||||
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
|
||||
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck に記事を保存する",
|
||||
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck の API key",
|
||||
@@ -511,5 +531,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
|
||||
"error.settings_media_playback_rate_range": "再生速度が範囲外"
|
||||
"error.settings_media_playback_rate_range": "再生速度が範囲外",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Zoeken",
|
||||
"search.placeholder": "Zoeken...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Volgende",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Vorige",
|
||||
"entry.status.unread": "Ongelezen",
|
||||
"entry.status.read": "Gelezen",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
|
||||
"alert.no_category": "Er zijn geen categorieën.",
|
||||
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
|
||||
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
|
||||
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
|
||||
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
|
||||
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
|
||||
@@ -292,6 +295,14 @@
|
||||
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
|
||||
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
|
||||
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
|
||||
"error.feed_already_exists": "Deze feed bestaat al.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Naam",
|
||||
"form.feed.label.site_url": "Website URL",
|
||||
"form.feed.label.feed_url": "Feed URL",
|
||||
"form.feed.label.description": "Beschrijving",
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.crawler": "Download originele content",
|
||||
"form.feed.label.feed_username": "Feed-gebruikersnaam",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML-bestand",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Activeer Fever API",
|
||||
"form.integration.fever_username": "Fever gebruikersnaam",
|
||||
"form.integration.fever_password": "Fever wachtwoord",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
||||
"form.integration.matrix_bot_url": "URL van de Matrix-server",
|
||||
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Opslaan naar Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
|
||||
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik"
|
||||
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Szukaj",
|
||||
"search.placeholder": "Szukaj...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Ostatni",
|
||||
"pagination.next": "Następny",
|
||||
"pagination.first": "Pierwszy",
|
||||
"pagination.previous": "Poprzedni",
|
||||
"entry.status.unread": "Nieprzeczytane",
|
||||
"entry.status.read": "Przeczytane",
|
||||
@@ -268,6 +270,7 @@
|
||||
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
|
||||
"alert.no_category": "Nie ma żadnej kategorii!",
|
||||
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
|
||||
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
|
||||
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
|
||||
"alert.no_feed": "Nie masz żadnej subskrypcji.",
|
||||
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
|
||||
@@ -302,6 +305,14 @@
|
||||
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
|
||||
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
|
||||
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
|
||||
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
|
||||
"error.feed_already_exists": "Ten kanał już istnieje.",
|
||||
@@ -326,6 +337,7 @@
|
||||
"form.feed.label.title": "Tytuł",
|
||||
"form.feed.label.site_url": "URL strony",
|
||||
"form.feed.label.feed_url": "URL kanału",
|
||||
"form.feed.label.description": "Opis",
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.crawler": "Pobierz oryginalną treść",
|
||||
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
|
||||
@@ -388,8 +400,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "Plik OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Aktywuj Fever API",
|
||||
"form.integration.fever_username": "Login do Fever",
|
||||
"form.integration.fever_password": "Hasło do Fever",
|
||||
@@ -461,6 +477,10 @@
|
||||
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
||||
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
@@ -545,5 +565,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
|
||||
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem"
|
||||
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Buscar por...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Próximo",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Anterior",
|
||||
"entry.status.unread": "Não lido",
|
||||
"entry.status.read": "Lido",
|
||||
@@ -258,6 +260,7 @@
|
||||
"alert.no_bookmark": "Não há favorito neste momento.",
|
||||
"alert.no_category": "Não há categoria.",
|
||||
"alert.no_category_entry": "Não há itens nesta categoria.",
|
||||
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
|
||||
"alert.no_feed_entry": "Não há itens nessa fonte.",
|
||||
"alert.no_feed": "Não há inscrições.",
|
||||
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
|
||||
@@ -292,6 +295,14 @@
|
||||
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.",
|
||||
"error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.",
|
||||
"error.settings_reading_speed_is_positive": "As velocidades de leitura devem ser inteiros positivos.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "O número de itens por página é inválido.",
|
||||
"error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
|
||||
"error.feed_already_exists": "Este feed já existe.",
|
||||
@@ -316,6 +327,7 @@
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.site_url": "URL do site",
|
||||
"form.feed.label.feed_url": "URL da fonte",
|
||||
"form.feed.label.description": "Descrição",
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.crawler": "Obter conteúdo original",
|
||||
"form.feed.label.feed_username": "Nome de usuário da fonte",
|
||||
@@ -378,8 +390,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "Arquivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Ativar API do Fever",
|
||||
"form.integration.fever_username": "Nome de usuário do Fever",
|
||||
"form.integration.fever_password": "Senha do Fever",
|
||||
@@ -451,6 +467,10 @@
|
||||
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Salvar itens no Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
|
||||
"form.integration.readeck_api_key": "Chave de API do Readeck",
|
||||
@@ -528,5 +548,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
|
||||
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo"
|
||||
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Поиск",
|
||||
"search.placeholder": "Поиск…",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Следующая",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Предыдущая",
|
||||
"entry.status.unread": "Не прочитано",
|
||||
"entry.status.read": "Прочитано",
|
||||
@@ -268,6 +270,7 @@
|
||||
"alert.no_bookmark": "Избранное отсутствует.",
|
||||
"alert.no_category": "Категории отсутствуют.",
|
||||
"alert.no_category_entry": "В этой категории нет статей.",
|
||||
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
|
||||
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
|
||||
"alert.no_feed": "У вас нет ни одной подписки.",
|
||||
"alert.no_feed_in_category": "Для этой категории нет подписки.",
|
||||
@@ -302,6 +305,14 @@
|
||||
"error.password_min_length": "Вы должны использовать минимум 6 символов.",
|
||||
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
|
||||
"error.settings_reading_speed_is_positive": "Скорость чтения должна быть целым положительным числом.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Недопустимое значение количества записей на странице.",
|
||||
"error.feed_mandatory_fields": "Ссылка и категория обязательны.",
|
||||
"error.feed_already_exists": "Эта подписка уже существует.",
|
||||
@@ -326,6 +337,7 @@
|
||||
"form.feed.label.title": "Название",
|
||||
"form.feed.label.site_url": "Адрес сайта",
|
||||
"form.feed.label.feed_url": "Адрес подписки",
|
||||
"form.feed.label.description": "Описание",
|
||||
"form.feed.label.category": "Категория",
|
||||
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
|
||||
"form.feed.label.feed_username": "Имя пользователя подписки",
|
||||
@@ -388,8 +400,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML файл",
|
||||
"form.import.label.url": "Ссылка",
|
||||
"form.integration.betula_activate": "Сохранять статьи в Бетулу",
|
||||
"form.integration.betula_url": "Адрес сервера Бетулы",
|
||||
"form.integration.betula_token": "Токен Бетулы",
|
||||
"form.integration.fever_activate": "Активировать Fever API",
|
||||
"form.integration.fever_username": "Имя пользователя Fever",
|
||||
"form.integration.fever_password": "Пароль Fever",
|
||||
@@ -461,6 +477,10 @@
|
||||
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
||||
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
|
||||
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
|
||||
"form.integration.readeck_api_key": "API-ключ Readeck",
|
||||
@@ -545,5 +565,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
|
||||
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона"
|
||||
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"alert.no_bookmark": "Yıldızlanmış makale yok.",
|
||||
"alert.no_category": "Hiç kategori yok.",
|
||||
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
|
||||
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
|
||||
"alert.no_feed": "Hiç beslemeniz yok.",
|
||||
"alert.no_feed_entry": "Bu besleme için makele yok.",
|
||||
"alert.no_feed_in_category": "Bu kategori için besleme yok.",
|
||||
@@ -121,6 +122,14 @@
|
||||
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
|
||||
"error.settings_media_playback_rate_range": "Oynatma hızı aralık dışında",
|
||||
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.site_url_not_empty": "Site URL'si boş olamaz.",
|
||||
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
|
||||
"error.title_required": "Başlık zorunlu.",
|
||||
@@ -153,6 +162,7 @@
|
||||
"form.feed.label.disabled": "Bu beslemeyi yenileme",
|
||||
"form.feed.label.feed_password": "Besleme Parolası",
|
||||
"form.feed.label.feed_url": "Besleme URL'si",
|
||||
"form.feed.label.description": "Açıklama",
|
||||
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
|
||||
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
|
||||
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
|
||||
@@ -167,6 +177,9 @@
|
||||
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
|
||||
"form.import.label.file": "OPML dosyası",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_services_url": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
@@ -221,6 +234,10 @@
|
||||
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
|
||||
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
|
||||
"form.integration.readeck_api_key": "Readeck API Anahtarı",
|
||||
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
|
||||
@@ -258,6 +275,7 @@
|
||||
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
|
||||
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
|
||||
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
|
||||
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
|
||||
"form.prefs.label.custom_css": "Özel CSS",
|
||||
@@ -479,7 +497,9 @@
|
||||
"page.users.title": "Kullanıcılar",
|
||||
"page.users.username": "Kullanıcı adı",
|
||||
"page.webauthn_rename.title": "Passkey'i Yeniden Adlandır",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Sonraki",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Önceki",
|
||||
"search.label": "Ara",
|
||||
"search.placeholder": "Ara...",
|
||||
@@ -495,5 +515,14 @@
|
||||
"time_elapsed.years": ["%d yıl önce", "%d yıl önce"],
|
||||
"time_elapsed.yesterday": "dün",
|
||||
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
|
||||
"tooltip.logged_user": "%s olarak giriş yapıldı"
|
||||
"tooltip.logged_user": "%s olarak giriş yapıldı",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "Пошук",
|
||||
"search.placeholder": "Шукати...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Вперед",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Назад",
|
||||
"entry.status.unread": "Непрочитане",
|
||||
"entry.status.read": "Прочитане",
|
||||
@@ -268,6 +270,7 @@
|
||||
"alert.no_bookmark": "Наразі закладки відсутні.",
|
||||
"alert.no_category": "Немає категорії.",
|
||||
"alert.no_category_entry": "У цій категорії немає записів.",
|
||||
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
|
||||
"alert.no_feed_entry": "У цій стрічці немає записів.",
|
||||
"alert.no_feed": "У вас немає підписок.",
|
||||
"alert.no_feed_in_category": "У цій категорії немає підписок.",
|
||||
@@ -309,6 +312,14 @@
|
||||
"error.password_min_length": "Пароль має складати щонайменше 6 символів.",
|
||||
"error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.",
|
||||
"error.settings_reading_speed_is_positive": "Швидкість читання має бути додатнім цілим числом.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Число записів на сторінку недійсне.",
|
||||
"error.feed_mandatory_fields": "URL та категорія є обов’язковими.",
|
||||
"error.feed_already_exists": "Така стрічка вже існує.",
|
||||
@@ -326,6 +337,7 @@
|
||||
"form.feed.label.title": "Назва",
|
||||
"form.feed.label.site_url": "URL-адреса сайту",
|
||||
"form.feed.label.feed_url": "URL-адреса стрічки",
|
||||
"form.feed.label.description": "Опис",
|
||||
"form.feed.label.category": "Категорія",
|
||||
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
|
||||
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
|
||||
@@ -388,8 +400,12 @@
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "Файл OPML",
|
||||
"form.import.label.url": "URL-адреса",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Увімкнути API Fever",
|
||||
"form.integration.fever_username": "Ім’я користувача Fever",
|
||||
"form.integration.fever_password": "Пароль Fever",
|
||||
@@ -461,6 +477,10 @@
|
||||
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
||||
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
|
||||
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||
@@ -545,5 +565,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
|
||||
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону"
|
||||
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "搜索",
|
||||
"search.placeholder": "搜索…",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "下一页",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "上一页",
|
||||
"entry.status.unread": "标为未读",
|
||||
"entry.status.read": "标为已读",
|
||||
@@ -248,6 +250,7 @@
|
||||
"alert.no_bookmark": "目前没有收藏",
|
||||
"alert.no_category": "目前没有分类",
|
||||
"alert.no_category_entry": "该分类下没有文章",
|
||||
"alert.no_tag_entry": "没有与此标签匹配的条目。",
|
||||
"alert.no_feed_entry": "该源中没有文章",
|
||||
"alert.no_feed": "目前没有源",
|
||||
"alert.no_history": "目前没有历史",
|
||||
@@ -290,6 +293,14 @@
|
||||
"error.site_url_not_empty": "源网站的网址不能为空。",
|
||||
"error.feed_title_not_empty": "订阅源的标题不能为空。",
|
||||
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.feed_category_not_found": "此类别不存在或不属于该用户。",
|
||||
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
|
||||
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
|
||||
@@ -306,6 +317,7 @@
|
||||
"form.feed.label.title": "标题",
|
||||
"form.feed.label.site_url": "源网站 URL",
|
||||
"form.feed.label.feed_url": "订阅源 URL",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.category": "类别",
|
||||
"form.feed.label.crawler": "抓取全文内容",
|
||||
"form.feed.label.feed_username": "源用户名",
|
||||
@@ -368,8 +380,12 @@
|
||||
"form.prefs.fieldset.application_settings": "应用设置",
|
||||
"form.prefs.fieldset.authentication_settings": "用户认证设置",
|
||||
"form.prefs.fieldset.reader_settings": "阅读器设置",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML 文件",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "启用 Fever API",
|
||||
"form.integration.fever_username": "Fever 用户名",
|
||||
"form.integration.fever_password": "Fever 密码",
|
||||
@@ -441,6 +457,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
||||
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "保存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端点",
|
||||
"form.integration.readeck_api_key": "Readeck API 密钥",
|
||||
@@ -511,5 +531,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
|
||||
"error.settings_media_playback_rate_range": "播放速度超出范围"
|
||||
"error.settings_media_playback_rate_range": "播放速度超出范围",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
"search.label": "搜尋",
|
||||
"search.placeholder": "搜尋…",
|
||||
"search.submit": "送出",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "下一頁",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "上一頁",
|
||||
"entry.status.unread": "標為未讀",
|
||||
"entry.status.read": "標為已讀",
|
||||
@@ -248,6 +250,7 @@
|
||||
"alert.no_bookmark": "目前沒有收藏",
|
||||
"alert.no_category": "目前沒有分類",
|
||||
"alert.no_category_entry": "該分類下沒有文章",
|
||||
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
|
||||
"alert.no_feed_entry": "該Feed中沒有文章",
|
||||
"alert.no_feed": "目前沒有Feed",
|
||||
"alert.no_history": "目前沒有歷史",
|
||||
@@ -282,6 +285,14 @@
|
||||
"error.password_min_length": "請至少輸入 6 個字元",
|
||||
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
|
||||
"error.settings_reading_speed_is_positive": "閱讀速度必須是正整數。",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "每頁的文章數無效。",
|
||||
"error.feed_mandatory_fields": "必須填寫網址和分類",
|
||||
"error.feed_already_exists": "此Feed已存在。",
|
||||
@@ -306,6 +317,7 @@
|
||||
"form.feed.label.title": "標題",
|
||||
"form.feed.label.site_url": "網站 URL",
|
||||
"form.feed.label.feed_url": "訂閱 Feed URL",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.category": "類別",
|
||||
"form.feed.label.crawler": "下載原文內容",
|
||||
"form.feed.label.feed_username": "Feed 使用者名稱",
|
||||
@@ -368,8 +380,12 @@
|
||||
"form.prefs.fieldset.application_settings": "應用程式設定",
|
||||
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
|
||||
"form.prefs.fieldset.reader_settings": "閱讀器設定",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.import.label.file": "OPML 檔案",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "啟用 Fever API",
|
||||
"form.integration.fever_username": "Fever 使用者名稱",
|
||||
"form.integration.fever_password": "Fever 密碼",
|
||||
@@ -441,6 +457,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrix 的密碼",
|
||||
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "儲存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端點",
|
||||
"form.integration.readeck_api_key": "Readeck API 金鑰",
|
||||
@@ -511,5 +531,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
|
||||
"error.settings_media_playback_rate_range": "播放速度超出範圍"
|
||||
"error.settings_media_playback_rate_range": "播放速度超出範圍",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
NextCheckAt time.Time `json:"next_check_at"`
|
||||
EtagHeader string `json:"etag_header"`
|
||||
@@ -167,6 +168,7 @@ type FeedModificationRequest struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
BlocklistRules *string `json:"blocklist_rules"`
|
||||
@@ -201,6 +203,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
|
||||
feed.Title = *f.Title
|
||||
}
|
||||
|
||||
if f.Description != nil && *f.Description != "" {
|
||||
feed.Description = *f.Description
|
||||
}
|
||||
|
||||
if f.ScraperRules != nil {
|
||||
feed.ScraperRules = *f.ScraperRules
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ package model // import "miniflux.app/v2/internal/model"
|
||||
// Integration represents user integration settings.
|
||||
type Integration struct {
|
||||
UserID int64
|
||||
BetulaEnabled bool
|
||||
BetulaURL string
|
||||
BetulaToken string
|
||||
PinboardEnabled bool
|
||||
PinboardToken string
|
||||
PinboardTags string
|
||||
@@ -90,4 +93,8 @@ type Integration struct {
|
||||
OmnivoreEnabled bool
|
||||
OmnivoreAPIKey string
|
||||
OmnivoreURL string
|
||||
RaindropEnabled bool
|
||||
RaindropToken string
|
||||
RaindropCollectionID string
|
||||
RaindropTags string
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ type User struct {
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
}
|
||||
|
||||
// UserCreationRequest represents the request to create a user.
|
||||
@@ -72,6 +74,8 @@ type UserModificationRequest struct {
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
}
|
||||
|
||||
// Patch updates the User object with the modification request.
|
||||
@@ -167,6 +171,14 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
if u.MediaPlaybackRate != nil {
|
||||
user.MediaPlaybackRate = *u.MediaPlaybackRate
|
||||
}
|
||||
|
||||
if u.BlockFilterEntryRules != nil {
|
||||
user.BlockFilterEntryRules = *u.BlockFilterEntryRules
|
||||
}
|
||||
|
||||
if u.KeepFilterEntryRules != nil {
|
||||
user.KeepFilterEntryRules = *u.KeepFilterEntryRules
|
||||
}
|
||||
}
|
||||
|
||||
// UseTimezone converts last login date to the given timezone.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
type brotliReadCloser struct {
|
||||
body io.ReadCloser
|
||||
brotliReader io.Reader
|
||||
}
|
||||
|
||||
func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {
|
||||
return &brotliReadCloser{
|
||||
body: body,
|
||||
brotliReader: brotli.NewReader(body),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *brotliReadCloser) Read(p []byte) (n int, err error) {
|
||||
return b.brotliReader.Read(p)
|
||||
}
|
||||
|
||||
func (b *brotliReadCloser) Close() error {
|
||||
return b.body.Close()
|
||||
}
|
||||
|
||||
type gzipReadCloser struct {
|
||||
body io.ReadCloser
|
||||
gzipReader io.Reader
|
||||
gzipErr error
|
||||
}
|
||||
|
||||
func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {
|
||||
return &gzipReadCloser{body: body}
|
||||
}
|
||||
|
||||
func (gz *gzipReadCloser) Read(p []byte) (n int, err error) {
|
||||
if gz.gzipReader == nil {
|
||||
if gz.gzipErr == nil {
|
||||
gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)
|
||||
}
|
||||
if gz.gzipErr != nil {
|
||||
return 0, gz.gzipErr
|
||||
}
|
||||
}
|
||||
|
||||
return gz.gzipReader.Read(p)
|
||||
}
|
||||
|
||||
func (gz *gzipReadCloser) Close() error {
|
||||
return gz.body.Close()
|
||||
}
|
||||
@@ -109,6 +109,16 @@ func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
|
||||
// We get the safe ciphers
|
||||
ciphers := tls.CipherSuites()
|
||||
if r.ignoreTLSErrors {
|
||||
// and the insecure ones if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
|
||||
ciphers = append(ciphers, tls.InsecureCipherSuites()...)
|
||||
}
|
||||
cipherSuites := []uint16{}
|
||||
for _, cipher := range ciphers {
|
||||
cipherSuites = append(cipherSuites, cipher.ID)
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
|
||||
@@ -128,6 +138,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
IdleConnTimeout: 10 * time.Second,
|
||||
|
||||
TLSClientConfig: &tls.Config{
|
||||
CipherSuites: cipherSuites,
|
||||
InsecureSkipVerify: r.ignoreTLSErrors,
|
||||
},
|
||||
}
|
||||
@@ -169,6 +180,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
}
|
||||
|
||||
req.Header = r.headers
|
||||
req.Header.Set("Accept-Encoding", "br, gzip")
|
||||
req.Header.Set("Accept", defaultAcceptHeader)
|
||||
req.Header.Set("Connection", "close")
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/locale"
|
||||
)
|
||||
@@ -54,12 +56,12 @@ func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bo
|
||||
return false
|
||||
}
|
||||
|
||||
if r.ETag() != "" && r.ETag() == lastEtagValue {
|
||||
return false
|
||||
if r.ETag() != "" {
|
||||
return r.ETag() != lastEtagValue
|
||||
}
|
||||
|
||||
if r.LastModified() != "" && r.LastModified() == lastModifiedValue {
|
||||
return false
|
||||
if r.LastModified() != "" {
|
||||
return r.LastModified() != lastModifiedValue
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -71,12 +73,31 @@ func (r *ResponseHandler) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {
|
||||
contentEncoding := strings.ToLower(r.httpResponse.Header.Get("Content-Encoding"))
|
||||
slog.Debug("Request response",
|
||||
slog.String("effective_url", r.EffectiveURL()),
|
||||
slog.String("content_length", r.httpResponse.Header.Get("Content-Length")),
|
||||
slog.String("content_encoding", contentEncoding),
|
||||
slog.String("content_type", r.httpResponse.Header.Get("Content-Type")),
|
||||
)
|
||||
|
||||
reader := r.httpResponse.Body
|
||||
switch contentEncoding {
|
||||
case "br":
|
||||
reader = NewBrotliReadCloser(r.httpResponse.Body)
|
||||
case "gzip":
|
||||
reader = NewGzipReadCloser(r.httpResponse.Body)
|
||||
}
|
||||
return http.MaxBytesReader(nil, reader, maxBodySize)
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
|
||||
return http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
|
||||
return r.getReader(maxBodySize)
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {
|
||||
limitedReader := http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
|
||||
limitedReader := r.getReader(maxBodySize)
|
||||
|
||||
buffer, err := io.ReadAll(limitedReader)
|
||||
if err != nil && err != io.EOF {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsModified(t *testing.T) {
|
||||
var cachedEtag = "abc123"
|
||||
var cachedLastModified = "Wed, 21 Oct 2015 07:28:00 GMT"
|
||||
|
||||
var testCases = map[string]struct {
|
||||
Status int
|
||||
LastModified string
|
||||
ETag string
|
||||
IsModified bool
|
||||
}{
|
||||
"Unmodified 304": {
|
||||
Status: 304,
|
||||
LastModified: cachedLastModified,
|
||||
ETag: cachedEtag,
|
||||
IsModified: false,
|
||||
},
|
||||
"Unmodified 200": {
|
||||
Status: 200,
|
||||
LastModified: cachedLastModified,
|
||||
ETag: cachedEtag,
|
||||
IsModified: false,
|
||||
},
|
||||
// This case is invalid per RFC9110 8.8.1, so ETag takes precedence.
|
||||
"Last-Modified changed only": {
|
||||
Status: 200,
|
||||
LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
|
||||
ETag: cachedEtag,
|
||||
IsModified: false,
|
||||
},
|
||||
"ETag changed only": {
|
||||
Status: 200,
|
||||
LastModified: cachedLastModified,
|
||||
ETag: "xyz789",
|
||||
IsModified: true,
|
||||
},
|
||||
"ETag and Last-Modified changed": {
|
||||
Status: 200,
|
||||
LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
|
||||
ETag: "xyz789",
|
||||
IsModified: true,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(tt *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Add("Last-Modified", tc.LastModified)
|
||||
header.Add("ETag", tc.ETag)
|
||||
rh := ResponseHandler{
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: tc.Status,
|
||||
Header: header,
|
||||
},
|
||||
}
|
||||
if tc.IsModified != rh.IsModified(cachedEtag, cachedLastModified) {
|
||||
tt.Error(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func (h *Handler) Export(userID int64) (string, error) {
|
||||
Title: feed.Title,
|
||||
FeedURL: feed.FeedURL,
|
||||
SiteURL: feed.SiteURL,
|
||||
Description: feed.Description,
|
||||
CategoryName: feed.Category.Title,
|
||||
})
|
||||
}
|
||||
@@ -68,11 +69,12 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
|
||||
}
|
||||
|
||||
feed := &model.Feed{
|
||||
UserID: userID,
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Category: category,
|
||||
UserID: userID,
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
h.store.CreateFeed(feed)
|
||||
|
||||
@@ -27,11 +27,12 @@ type opmlHeader struct {
|
||||
}
|
||||
|
||||
type opmlOutline struct {
|
||||
Title string `xml:"title,attr,omitempty"`
|
||||
Text string `xml:"text,attr"`
|
||||
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
Title string `xml:"title,attr,omitempty"`
|
||||
Text string `xml:"text,attr"`
|
||||
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Description string `xml:"description,attr,omitempty"`
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
|
||||
@@ -34,6 +34,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
|
||||
Title: outline.GetTitle(),
|
||||
FeedURL: outline.FeedURL,
|
||||
SiteURL: outline.GetSiteURL(),
|
||||
Description: outline.Description,
|
||||
CategoryName: category,
|
||||
})
|
||||
} else if outline.Outlines.HasChildren() {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
|
||||
`
|
||||
|
||||
var expected SubcriptionList
|
||||
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/"})
|
||||
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/", Description: "Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media."})
|
||||
|
||||
subscriptions, err := Parse(bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
|
||||
@@ -48,10 +48,11 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
|
||||
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
|
||||
for _, subscription := range groupedSubs[categoryName] {
|
||||
category.Outlines = append(category.Outlines, opmlOutline{
|
||||
Title: subscription.Title,
|
||||
Text: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Title: subscription.Title,
|
||||
Text: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ type Subcription struct {
|
||||
SiteURL string
|
||||
FeedURL string
|
||||
CategoryName string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Equals compare two subscriptions.
|
||||
func (s Subcription) Equals(subscription *Subcription) bool {
|
||||
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
|
||||
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName
|
||||
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
|
||||
s.Description == subscription.Description
|
||||
}
|
||||
|
||||
// SubcriptionList is a list of subscriptions.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -23,10 +24,13 @@ import (
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
|
||||
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
|
||||
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
@@ -42,24 +46,29 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
|
||||
slog.Debug("Processing entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("entry_hash", entry.Hash),
|
||||
slog.String("entry_title", entry.Title),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
)
|
||||
if isBlockedEntry(feed, entry) || !isAllowedEntry(feed, entry) || !isRecentEntry(entry) {
|
||||
if isBlockedEntry(feed, entry, user) || !isAllowedEntry(feed, entry, user) || !isRecentEntry(entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
websiteURL := getUrlFromEntry(feed, entry)
|
||||
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL)
|
||||
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
|
||||
if feed.Crawler && (entryIsNew || forceRefresh) {
|
||||
slog.Debug("Scraping entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("entry_hash", entry.Hash),
|
||||
slog.String("entry_title", entry.Title),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Bool("entry_is_new", entryIsNew),
|
||||
slog.Bool("force_refresh", forceRefresh),
|
||||
slog.String("website_url", websiteURL),
|
||||
)
|
||||
|
||||
startTime := time.Now()
|
||||
@@ -90,7 +99,6 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
if scraperErr != nil {
|
||||
slog.Warn("Unable to scrape entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
@@ -98,7 +106,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
)
|
||||
} else if content != "" {
|
||||
// We replace the entry content only if the scraper doesn't return any error.
|
||||
entry.Content = content
|
||||
entry.Content = minifyEntryContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +122,46 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
feed.Entries = filteredEntries
|
||||
}
|
||||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
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 "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
|
||||
}
|
||||
@@ -134,7 +181,6 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
|
||||
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
@@ -146,7 +192,47 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
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 "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
|
||||
}
|
||||
@@ -165,7 +251,6 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
|
||||
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
@@ -209,7 +294,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
entry.Content = content
|
||||
entry.Content = minifyEntryContent(content)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
@@ -227,10 +312,16 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
|
||||
parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
|
||||
|
||||
if len(parts) >= 3 {
|
||||
re := regexp.MustCompile(parts[1])
|
||||
re, err := regexp.Compile(parts[1])
|
||||
if err != nil {
|
||||
slog.Error("Failed on regexp compilation",
|
||||
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return url
|
||||
}
|
||||
url = re.ReplaceAllString(entry.URL, parts[2])
|
||||
slog.Debug("Rewriting entry URL",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("original_entry_url", entry.URL),
|
||||
slog.String("rewritten_entry_url", url),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
@@ -238,7 +329,6 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Cannot find search and replace terms for replace rule",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("original_entry_url", entry.URL),
|
||||
slog.String("rewritten_entry_url", url),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
@@ -251,6 +341,11 @@ func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
|
||||
}
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldFetchYouTubeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchYouTubeWatchTime(entry.URL)
|
||||
@@ -266,7 +361,26 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(entry, feed)
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchNebulaWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchNebulaWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,14 +399,13 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(entry, feed)
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YT error case and non-YT entries.
|
||||
if entry.ReadingTime == 0 {
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +418,14 @@ func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchNebulaWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := nebulaRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
@@ -344,6 +465,38 @@ func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := strconv.ParseInt(durs, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur / 60), nil
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
@@ -419,3 +572,19 @@ func isRecentEntry(entry *model.Entry) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func minifyEntryContent(entryContent string) string {
|
||||
m := minify.New()
|
||||
|
||||
// Options required to avoid breaking the HTML content.
|
||||
m.Add("text/html", &html.Minifier{
|
||||
KeepEndTags: true,
|
||||
KeepQuotes: true,
|
||||
})
|
||||
|
||||
if minifiedHTML, err := m.String("text/html", entryContent); err == nil {
|
||||
entryContent = minifiedHTML
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
@@ -15,23 +15,33 @@ func TestBlockingEntries(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
feed *model.Feed
|
||||
entry *model.Entry
|
||||
user *model.User
|
||||
expected bool
|
||||
}{
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://example.com"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://different.com"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"something different", "something else"}}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://example.com"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://different.com"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"something different", "something else"}}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://example.com", Title: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Test"}, &model.User{BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://example.com", Content: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Test"}, &model.User{BlockFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result := isBlockedEntry(tc.feed, tc.entry)
|
||||
result := isBlockedEntry(tc.feed, tc.entry, tc.user)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for entry %q`, result, tc.entry.Title)
|
||||
}
|
||||
@@ -42,23 +52,33 @@ func TestAllowEntries(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
feed *model.Feed
|
||||
entry *model.Entry
|
||||
user *model.User
|
||||
expected bool
|
||||
}{
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://example.com"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://different.com"}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something more", Tags: []string{"something different", "something else"}}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://example.com"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://different.com"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something more", Tags: []string{"something different", "something else"}}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://example.com", Title: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Test"}, &model.User{KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://example.com", Content: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Test"}, &model.User{KeepFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result := isAllowedEntry(tc.feed, tc.entry)
|
||||
result := isAllowedEntry(tc.feed, tc.entry, tc.user)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for entry %q`, result, tc.entry.Title)
|
||||
}
|
||||
@@ -117,3 +137,12 @@ func TestIsRecentEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyEntryContent(t *testing.T) {
|
||||
input := `<p> Some text with a <a href="http://example.org/"> link </a> </p>`
|
||||
expected := `<p>Some text with a <a href="http://example.org/">link</a></p>`
|
||||
result := minifyEntryContent(input)
|
||||
if expected != result {
|
||||
t.Errorf(`Unexpected result, got %q`, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package readtime provides a function to estimate the reading time of an article.
|
||||
// Package readingtime provides a function to estimate the reading time of an article.
|
||||
package readingtime
|
||||
|
||||
import (
|
||||
|
||||
@@ -24,13 +24,13 @@ var predefinedRules = map[string]string{
|
||||
"monkeyuser.com": "add_image_title",
|
||||
"mrlovenstein.com": "add_image_title",
|
||||
"nedroid.com": "add_image_title",
|
||||
"oglaf.com": "add_image_title",
|
||||
"oglaf.com": `replace("media.oglaf.com/story/tt(.+).gif"|"media.oglaf.com/comic/$1.jpg"),add_image_title`,
|
||||
"optipess.com": "add_image_title",
|
||||
"peebleslab.com": "add_image_title",
|
||||
"quantamagazine.org": `add_youtube_video_from_id, remove("h6:not(.byline,.post__title__kicker), #comments, .next-post__content, .footer__section, figure .outer--content, script")`,
|
||||
"sentfromthemoon.com": "add_image_title",
|
||||
"thedoghousediaries.com": "add_image_title",
|
||||
"theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list")`,
|
||||
"theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list, .hidden")`,
|
||||
"treelobsters.com": "add_image_title",
|
||||
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
|
||||
"www.qwantz.com": "add_image_title,add_mailto_subject",
|
||||
|
||||
@@ -82,7 +82,7 @@ func Sanitize(baseURL, input string) string {
|
||||
var buffer strings.Builder
|
||||
var tagStack []string
|
||||
var parentTag string
|
||||
blacklistedTagDepth := 0
|
||||
var blockedStack []string
|
||||
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(input))
|
||||
for {
|
||||
@@ -98,7 +98,7 @@ func Sanitize(baseURL, input string) string {
|
||||
token := tokenizer.Token()
|
||||
switch token.Type {
|
||||
case html.TextToken:
|
||||
if blacklistedTagDepth > 0 {
|
||||
if len(blockedStack) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -116,7 +116,10 @@ func Sanitize(baseURL, input string) string {
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) {
|
||||
|
||||
if isBlockedTag(tagName) || slices.ContainsFunc(token.Attr, func(attr html.Attribute) bool { return attr.Key == "hidden" }) {
|
||||
blockedStack = append(blockedStack, tagName)
|
||||
} else if len(blockedStack) == 0 && isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
@@ -128,22 +131,20 @@ func Sanitize(baseURL, input string) string {
|
||||
|
||||
tagStack = append(tagStack, tagName)
|
||||
}
|
||||
} else if isBlockedTag(tagName) {
|
||||
blacklistedTagDepth++
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
if len(blockedStack) > 0 && blockedStack[len(blockedStack)-1] == tagName {
|
||||
blockedStack = blockedStack[:len(blockedStack)-1]
|
||||
} else if len(blockedStack) == 0 && isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString("</" + tagName + ">")
|
||||
} else if isBlockedTag(tagName) {
|
||||
blacklistedTagDepth--
|
||||
}
|
||||
case html.SelfClosingTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) {
|
||||
if isValidTag(tagName) && len(blockedStack) == 0 {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
|
||||
@@ -630,3 +630,13 @@ func TestReplaceStyle(t *testing.T) {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenParagraph(t *testing.T) {
|
||||
input := `<p>Before paragraph.</p><p hidden>This should <em>not</em> appear in the <strong>output</strong></p><p>After paragraph.</p>`
|
||||
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ var predefinedRules = map[string]string{
|
||||
"openingsource.org": "article.suxing-popup-gallery",
|
||||
"osnews.com": "div.newscontent1",
|
||||
"phoronix.com": "div.content",
|
||||
"pitchfork.com": "#main-content",
|
||||
"pseudo-sciences.org": "#art_main",
|
||||
"quantamagazine.org": ".outer--content, figure, script",
|
||||
"raywenderlich.com": "article",
|
||||
|
||||
@@ -5,10 +5,13 @@ package subscription // import "miniflux.app/v2/internal/reader/subscription"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/rssbridge"
|
||||
@@ -22,9 +25,19 @@ import (
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
type youtubeKind string
|
||||
|
||||
const (
|
||||
youtubeIDKindChannel youtubeKind = "channel"
|
||||
youtubeIDKindVideo youtubeKind = "video"
|
||||
youtubeIDKindPlaylist youtubeKind = "playlist"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)$`)
|
||||
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
youtubeHostRegex = regexp.MustCompile(`youtube\.com$`)
|
||||
youtubeChannelRegex = regexp.MustCompile(`channel/(.*)$`)
|
||||
|
||||
errNotYoutubeUrl = fmt.Errorf("this website is not a YouTube page")
|
||||
)
|
||||
|
||||
type SubscriptionFinder struct {
|
||||
@@ -74,31 +87,40 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil
|
||||
}
|
||||
|
||||
// Step 2) Check if the website URL is a YouTube channel.
|
||||
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError := f.FindSubscriptionsFromYouTubeChannelPage(websiteURL)
|
||||
if localizedError != nil {
|
||||
return nil, localizedError
|
||||
subscriptions := make(Subscriptions, 1)
|
||||
|
||||
// Step 2) Parse URL to find feeds from YouTube.
|
||||
kind, _, err := youtubeURLIDExtractor(websiteURL)
|
||||
|
||||
// If YouTube url has been detected, return the YouTube feed
|
||||
if err == nil || !errors.Is(err, errNotYoutubeUrl) {
|
||||
switch kind {
|
||||
case youtubeIDKindChannel:
|
||||
slog.Debug("Try to detect feeds from YouTube channel page", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeChannelPage(websiteURL)
|
||||
if localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
case youtubeIDKindVideo:
|
||||
slog.Debug("Try to detect feeds from YouTube video page", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeVideoPage(websiteURL)
|
||||
if localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
case youtubeIDKindPlaylist:
|
||||
slog.Debug("Try to detect feeds from YouTube playlist page", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError = f.FindSubscriptionsFromYouTubePlaylistPage(websiteURL)
|
||||
if localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
}
|
||||
if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from YouTube page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from YouTube channel page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Step 3) Check if the website URL is a YouTube video.
|
||||
slog.Debug("Try to detect feeds from YouTube video page", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError = f.FindSubscriptionsFromYouTubeVideoPage(websiteURL)
|
||||
if localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
|
||||
if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from YouTube video page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Step 4) Parse web page to find feeds from HTML meta tags.
|
||||
// Step 3) Parse web page to find feeds from HTML meta tags.
|
||||
slog.Debug("Try to detect feeds from HTML meta tags",
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("content_type", responseHandler.ContentType()),
|
||||
@@ -113,7 +135,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// Step 5) Check if the website URL can use RSS-Bridge.
|
||||
// Step 4) Check if the website URL can use RSS-Bridge.
|
||||
if rssBridgeURL != "" {
|
||||
slog.Debug("Try to detect feeds with RSS-Bridge", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError := f.FindSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL)
|
||||
@@ -127,7 +149,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6) Check if the website has a known feed URL.
|
||||
// Step 5) Check if the website has a known feed URL.
|
||||
slog.Debug("Try to detect feeds from well-known URLs", slog.String("website_url", websiteURL))
|
||||
subscriptions, localizedError = f.FindSubscriptionsFromWellKnownURLs(websiteURL)
|
||||
if localizedError != nil {
|
||||
@@ -285,20 +307,24 @@ func (f *SubscriptionFinder) FindSubscriptionsFromRSSBridge(websiteURL, rssBridg
|
||||
}
|
||||
|
||||
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
matches := youtubeChannelRegex.FindStringSubmatch(websiteURL)
|
||||
kind, id, _ := youtubeURLIDExtractor(websiteURL)
|
||||
|
||||
if len(matches) == 2 {
|
||||
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, matches[1])
|
||||
if kind == youtubeIDKindChannel {
|
||||
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, id)
|
||||
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
|
||||
}
|
||||
|
||||
slog.Debug("This website is not a YouTube channel page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
if !youtubeVideoRegex.MatchString(websiteURL) {
|
||||
kind, _, err := youtubeURLIDExtractor(websiteURL)
|
||||
if err != nil {
|
||||
slog.Debug("Could not parse url", slog.String("website_url", websiteURL))
|
||||
}
|
||||
|
||||
if kind != youtubeIDKindVideo {
|
||||
slog.Debug("This website is not a YouTube video page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
@@ -322,3 +348,51 @@ func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeVideoPage(websiteURL st
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubePlaylistPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
kind, id, _ := youtubeURLIDExtractor(websiteURL)
|
||||
|
||||
if kind == youtubeIDKindPlaylist {
|
||||
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?playlist_id=%s`, id)
|
||||
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
|
||||
}
|
||||
|
||||
slog.Debug("This website is not a YouTube playlist page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func youtubeURLIDExtractor(websiteURL string) (idKind youtubeKind, id string, err error) {
|
||||
decodedUrl, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
err = errNotYoutubeUrl
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(decodedUrl.Path, "/channel"):
|
||||
idKind = youtubeIDKindChannel
|
||||
matches := youtubeChannelRegex.FindStringSubmatch(decodedUrl.Path)
|
||||
id = matches[1]
|
||||
return
|
||||
case strings.HasPrefix(decodedUrl.Path, "/watch") && decodedUrl.Query().Has("list"):
|
||||
idKind = youtubeIDKindPlaylist
|
||||
id = decodedUrl.Query().Get("list")
|
||||
return
|
||||
case strings.HasPrefix(decodedUrl.Path, "/watch"):
|
||||
idKind = youtubeIDKindVideo
|
||||
id = decodedUrl.Query().Get("v")
|
||||
return
|
||||
case strings.HasPrefix(decodedUrl.Path, "/playlist"):
|
||||
idKind = youtubeIDKindPlaylist
|
||||
id = decodedUrl.Query().Get("list")
|
||||
return
|
||||
}
|
||||
err = fmt.Errorf("unable to extract youtube id from URL: %s", websiteURL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,10 +4,99 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindYoutubePlaylistFeed(t *testing.T) {
|
||||
scenarios := map[string]string{
|
||||
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
}
|
||||
|
||||
for websiteURL, expectedFeedURL := range scenarios {
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubePlaylistPage(websiteURL)
|
||||
if localizedError != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted YouTube playlist page should not return any error: %v`, localizedError)
|
||||
}
|
||||
|
||||
if len(subscriptions) != 1 {
|
||||
t.Fatal(`Incorrect number of subscriptions returned`)
|
||||
}
|
||||
|
||||
if subscriptions[0].URL != expectedFeedURL {
|
||||
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, expectedFeedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItDoesNotConsiderPlaylistWatchPageAsVideoWatchPage(t *testing.T) {
|
||||
_, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubeVideoPage("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM")
|
||||
if localizedError != nil {
|
||||
t.Fatalf(`Should not consider a playlist watch page as a video watch page`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYoutubeIdExtractor(t *testing.T) {
|
||||
type testResult struct {
|
||||
ID string
|
||||
Kind youtubeKind
|
||||
error error
|
||||
}
|
||||
urls := map[string]testResult{
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ": {
|
||||
ID: "dQw4w9WgXcQ",
|
||||
Kind: youtubeIDKindVideo,
|
||||
error: nil,
|
||||
},
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1": {
|
||||
ID: "dQw4w9WgXcQ",
|
||||
Kind: youtubeIDKindVideo,
|
||||
error: nil,
|
||||
},
|
||||
"https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ": {
|
||||
ID: "dQw4w9WgXcQ",
|
||||
Kind: youtubeIDKindVideo,
|
||||
error: nil,
|
||||
},
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM": {
|
||||
ID: "PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
Kind: youtubeIDKindPlaylist,
|
||||
error: nil,
|
||||
},
|
||||
"https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR": {
|
||||
ID: "PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
Kind: youtubeIDKindPlaylist,
|
||||
error: nil,
|
||||
},
|
||||
"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": {
|
||||
ID: "UC-Qj80avWItNRjkZ41rzHyw",
|
||||
Kind: youtubeIDKindChannel,
|
||||
error: nil,
|
||||
},
|
||||
"https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw": {
|
||||
ID: "",
|
||||
Kind: "",
|
||||
error: errNotYoutubeUrl,
|
||||
},
|
||||
}
|
||||
|
||||
for websiteURL, expected := range urls {
|
||||
kind, id, err := youtubeURLIDExtractor(websiteURL)
|
||||
if !errors.Is(err, expected.error) {
|
||||
t.Fatalf(`Unexpected error: %v got %v`, expected.error, err)
|
||||
}
|
||||
if id != expected.ID {
|
||||
t.Fatalf(`Unexpected ID: %v got %v`, expected.ID, id)
|
||||
}
|
||||
if kind != expected.Kind {
|
||||
t.Fatalf(`Unexpected Kind: %v got %v`, expected.Kind, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
scenarios := map[string]string{
|
||||
"https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw": "https://www.youtube.com/feeds/videos.xml?channel_id=UC-Qj80avWItNRjkZ41rzHyw",
|
||||
|
||||
+13
-18
@@ -225,24 +225,27 @@ func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetReadTime fetches the read time of an entry based on its hash, and the feed id and user id from the feed.
|
||||
// It's intended to be used on entries objects created by parsing a feed as they don't contain much information.
|
||||
// The feed param helps to scope the search to a specific user and feed in order to avoid hash clashes.
|
||||
func (s *Storage) GetReadTime(entry *model.Entry, feed *model.Feed) int {
|
||||
func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
|
||||
var result bool
|
||||
s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2`, feedID, entryHash).Scan(&result)
|
||||
return !result
|
||||
}
|
||||
|
||||
func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
|
||||
var result int
|
||||
|
||||
// Note: This query uses entries_feed_id_hash_key index
|
||||
s.db.QueryRow(
|
||||
`SELECT
|
||||
reading_time
|
||||
FROM
|
||||
entries
|
||||
WHERE
|
||||
user_id=$1 AND
|
||||
feed_id=$2 AND
|
||||
hash=$3
|
||||
feed_id=$1 AND
|
||||
hash=$2
|
||||
`,
|
||||
feed.UserID,
|
||||
feed.ID,
|
||||
entry.Hash,
|
||||
feedID,
|
||||
entryHash,
|
||||
).Scan(&result)
|
||||
return result
|
||||
}
|
||||
@@ -575,14 +578,6 @@ func (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntryURLExists returns true if an entry with this URL already exists.
|
||||
func (s *Storage) EntryURLExists(feedID int64, entryURL string) bool {
|
||||
var result bool
|
||||
query := `SELECT true FROM entries WHERE feed_id=$1 AND url=$2`
|
||||
s.db.QueryRow(query, feedID, entryURL).Scan(&result)
|
||||
return result
|
||||
}
|
||||
|
||||
// EntryShareCode returns the share code of the provided entry.
|
||||
// It generates a new one if not already defined.
|
||||
func (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) {
|
||||
|
||||
@@ -58,6 +58,15 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EntryPaginationBuilder) WithTags(tags []string) {
|
||||
if len(tags) > 0 {
|
||||
for _, tag := range tags {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
|
||||
e.args = append(e.args, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithGloballyVisible adds global visibility to the condition.
|
||||
func (e *EntryPaginationBuilder) WithGloballyVisible() {
|
||||
e.conditions = append(e.conditions, "not c.hide_globally")
|
||||
|
||||
@@ -160,7 +160,7 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
|
||||
func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
|
||||
if len(tags) > 0 {
|
||||
for _, cat := range tags {
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("$%d = ANY(e.tags)", len(e.args)+1))
|
||||
e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
|
||||
e.args = append(e.args, cat)
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
|
||||
f.title as feed_title,
|
||||
f.feed_url,
|
||||
f.site_url,
|
||||
f.description,
|
||||
f.checked_at,
|
||||
f.category_id,
|
||||
c.title as category_title,
|
||||
@@ -347,6 +348,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
|
||||
&entry.Feed.Title,
|
||||
&entry.Feed.FeedURL,
|
||||
&entry.Feed.SiteURL,
|
||||
&entry.Feed.Description,
|
||||
&entry.Feed.CheckedAt,
|
||||
&entry.Feed.Category.ID,
|
||||
&entry.Feed.Category.Title,
|
||||
|
||||
@@ -238,10 +238,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
|
||||
url_rewrite_rules,
|
||||
no_media_player,
|
||||
apprise_service_urls,
|
||||
disable_http2
|
||||
disable_http2,
|
||||
description
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
|
||||
RETURNING
|
||||
id
|
||||
`
|
||||
@@ -272,6 +273,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
|
||||
feed.NoMediaPlayer,
|
||||
feed.AppriseServiceURLs,
|
||||
feed.DisableHTTP2,
|
||||
feed.Description,
|
||||
).Scan(&feed.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
|
||||
@@ -344,9 +346,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
url_rewrite_rules=$25,
|
||||
no_media_player=$26,
|
||||
apprise_service_urls=$27,
|
||||
disable_http2=$28
|
||||
disable_http2=$28,
|
||||
description=$29
|
||||
WHERE
|
||||
id=$29 AND user_id=$30
|
||||
id=$30 AND user_id=$31
|
||||
`
|
||||
_, err = s.db.Exec(query,
|
||||
feed.FeedURL,
|
||||
@@ -377,6 +380,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
|
||||
feed.NoMediaPlayer,
|
||||
feed.AppriseServiceURLs,
|
||||
feed.DisableHTTP2,
|
||||
feed.Description,
|
||||
feed.ID,
|
||||
feed.UserID,
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
f.feed_url,
|
||||
f.site_url,
|
||||
f.title,
|
||||
f.description,
|
||||
f.etag_header,
|
||||
f.last_modified_header,
|
||||
f.user_id,
|
||||
@@ -202,6 +203,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
|
||||
&feed.FeedURL,
|
||||
&feed.SiteURL,
|
||||
&feed.Title,
|
||||
&feed.Description,
|
||||
&feed.EtagHeader,
|
||||
&feed.LastModifiedHeader,
|
||||
&feed.UserID,
|
||||
|
||||
@@ -193,7 +193,14 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
rssbridge_url,
|
||||
omnivore_enabled,
|
||||
omnivore_api_key,
|
||||
omnivore_url
|
||||
omnivore_url,
|
||||
raindrop_enabled,
|
||||
raindrop_token,
|
||||
raindrop_collection_id,
|
||||
raindrop_tags,
|
||||
betula_enabled,
|
||||
betula_url,
|
||||
betula_token
|
||||
FROM
|
||||
integrations
|
||||
WHERE
|
||||
@@ -286,6 +293,13 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
|
||||
&integration.OmnivoreEnabled,
|
||||
&integration.OmnivoreAPIKey,
|
||||
&integration.OmnivoreURL,
|
||||
&integration.RaindropEnabled,
|
||||
&integration.RaindropToken,
|
||||
&integration.RaindropCollectionID,
|
||||
&integration.RaindropTags,
|
||||
&integration.BetulaEnabled,
|
||||
&integration.BetulaURL,
|
||||
&integration.BetulaToken,
|
||||
)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
@@ -386,9 +400,16 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
omnivore_url=$81,
|
||||
linkwarden_enabled=$82,
|
||||
linkwarden_url=$83,
|
||||
linkwarden_api_key=$84
|
||||
linkwarden_api_key=$84,
|
||||
raindrop_enabled=$85,
|
||||
raindrop_token=$86,
|
||||
raindrop_collection_id=$87,
|
||||
raindrop_tags=$88,
|
||||
betula_enabled=$89,
|
||||
betula_url=$90,
|
||||
betula_token=$91
|
||||
WHERE
|
||||
user_id=$85
|
||||
user_id=$92
|
||||
`
|
||||
_, err := s.db.Exec(
|
||||
query,
|
||||
@@ -476,6 +497,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
|
||||
integration.LinkwardenEnabled,
|
||||
integration.LinkwardenURL,
|
||||
integration.LinkwardenAPIKey,
|
||||
integration.RaindropEnabled,
|
||||
integration.RaindropToken,
|
||||
integration.RaindropCollectionID,
|
||||
integration.RaindropTags,
|
||||
integration.BetulaEnabled,
|
||||
integration.BetulaURL,
|
||||
integration.BetulaToken,
|
||||
integration.UserID,
|
||||
)
|
||||
|
||||
@@ -513,7 +541,9 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
|
||||
readeck_enabled='t' OR
|
||||
shaarli_enabled='t' OR
|
||||
webhook_enabled='t' OR
|
||||
omnivore_enabled='t'
|
||||
omnivore_enabled='t' OR
|
||||
raindrop_enabled='t' OR
|
||||
betula_enabled='t'
|
||||
)
|
||||
`
|
||||
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
|
||||
|
||||
+36
-10
@@ -92,7 +92,9 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
|
||||
default_home_page,
|
||||
categories_sorting_order,
|
||||
mark_read_on_view,
|
||||
media_playback_rate
|
||||
media_playback_rate,
|
||||
block_filter_entry_rules,
|
||||
keep_filter_entry_rules
|
||||
`
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
@@ -132,6 +134,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
|
||||
&user.CategoriesSortingOrder,
|
||||
&user.MarkReadOnView,
|
||||
&user.MediaPlaybackRate,
|
||||
&user.BlockFilterEntryRules,
|
||||
&user.KeepFilterEntryRules,
|
||||
)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
@@ -189,9 +193,11 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
default_home_page=$20,
|
||||
categories_sorting_order=$21,
|
||||
mark_read_on_view=$22,
|
||||
media_playback_rate=$23
|
||||
media_playback_rate=$23,
|
||||
block_filter_entry_rules=$24,
|
||||
keep_filter_entry_rules=$25
|
||||
WHERE
|
||||
id=$24
|
||||
id=$26
|
||||
`
|
||||
|
||||
_, err = s.db.Exec(
|
||||
@@ -219,6 +225,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
user.CategoriesSortingOrder,
|
||||
user.MarkReadOnView,
|
||||
user.MediaPlaybackRate,
|
||||
user.BlockFilterEntryRules,
|
||||
user.KeepFilterEntryRules,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -248,9 +256,11 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
default_home_page=$19,
|
||||
categories_sorting_order=$20,
|
||||
mark_read_on_view=$21,
|
||||
media_playback_rate=$22
|
||||
media_playback_rate=$22,
|
||||
block_filter_entry_rules=$23,
|
||||
keep_filter_entry_rules=$24
|
||||
WHERE
|
||||
id=$23
|
||||
id=$25
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(
|
||||
@@ -277,6 +287,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
|
||||
user.CategoriesSortingOrder,
|
||||
user.MarkReadOnView,
|
||||
user.MediaPlaybackRate,
|
||||
user.BlockFilterEntryRules,
|
||||
user.KeepFilterEntryRules,
|
||||
user.ID,
|
||||
)
|
||||
|
||||
@@ -325,7 +337,9 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
|
||||
default_home_page,
|
||||
categories_sorting_order,
|
||||
mark_read_on_view,
|
||||
media_playback_rate
|
||||
media_playback_rate,
|
||||
block_filter_entry_rules,
|
||||
keep_filter_entry_rules
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
@@ -361,7 +375,9 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
|
||||
default_home_page,
|
||||
categories_sorting_order,
|
||||
mark_read_on_view,
|
||||
media_playback_rate
|
||||
media_playback_rate,
|
||||
block_filter_entry_rules,
|
||||
keep_filter_entry_rules
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
@@ -397,7 +413,9 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
|
||||
default_home_page,
|
||||
categories_sorting_order,
|
||||
mark_read_on_view,
|
||||
media_playback_rate
|
||||
media_playback_rate,
|
||||
block_filter_entry_rules,
|
||||
keep_filter_entry_rules
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
@@ -440,7 +458,9 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
|
||||
u.default_home_page,
|
||||
u.categories_sorting_order,
|
||||
u.mark_read_on_view,
|
||||
media_playback_rate
|
||||
media_playback_rate,
|
||||
u.block_filter_entry_rules,
|
||||
u.keep_filter_entry_rules
|
||||
FROM
|
||||
users u
|
||||
LEFT JOIN
|
||||
@@ -478,6 +498,8 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
|
||||
&user.CategoriesSortingOrder,
|
||||
&user.MarkReadOnView,
|
||||
&user.MediaPlaybackRate,
|
||||
&user.BlockFilterEntryRules,
|
||||
&user.KeepFilterEntryRules,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -586,7 +608,9 @@ func (s *Storage) Users() (model.Users, error) {
|
||||
default_home_page,
|
||||
categories_sorting_order,
|
||||
mark_read_on_view,
|
||||
media_playback_rate
|
||||
media_playback_rate,
|
||||
block_filter_entry_rules,
|
||||
keep_filter_entry_rules
|
||||
FROM
|
||||
users
|
||||
ORDER BY username ASC
|
||||
@@ -625,6 +649,8 @@ func (s *Storage) Users() (model.Users, error) {
|
||||
&user.CategoriesSortingOrder,
|
||||
&user.MarkReadOnView,
|
||||
&user.MediaPlaybackRate,
|
||||
&user.BlockFilterEntryRules,
|
||||
&user.KeepFilterEntryRules,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"html/template"
|
||||
"math"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -91,8 +92,9 @@ func (f *funcMap) Map() template.FuncMap {
|
||||
"nonce": func() string {
|
||||
return crypto.GenerateRandomStringHex(16)
|
||||
},
|
||||
"deRef": func(i *int) int { return *i },
|
||||
"duration": duration,
|
||||
"deRef": func(i *int) int { return *i },
|
||||
"duration": duration,
|
||||
"urlEncode": url.PathEscape,
|
||||
|
||||
// These functions are overrode at runtime after the parsing.
|
||||
"elapsed": func(timezone string, t time.Time) string {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{{ define "enclosure_media_controls" }}
|
||||
<div class="media-controls">
|
||||
<div class="media-seek-control">
|
||||
<div class="media-control-label">{{ t "enclosure_media_controls.seek" }} </div>
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="-30" title="{{ t "enclosure_media_controls.seek.title" "-30" }}" ><span class="icon-label" >-30s</span></button>
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="-10" title="{{ t "enclosure_media_controls.seek.title" "-10" }}" ><span class="icon-label" >-10s</span></button>
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="+10" title="{{ t "enclosure_media_controls.seek.title" "+10" }}" ><span class="icon-label" >+10s</span></button>
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="seek" data-action-value="+30" title="{{ t "enclosure_media_controls.seek.title" "+30" }}" ><span class="icon-label" >+30s</span></button>
|
||||
</div>
|
||||
<div class="media-speed-control">
|
||||
|
||||
<div class="media-control-label">{{ t "enclosure_media_controls.speed" }} (<span class="speed-indicator" data-enclosure-id="{{.ID}}">1.00x</span>)</div> <!-- Need JS to display the current speed unfortunately -->
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed" data-action-value="-0.25" title="{{ t "enclosure_media_controls.speed.slower.title" "0.25" }}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.slower" }}</span></button>
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed-reset" data-action-value="1" title="{{ t "enclosure_media_controls.speed.reset.title"}}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.reset" }}</span></button>
|
||||
<button class="page-button" data-enclosure-id="{{.ID}}" data-enclosure-action="speed" data-action-value="+0.25" title="{{ t "enclosure_media_controls.speed.faster.title" "0.25" }}"><span class="icon-label" >{{ t "enclosure_media_controls.speed.faster" }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>{{template "title" .}} - Miniflux</title>
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Miniflux">
|
||||
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials">
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
{{ define "pagination" }}
|
||||
<div class="pagination">
|
||||
<div class="pagination-prev {{ if not .ShowPrev }}disabled{{end}}">
|
||||
{{ if .ShowPrev }}
|
||||
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
|
||||
{{ else }}
|
||||
{{ t "pagination.previous" }}
|
||||
{{ end }}
|
||||
<div class="pagination-backward">
|
||||
<div class="pagination-first {{ if not .ShowFirst }}disabled{{end}}">
|
||||
{{ if .ShowFirst }}
|
||||
<a href="{{ .Route }}{{ if gt .FirstOffset 0 }}?offset={{ .FirstOffset }}{{ if .SearchQuery }}&q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="first">{{ t "pagination.first" }}</a>
|
||||
{{ else }}
|
||||
{{ t "pagination.first" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="pagination-prev {{ if not .ShowPrev }}disabled{{end}}">
|
||||
{{ if .ShowPrev }}
|
||||
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
|
||||
{{ else }}
|
||||
{{ t "pagination.previous" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination-next {{ if not .ShowNext }}disabled{{end}}">
|
||||
{{ if .ShowNext }}
|
||||
<a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&q={{ .SearchQuery }}{{ end }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
|
||||
{{ else }}
|
||||
{{ t "pagination.next" }}
|
||||
{{ end }}
|
||||
<div class="pagination-forward">
|
||||
<div class="pagination-next {{ if not .ShowNext }}disabled{{end}}">
|
||||
{{ if .ShowNext }}
|
||||
<a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&q={{ .SearchQuery }}{{ end }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
|
||||
{{ else }}
|
||||
{{ t "pagination.next" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="pagination-last {{ if not .ShowLast }}disabled{{end}}">
|
||||
{{ if .ShowLast }}
|
||||
<a href="{{ .Route }}?offset={{ .LastOffset }}{{ if .SearchQuery }}&q={{ .SearchQuery }}{{ end }}" data-page="last" >{{ t "pagination.last" }}</a>
|
||||
{{ else }}
|
||||
{{ t "pagination.last" }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -63,6 +63,9 @@
|
||||
<label for="form-feed-url">{{ t "form.feed.label.feed_url" }}</label>
|
||||
<input type="url" name="feed_url" id="form-feed-url" placeholder="https://domain.tld/" value="{{ .form.FeedURL }}" spellcheck="false" required>
|
||||
|
||||
<label for="form-description">{{ t "form.feed.label.description" }}</label>
|
||||
<textarea name="description" id="form-description" cols="40" rows="10" >{{ .form.Description }}</textarea>
|
||||
|
||||
{{ if not .form.CategoryHidden }}
|
||||
<label><input type="checkbox" name="hide_globally" value="1"{{ if .form.HideGlobally }} checked{{ end }}> {{ t "form.feed.label.hide_globally" }}</label>
|
||||
{{ end }}
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
{{ if .entry.Tags }}
|
||||
<div class="entry-tags">
|
||||
{{ t "entry.tags.label" }}
|
||||
{{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<strong>{{ $e }}</strong>{{end}}
|
||||
{{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<a href="{{ route "tagEntriesAll" "tagName" (urlEncode $e) }}"><strong>{{ $e }}</strong></a>{{end}}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="entry-date">
|
||||
@@ -174,6 +174,7 @@
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
@@ -181,6 +182,7 @@
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
@@ -188,6 +190,7 @@
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
@@ -195,6 +198,7 @@
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -218,6 +222,7 @@
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "audio")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
@@ -225,6 +230,7 @@
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</audio>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "video/" }}
|
||||
<div class="enclosure-video">
|
||||
@@ -232,6 +238,7 @@
|
||||
data-last-position="{{ .MediaProgression }}"
|
||||
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
|
||||
data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
|
||||
data-enclosure-id="{{.ID}}"
|
||||
>
|
||||
{{ if (and $.user (mustBeProxyfied "video")) }}
|
||||
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
|
||||
@@ -239,6 +246,7 @@
|
||||
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
|
||||
{{ end }}
|
||||
</video>
|
||||
{{ template "enclosure_media_controls" . }}
|
||||
</div>
|
||||
{{ else if hasPrefix .MimeType "image/" }}
|
||||
<div class="enclosure-image">
|
||||
|
||||
@@ -38,6 +38,25 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.BetulaEnabled }}open{{ end }}>
|
||||
<summary>Betula</summary>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="betula_enabled" value="1" {{ if .form.BetulaEnabled }}checked{{ end }}> {{ t "form.integration.betula_activate" }}
|
||||
</label>
|
||||
|
||||
<label for="form-betula-url">{{ t "form.integration.betula_url" }}</label>
|
||||
<input type="url" name="betula_url" id="form-betula-url" value="{{ .form.BetulaURL }}" placeholder="http://links.bouncepaw.com" spellcheck="false">
|
||||
|
||||
<label for="form-betula-token">{{ t "form.integration.betula_token" }}</label>
|
||||
<input type="text" name="betula_token" id="form-betula-token" value="{{ .form.BetulaToken }}" spellcheck="false">
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.EspialEnabled }}open{{ end }}>
|
||||
<summary>Espial</summary>
|
||||
<div class="form-section">
|
||||
@@ -326,6 +345,28 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.RaindropEnabled }}open{{ end }}>
|
||||
<summary>Raindrop</summary>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="raindrop_enabled" value="1" {{ if .form.RaindropEnabled }}checked{{ end }}> {{ t "form.integration.raindrop_activate" }}
|
||||
</label>
|
||||
|
||||
<label for="form-raindrop-token">{{ t "form.integration.raindrop_token" }}</label>
|
||||
<input type="text" name="raindrop_token" id="form-raindrop-token" value="{{ .form.RaindropToken }}" spellcheck="false">
|
||||
|
||||
<label for="form-raindrop-collection-id">{{ t "form.integration.raindrop_collection_id" }}</label>
|
||||
<input type="text" name="raindrop_collection_id" id="form-raindrop-collection-id" value="{{ .form.RaindropCollectionID }}" spellcheck="false">
|
||||
|
||||
<label for="form-raindrop-tags">{{ t "form.integration.raindrop_tags" }}</label>
|
||||
<input type="text" name="raindrop_tags" id="form-raindrop-tags" value="{{ .form.RaindropTags }}" spellcheck="false">
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details {{ if .form.ReadeckEnabled }}open{{ end }}>
|
||||
<summary>Readeck</summary>
|
||||
<div class="form-section">
|
||||
|
||||
@@ -200,6 +200,28 @@
|
||||
|
||||
<label for="form-custom-css">{{t "form.prefs.label.custom_css" }}</label>
|
||||
<textarea id="form-custom-css" name="custom_css" cols="40" rows="10" spellcheck="false">{{ .form.CustomCSS }}</textarea>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{{ t "form.prefs.fieldset.global_feed_settings" }}</legend>
|
||||
<div class="form-label-row">
|
||||
<label for="form-blocklist-rules">
|
||||
{{ t "form.feed.label.blocklist_rules" }}
|
||||
</label>
|
||||
</div>
|
||||
<textarea id="form-blocklist-rules" name="block_filter_entry_rules" cols="40" rows="10" spellcheck="false">{{ .form.BlockFilterEntryRules }}</textarea>
|
||||
|
||||
<div class="form-label-row">
|
||||
<label for="form-keeplist-rules">
|
||||
{{ t "form.feed.label.keeplist_rules" }}
|
||||
</label>
|
||||
</div>
|
||||
<textarea id="form-keeplist-rules" name="keep_filter_entry_rules" cols="40" rows="10" spellcheck="false">{{ .form.KeepFilterEntryRules }}</textarea>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{{ define "title"}}{{ .tagName }} ({{ .total }}){{ end }}
|
||||
|
||||
{{ define "page_header"}}
|
||||
<section class="page-header" aria-labelledby="page-header-title page-header-title-count">
|
||||
<h1 id="page-header-title" dir="auto">
|
||||
{{ .tagName }}
|
||||
<span aria-hidden="true"> ({{ .total }})</span>
|
||||
</h1>
|
||||
<span id="page-header-title-count" class="sr-only">{{ plural "page.tag_entry_count" .total .total }}</span>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content"}}
|
||||
{{ if not .entries }}
|
||||
<p role="alert" class="alert alert-info">{{ t "alert.no_tag_entry" }}</p>
|
||||
{{ else }}
|
||||
<div class="pagination-top">
|
||||
{{ template "pagination" .pagination }}
|
||||
</div>
|
||||
<div class="items">
|
||||
{{ range .entries }}
|
||||
<article
|
||||
class="item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}"
|
||||
data-id="{{ .ID }}"
|
||||
aria-labelledby="entry-title-{{ .ID }}"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="item-header" dir="auto">
|
||||
<h2 id="entry-title-{{ .ID }}" class="item-title">
|
||||
<a href="{{ route "tagEntry" "entryID" .ID "tagName" (urlEncode $.tagName) }}">
|
||||
{{ if ne .Feed.Icon.IconID 0 }}
|
||||
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="">
|
||||
{{ end }}
|
||||
{{ .Title }}
|
||||
</a>
|
||||
</h2>
|
||||
<span class="category">
|
||||
<a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
|
||||
{{ .Feed.Category.Title }}
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
|
||||
</article>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="pagination-bottom">
|
||||
{{ template "pagination" .pagination }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
@@ -32,8 +33,9 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
// Avoid accidental and excessive refreshes.
|
||||
if time.Now().UTC().Unix()-request.LastForceRefresh(r) < 1800 {
|
||||
sess.NewFlashErrorMessage(printer.Print("alert.too_many_feeds_refresh"))
|
||||
if time.Now().UTC().Unix()-request.LastForceRefresh(r) < int64(config.Opts.ForceRefreshInterval())*60 {
|
||||
time := config.Opts.ForceRefreshInterval()
|
||||
sess.NewFlashErrorMessage(printer.Plural("alert.too_many_feeds_refresh", time, time))
|
||||
} else {
|
||||
// We allow the end-user to force refresh all its feeds in this category
|
||||
// without taking into consideration the number of errors.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
entryID := request.RouteInt64Param(r, "entryID")
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithTags([]string{tagName})
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
html.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
|
||||
err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
entry.Status = model.EntryStatusRead
|
||||
}
|
||||
|
||||
entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
|
||||
entryPaginationBuilder.WithTags([]string{tagName})
|
||||
prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextEntryRoute := ""
|
||||
if nextEntry != nil {
|
||||
nextEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", nextEntry.ID)
|
||||
}
|
||||
|
||||
prevEntryRoute := ""
|
||||
if prevEntry != nil {
|
||||
prevEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", prevEntry.ID)
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("entry", entry)
|
||||
view.Set("prevEntry", prevEntry)
|
||||
view.Set("nextEntry", nextEntry)
|
||||
view.Set("nextEntryRoute", nextEntryRoute)
|
||||
view.Set("prevEntryRoute", prevEntryRoute)
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("entry"))
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
|
||||
SiteURL: feed.SiteURL,
|
||||
FeedURL: feed.FeedURL,
|
||||
Title: feed.Title,
|
||||
Description: feed.Description,
|
||||
ScraperRules: feed.ScraperRules,
|
||||
RewriteRules: feed.RewriteRules,
|
||||
BlocklistRules: feed.BlocklistRules,
|
||||
|
||||
@@ -29,7 +29,9 @@ func (h *handler) showIcon(w http.ResponseWriter, r *http.Request) {
|
||||
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
b.WithHeader("Content-Type", icon.MimeType)
|
||||
b.WithBody(icon.Content)
|
||||
b.WithoutCompression()
|
||||
if icon.MimeType != "image/svg+xml" {
|
||||
b.WithoutCompression()
|
||||
}
|
||||
b.Write()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
FeedURL: model.OptionalString(feedForm.FeedURL),
|
||||
SiteURL: model.OptionalString(feedForm.SiteURL),
|
||||
Title: model.OptionalString(feedForm.Title),
|
||||
Description: model.OptionalString(feedForm.Description),
|
||||
CategoryID: model.OptionalNumber(feedForm.CategoryID),
|
||||
BlocklistRules: model.OptionalString(feedForm.BlocklistRules),
|
||||
KeeplistRules: model.OptionalString(feedForm.KeeplistRules),
|
||||
|
||||
@@ -15,6 +15,7 @@ type FeedForm struct {
|
||||
FeedURL string
|
||||
SiteURL string
|
||||
Title string
|
||||
Description string
|
||||
ScraperRules string
|
||||
RewriteRules string
|
||||
BlocklistRules string
|
||||
@@ -43,6 +44,7 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
|
||||
feed.Title = f.Title
|
||||
feed.SiteURL = f.SiteURL
|
||||
feed.FeedURL = f.FeedURL
|
||||
feed.Description = f.Description
|
||||
feed.ScraperRules = f.ScraperRules
|
||||
feed.RewriteRules = f.RewriteRules
|
||||
feed.BlocklistRules = f.BlocklistRules
|
||||
@@ -76,6 +78,7 @@ func NewFeedForm(r *http.Request) *FeedForm {
|
||||
FeedURL: r.FormValue("feed_url"),
|
||||
SiteURL: r.FormValue("site_url"),
|
||||
Title: r.FormValue("title"),
|
||||
Description: r.FormValue("description"),
|
||||
ScraperRules: r.FormValue("scraper_rules"),
|
||||
UserAgent: r.FormValue("user_agent"),
|
||||
Cookie: r.FormValue("cookie"),
|
||||
|
||||
@@ -96,6 +96,13 @@ type IntegrationForm struct {
|
||||
OmnivoreEnabled bool
|
||||
OmnivoreAPIKey string
|
||||
OmnivoreURL string
|
||||
RaindropEnabled bool
|
||||
RaindropToken string
|
||||
RaindropCollectionID string
|
||||
RaindropTags string
|
||||
BetulaEnabled bool
|
||||
BetulaURL string
|
||||
BetulaToken string
|
||||
}
|
||||
|
||||
// Merge copy form values to the model.
|
||||
@@ -181,6 +188,13 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
|
||||
integration.OmnivoreEnabled = i.OmnivoreEnabled
|
||||
integration.OmnivoreAPIKey = i.OmnivoreAPIKey
|
||||
integration.OmnivoreURL = i.OmnivoreURL
|
||||
integration.RaindropEnabled = i.RaindropEnabled
|
||||
integration.RaindropToken = i.RaindropToken
|
||||
integration.RaindropCollectionID = i.RaindropCollectionID
|
||||
integration.RaindropTags = i.RaindropTags
|
||||
integration.BetulaEnabled = i.BetulaEnabled
|
||||
integration.BetulaURL = i.BetulaURL
|
||||
integration.BetulaToken = i.BetulaToken
|
||||
}
|
||||
|
||||
// NewIntegrationForm returns a new IntegrationForm.
|
||||
@@ -269,6 +283,13 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
|
||||
OmnivoreEnabled: r.FormValue("omnivore_enabled") == "1",
|
||||
OmnivoreAPIKey: r.FormValue("omnivore_api_key"),
|
||||
OmnivoreURL: r.FormValue("omnivore_url"),
|
||||
RaindropEnabled: r.FormValue("raindrop_enabled") == "1",
|
||||
RaindropToken: r.FormValue("raindrop_token"),
|
||||
RaindropCollectionID: r.FormValue("raindrop_collection_id"),
|
||||
RaindropTags: r.FormValue("raindrop_tags"),
|
||||
BetulaEnabled: r.FormValue("betula_enabled") == "1",
|
||||
BetulaURL: r.FormValue("betula_url"),
|
||||
BetulaToken: r.FormValue("betula_token"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ type SettingsForm struct {
|
||||
CategoriesSortingOrder string
|
||||
MarkReadOnView bool
|
||||
MediaPlaybackRate float64
|
||||
BlockFilterEntryRules string
|
||||
KeepFilterEntryRules string
|
||||
}
|
||||
|
||||
// Merge updates the fields of the given user.
|
||||
@@ -57,6 +59,8 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
|
||||
user.CategoriesSortingOrder = s.CategoriesSortingOrder
|
||||
user.MarkReadOnView = s.MarkReadOnView
|
||||
user.MediaPlaybackRate = s.MediaPlaybackRate
|
||||
user.BlockFilterEntryRules = s.BlockFilterEntryRules
|
||||
user.KeepFilterEntryRules = s.KeepFilterEntryRules
|
||||
|
||||
if s.Password != "" {
|
||||
user.Password = s.Password
|
||||
@@ -133,5 +137,7 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
|
||||
CategoriesSortingOrder: r.FormValue("categories_sorting_order"),
|
||||
MarkReadOnView: r.FormValue("mark_read_on_view") == "1",
|
||||
MediaPlaybackRate: mediaPlaybackRate,
|
||||
BlockFilterEntryRules: r.FormValue("block_filter_entry_rules"),
|
||||
KeepFilterEntryRules: r.FormValue("keep_filter_entry_rules"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,13 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
|
||||
OmnivoreEnabled: integration.OmnivoreEnabled,
|
||||
OmnivoreAPIKey: integration.OmnivoreAPIKey,
|
||||
OmnivoreURL: integration.OmnivoreURL,
|
||||
RaindropEnabled: integration.RaindropEnabled,
|
||||
RaindropToken: integration.RaindropToken,
|
||||
RaindropCollectionID: integration.RaindropCollectionID,
|
||||
RaindropTags: integration.RaindropTags,
|
||||
BetulaEnabled: integration.BetulaEnabled,
|
||||
BetulaURL: integration.BetulaURL,
|
||||
BetulaToken: integration.BetulaToken,
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
|
||||
@@ -9,17 +9,30 @@ type pagination struct {
|
||||
Offset int
|
||||
ItemsPerPage int
|
||||
ShowNext bool
|
||||
ShowLast bool
|
||||
ShowFirst bool
|
||||
ShowPrev bool
|
||||
NextOffset int
|
||||
LastOffset int
|
||||
PrevOffset int
|
||||
FirstOffset int
|
||||
SearchQuery string
|
||||
}
|
||||
|
||||
func getPagination(route string, total, offset, nbItemsPerPage int) pagination {
|
||||
nextOffset := 0
|
||||
prevOffset := 0
|
||||
|
||||
firstOffset := 0
|
||||
lastOffset := (total / nbItemsPerPage) * nbItemsPerPage
|
||||
if lastOffset == total {
|
||||
lastOffset -= nbItemsPerPage
|
||||
}
|
||||
|
||||
showNext := (total - offset) > nbItemsPerPage
|
||||
showPrev := offset > 0
|
||||
showLast := showNext
|
||||
showFirst := showPrev
|
||||
|
||||
if showNext {
|
||||
nextOffset = offset + nbItemsPerPage
|
||||
@@ -35,8 +48,12 @@ func getPagination(route string, total, offset, nbItemsPerPage int) pagination {
|
||||
Offset: offset,
|
||||
ItemsPerPage: nbItemsPerPage,
|
||||
ShowNext: showNext,
|
||||
ShowLast: showLast,
|
||||
NextOffset: nextOffset,
|
||||
LastOffset: lastOffset,
|
||||
ShowPrev: showPrev,
|
||||
ShowFirst: showFirst,
|
||||
PrevOffset: prevOffset,
|
||||
FirstOffset: firstOffset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
CategoriesSortingOrder: user.CategoriesSortingOrder,
|
||||
MarkReadOnView: user.MarkReadOnView,
|
||||
MediaPlaybackRate: user.MediaPlaybackRate,
|
||||
BlockFilterEntryRules: user.BlockFilterEntryRules,
|
||||
KeepFilterEntryRules: user.KeepFilterEntryRules,
|
||||
}
|
||||
|
||||
timezones, err := h.store.Timezones()
|
||||
|
||||
@@ -5,6 +5,8 @@ package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
@@ -30,6 +32,12 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := h.store.WebAuthnCredentialsByUserID(loggedUser.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
settingsForm := form.NewSettingsForm(r)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
@@ -42,6 +50,18 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
view.Set("user", loggedUser)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID))
|
||||
view.Set("default_home_pages", model.HomePages())
|
||||
view.Set("categories_sorting_options", model.CategoriesSortingOptions())
|
||||
view.Set("countWebAuthnCerts", h.store.CountWebAuthnCredentialsByUserID(loggedUser.ID))
|
||||
view.Set("webAuthnCerts", creds)
|
||||
|
||||
// Sanitize the end of the block & Keep rules
|
||||
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
|
||||
settingsForm.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(settingsForm.BlockFilterEntryRules, "")
|
||||
settingsForm.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(settingsForm.KeepFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
settingsForm.BlockFilterEntryRules = strings.ReplaceAll(settingsForm.BlockFilterEntryRules, "\r\n", "\n")
|
||||
settingsForm.KeepFilterEntryRules = strings.ReplaceAll(settingsForm.KeepFilterEntryRules, "\r\n", "\n")
|
||||
|
||||
if validationErr := settingsForm.Validate(); validationErr != nil {
|
||||
view.Set("errorMessage", validationErr.Translate(loggedUser.Language))
|
||||
@@ -50,19 +70,21 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
userModificationRequest := &model.UserModificationRequest{
|
||||
Username: model.OptionalString(settingsForm.Username),
|
||||
Password: model.OptionalString(settingsForm.Password),
|
||||
Theme: model.OptionalString(settingsForm.Theme),
|
||||
Language: model.OptionalString(settingsForm.Language),
|
||||
Timezone: model.OptionalString(settingsForm.Timezone),
|
||||
EntryDirection: model.OptionalString(settingsForm.EntryDirection),
|
||||
EntriesPerPage: model.OptionalNumber(settingsForm.EntriesPerPage),
|
||||
DisplayMode: model.OptionalString(settingsForm.DisplayMode),
|
||||
GestureNav: model.OptionalString(settingsForm.GestureNav),
|
||||
DefaultReadingSpeed: model.OptionalNumber(settingsForm.DefaultReadingSpeed),
|
||||
CJKReadingSpeed: model.OptionalNumber(settingsForm.CJKReadingSpeed),
|
||||
DefaultHomePage: model.OptionalString(settingsForm.DefaultHomePage),
|
||||
MediaPlaybackRate: model.OptionalNumber(settingsForm.MediaPlaybackRate),
|
||||
Username: model.OptionalString(settingsForm.Username),
|
||||
Password: model.OptionalString(settingsForm.Password),
|
||||
Theme: model.OptionalString(settingsForm.Theme),
|
||||
Language: model.OptionalString(settingsForm.Language),
|
||||
Timezone: model.OptionalString(settingsForm.Timezone),
|
||||
EntryDirection: model.OptionalString(settingsForm.EntryDirection),
|
||||
EntriesPerPage: model.OptionalNumber(settingsForm.EntriesPerPage),
|
||||
DisplayMode: model.OptionalString(settingsForm.DisplayMode),
|
||||
GestureNav: model.OptionalString(settingsForm.GestureNav),
|
||||
DefaultReadingSpeed: model.OptionalNumber(settingsForm.DefaultReadingSpeed),
|
||||
CJKReadingSpeed: model.OptionalNumber(settingsForm.CJKReadingSpeed),
|
||||
DefaultHomePage: model.OptionalString(settingsForm.DefaultHomePage),
|
||||
MediaPlaybackRate: model.OptionalNumber(settingsForm.MediaPlaybackRate),
|
||||
BlockFilterEntryRules: model.OptionalString(settingsForm.BlockFilterEntryRules),
|
||||
KeepFilterEntryRules: model.OptionalString(settingsForm.KeepFilterEntryRules),
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {
|
||||
|
||||
@@ -710,6 +710,7 @@ template {
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination-top {
|
||||
@@ -732,21 +733,40 @@ template {
|
||||
}
|
||||
|
||||
.pagination > div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pagination > div.pagination-backward > div {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.pagination > div.pagination-forward > div {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.pagination-next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pagination-prev:before {
|
||||
content: "« ";
|
||||
.pagination-next:after {
|
||||
content: " ›";
|
||||
}
|
||||
|
||||
.pagination-next:after {
|
||||
.pagination-last {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pagination-last:after {
|
||||
content: " »";
|
||||
}
|
||||
|
||||
.pagination-prev:before {
|
||||
content: "‹ ";
|
||||
}
|
||||
.pagination-first:before {
|
||||
content: "« ";
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
color: var(--pagination-link-color);
|
||||
}
|
||||
@@ -1018,6 +1038,10 @@ article.category-has-unread {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.entry-content img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.entry-content figure {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
@@ -1215,6 +1239,39 @@ audio, video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-controls{
|
||||
font-size: .9em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.media-controls .media-control-label{
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.media-controls>div{
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content:center;
|
||||
min-width: 50%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-controls>div>*{
|
||||
padding-left:12px;
|
||||
}
|
||||
|
||||
.media-controls>div>*:first-child{
|
||||
padding-left:0;
|
||||
}
|
||||
|
||||
.media-controls span.speed-indicator{
|
||||
/*monospace to ensure constant width even when value change. JS ensure the value is always on 4 characters (in most cases)
|
||||
This reduce ui flickering due to element moving around a bit
|
||||
*/
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.integration-form summary {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ function onClickMainMenuListItem(event) {
|
||||
if (element.tagName === "A") {
|
||||
window.location.href = element.getAttribute("href");
|
||||
} else {
|
||||
window.location.href = element.querySelector("a").getAttribute("href");
|
||||
const linkElement = element.querySelector("a") || element.closest("a");
|
||||
window.location.href = linkElement.getAttribute("href");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,8 +446,8 @@ function goToPage(page, fallbackSelf) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {(number|event)} offset - many items to jump for focus.
|
||||
*
|
||||
* @param {(number|event)} offset - many items to jump for focus.
|
||||
*/
|
||||
function goToPrevious(offset) {
|
||||
if (offset instanceof KeyboardEvent) {
|
||||
@@ -460,8 +461,8 @@ function goToPrevious(offset) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {(number|event)} offset - How many items to jump for focus.
|
||||
*
|
||||
* @param {(number|event)} offset - How many items to jump for focus.
|
||||
*/
|
||||
function goToNext(offset) {
|
||||
if (offset instanceof KeyboardEvent) {
|
||||
@@ -520,7 +521,7 @@ function goToListItem(offset) {
|
||||
items[i].classList.remove("current-item");
|
||||
|
||||
// By default adjust selection by offset
|
||||
let itemOffset = (i + offset + items.length) % items.length;
|
||||
let itemOffset = (i + offset + items.length) % items.length;
|
||||
// Allow jumping to top or bottom
|
||||
if (offset == TOP) {
|
||||
itemOffset = 0;
|
||||
@@ -741,3 +742,43 @@ function getCsrfToken() {
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle all clicks on media player controls button on enclosures.
|
||||
* Will change the current speed and position of the player accordingly.
|
||||
* Will not save anything, all is done client-side, however, changing the position
|
||||
* will trigger the handlePlayerProgressionSave and save the new position backends side.
|
||||
* @param {Element} button
|
||||
*/
|
||||
function handleMediaControl(button) {
|
||||
const action = button.dataset.enclosureAction;
|
||||
const value = parseFloat(button.dataset.actionValue);
|
||||
const targetEnclosureId = button.dataset.enclosureId;
|
||||
const enclosures = document.querySelectorAll(`audio[data-enclosure-id="${targetEnclosureId}"],video[data-enclosure-id="${targetEnclosureId}"]`);
|
||||
const speedIndicator = document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${targetEnclosureId}"]`);
|
||||
enclosures.forEach((enclosure) => {
|
||||
switch (action) {
|
||||
case "seek":
|
||||
enclosure.currentTime = enclosure.currentTime + value > 0 ? enclosure.currentTime + value : 0;
|
||||
break;
|
||||
case "speed":
|
||||
// I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped.
|
||||
// 0.25 was chosen because it will allow to get back to 1x in two "faster" click, and lower value with same property would be 0.
|
||||
enclosure.playbackRate = Math.max(0.25, enclosure.playbackRate + value);
|
||||
speedIndicator.forEach((speedI) => {
|
||||
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
|
||||
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
|
||||
speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`;
|
||||
});
|
||||
break;
|
||||
case "speed-reset":
|
||||
enclosure.playbackRate = value ;
|
||||
speedIndicator.forEach((speedI) => {
|
||||
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
|
||||
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
|
||||
speedI.innerText = `${enclosure.playbackRate.toFixed(2)}x`;
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+14
@@ -167,6 +167,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
playbackRateElements.forEach((element) => {
|
||||
if (element.dataset.playbackRate) {
|
||||
element.playbackRate = element.dataset.playbackRate;
|
||||
if (element.dataset.enclosureId){
|
||||
// In order to display properly the speed we need to do it on bootstrap.
|
||||
// Could not do it backend side because I didn't know how to do it because of the template inclusion and
|
||||
// the way the initial playback speed is handled. See enclosure_media_controls.html if you want to try to fix this
|
||||
document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${element.dataset.enclosureId}"]`).forEach((speedI)=>{
|
||||
speedI.innerText = `${parseFloat(element.dataset.playbackRate).toFixed(2)}x`;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set enclosure media controls handlers
|
||||
const mediaControlsElements = document.querySelectorAll("button[data-enclosure-action]");
|
||||
mediaControlsElements.forEach((element) => {
|
||||
element.addEventListener("click", () => handleMediaControl(element));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,12 +31,12 @@ func (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch filepath.Ext(filename) {
|
||||
case ".png":
|
||||
b.WithoutCompression()
|
||||
b.WithHeader("Content-Type", "image/png")
|
||||
case ".svg":
|
||||
b.WithHeader("Content-Type", "image/svg+xml")
|
||||
}
|
||||
|
||||
b.WithoutCompression()
|
||||
b.WithBody(blob)
|
||||
b.Write()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
offset := request.QueryIntParam(r, "offset", 0)
|
||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithTags([]string{tagName})
|
||||
builder.WithSorting("status", "asc")
|
||||
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(user.EntriesPerPage)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := builder.CountEntries()
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
view.Set("tagName", tagName)
|
||||
view.Set("total", count)
|
||||
view.Set("entries", entries)
|
||||
view.Set("pagination", getPagination(route.Path(h.router, "tagEntriesAll", "tagName", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
|
||||
view.Set("showOnlyUnreadEntries", false)
|
||||
|
||||
html.OK(w, r, view.Render("tag_entries"))
|
||||
}
|
||||
@@ -93,6 +93,10 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost)
|
||||
|
||||
// Tag pages.
|
||||
uiRouter.HandleFunc("/tags/{tagName}/entries/all", handler.showTagEntriesAllPage).Name("tagEntriesAll").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/tags/{tagName}/entry/{entryID}", handler.showTagEntryPage).Name("tagEntry").Methods(http.MethodGet)
|
||||
|
||||
// Entry pages.
|
||||
uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
package validator // import "miniflux.app/v2/internal/validator"
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
@@ -108,6 +111,18 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
|
||||
}
|
||||
}
|
||||
|
||||
if changes.BlockFilterEntryRules != nil {
|
||||
if err := isValidFilterRules(*changes.BlockFilterEntryRules, "block"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if changes.KeepFilterEntryRules != nil {
|
||||
if err := isValidFilterRules(*changes.KeepFilterEntryRules, "keep"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -195,3 +210,35 @@ func validateMediaPlaybackRate(mediaPlaybackRate float64) *locale.LocalizedError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
|
||||
// Valid Format: FieldName=RegEx\nFieldName=RegEx...
|
||||
fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag"}
|
||||
|
||||
rules := strings.Split(filterEntryRules, "\n")
|
||||
for i, rule := range rules {
|
||||
// Check if rule starts with a valid fieldName
|
||||
idx := slices.IndexFunc(fieldNames, func(fieldName string) bool { return strings.HasPrefix(rule, fieldName) })
|
||||
if idx == -1 {
|
||||
return locale.NewLocalizedError("error.settings_"+filterType+"_rule_fieldname_invalid", i+1, "'"+strings.Join(fieldNames, "', '")+"'")
|
||||
}
|
||||
fieldName := fieldNames[idx]
|
||||
fieldRegEx, _ := strings.CutPrefix(rule, fieldName)
|
||||
|
||||
// Check if regex begins with a =
|
||||
if !strings.HasPrefix(fieldRegEx, "=") {
|
||||
return locale.NewLocalizedError("error.settings_"+filterType+"_rule_separator_required", i+1)
|
||||
}
|
||||
fieldRegEx = strings.TrimPrefix(fieldRegEx, "=")
|
||||
|
||||
if fieldRegEx == "" {
|
||||
return locale.NewLocalizedError("error.settings_"+filterType+"_rule_regex_required", i+1)
|
||||
}
|
||||
|
||||
// Check if provided pattern is a valid RegEx
|
||||
if !IsValidRegex(fieldRegEx) {
|
||||
return locale.NewLocalizedError("error.settings_"+filterType+"_rule_invalid_regex", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user