mirror of
https://github.com/miniflux/v2.git
synced 2024-09-28 13:21:52 +02:00
Compare commits
154 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
faa70f3019 | ||
|
cfe410f202 | ||
|
c2ac2bfb83 | ||
|
c326d5574b | ||
|
f1c8c060c0 | ||
|
6944fb1e50 | ||
|
7d21298fab | ||
|
95201fc5cf | ||
|
5e335995e1 | ||
|
70f126fc5a | ||
|
38cdc4d3df | ||
|
4ab1cdd2e9 | ||
|
f3e48505df | ||
|
60c75ab3b6 | ||
|
349f040921 | ||
|
74376cd33c | ||
|
2a4d2985c4 | ||
|
907941394b | ||
|
88ea0ade3e | ||
|
fcf9fde118 | ||
|
5c38688783 | ||
|
e0850fc648 | ||
|
8708a109b3 | ||
|
0fe787bb93 | ||
|
2dffcfeadc | ||
|
4bbc12e3b2 | ||
|
3e0e8dda2b | ||
|
eb4bca6eb7 | ||
|
810b351772 | ||
|
89ff33ddd0 | ||
|
f3a5a3ee14 | ||
|
e98e16e45a | ||
|
eb057d0415 | ||
|
fa51c3ead7 | ||
|
56d7e4d5e9 | ||
|
cc94ab704a | ||
|
9b8eabf036 | ||
|
a8ac3dec47 | ||
|
6feee555ba | ||
|
ee926e73cb | ||
|
ade412f453 | ||
|
6fb7e84ce1 | ||
|
770cc1dbb3 | ||
|
59dac15bdf | ||
|
da6aa36758 | ||
|
2a22fe6b75 | ||
|
bcbf9f4025 | ||
|
569529d73b | ||
|
31cb06026d | ||
|
d048d59d39 | ||
|
4f55361f5f | ||
|
37309adbc0 | ||
|
92f3dc26e4 | ||
|
f6dc952551 | ||
|
29387f2d60 | ||
|
c0f6e32a99 | ||
|
11cafec863 | ||
|
8cfe77a3cd | ||
|
8d4d092cd7 | ||
|
968355f9b9 | ||
|
3ca52c7f7f | ||
|
2e856a6bf0 | ||
|
36c25e7689 | ||
|
cb97d4a1a8 | ||
|
79ea9e28b5 | ||
|
f847c3e754 | ||
|
4ca19d123a | ||
|
90ef864edd | ||
|
01133c586f | ||
|
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 |
@ -1,7 +1,7 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go:1.22
|
||||
image: mcr.microsoft.com/devcontainers/go:1.23
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
|
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -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
|
||||
|
2
.github/workflows/build_binaries.yml
vendored
2
.github/workflows/build_binaries.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
check-latest: true
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
100
.github/workflows/docker.yml
vendored
100
.github/workflows/docker.yml
vendored
@ -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' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
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' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
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' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
@ -101,19 +77,21 @@ jobs:
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
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
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
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 }}
|
||||
|
6
.github/workflows/linters.yml
vendored
6
.github/workflows/linters.yml
vendored
@ -28,12 +28,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.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
|
||||
with:
|
||||
version: "2023.1.7"
|
||||
version: "2024.1.1"
|
||||
install-go: false
|
||||
|
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.22.x"]
|
||||
go-version: ["1.23.x"]
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@ -43,7 +43,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Postgres client
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
miniflux-*
|
||||
./*.sha256
|
||||
./miniflux
|
||||
*.rpm
|
||||
*.deb
|
||||
.idea
|
||||
.vscode
|
||||
*.deb
|
||||
*.rpm
|
||||
miniflux-*
|
134
ChangeLog
134
ChangeLog
@ -1,3 +1,137 @@
|
||||
Version 2.2.1 (September 28, 2024)
|
||||
----------------------------------
|
||||
|
||||
* refactor: split processor package into smaller files
|
||||
* fix(mediaproxy): forward client user-agent to origin to bypass bot protection
|
||||
* fix: use root URL to generate absolute proxy URL
|
||||
* fix: remove progression save on shared entry
|
||||
* fix: add datasource variable and upgrade depecrated panels on the Grafana dashboard
|
||||
* feat(locale): update zh_CN translations
|
||||
* feat(locale): update Ukrainian translations
|
||||
* feat(locale): update Spanish translations
|
||||
* feat(locale): update Dutch translations
|
||||
* feat: use Bilibili API instead of web scraping to get videos watch time
|
||||
* feat: add pagination to shared entries listing
|
||||
* feat: add button to show only starred entries per category
|
||||
* build(deps): bump `golang.org/x/term` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.22.0` to `0.23.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.9.2` to `1.10.0`
|
||||
* build(deps): bump `github.com/prometheus/client_golang` from `1.20.3` to `1.20.4`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.10.2` to `0.11.2`
|
||||
* build: update go.mod to Go 1.23
|
||||
* build: bump devcontainer version to go 1.23
|
||||
|
||||
Version 2.2.0 (August 18, 2024)
|
||||
-------------------------------
|
||||
|
||||
* refactor: simplify Youtube feeds discovery
|
||||
* fix(integration): define content encoding explicitly when sending article body to Readeck
|
||||
* fix(fever): correct sorting direction when using `max_id` argument
|
||||
* fix(client): Return `nil` and error if endpoint is an empty string
|
||||
* fix: video poster image URL is encoded twice when using `MEDIA_PROXY_MODE=all`
|
||||
* fix: use `BASE_URL` instead of `r.Host` to generate absolute media proxy URL
|
||||
* fix: panic during YouTube channel feed discovery
|
||||
* fix: honor `hide_globally` when creating a new feed through the api
|
||||
* fix: align pagination correctly on small screens with non-English text
|
||||
* fix: `store.GetEnclosure()` should return `nil` if no rows are returned
|
||||
* feat(locale): update Turkish translations
|
||||
* feat(locale): update French translations
|
||||
* feat(locale): update Chinese` translations
|
||||
* feat(integration): add ntfy integration
|
||||
* feat(api): add API routes `/v1/enclosures/{enclosureID}`
|
||||
* feat: validate `OAUTH2_PROVIDER` config option value
|
||||
* feat: remove YouTube video page subscription finder because `meta[itemprop="channelId"]` no longer exists
|
||||
* feat: remove well-known URL parameter trackers
|
||||
* feat: mark media as read when playback reaches 90%
|
||||
* feat: change log level to info when running migrations
|
||||
* feat: allow customizing the display name of the OpenID Connect provider
|
||||
* feat: add support for `base` HTML element when discovering feeds
|
||||
* feat: add support for `aside` HTML element in entry content
|
||||
* feat: Add option to disable local auth form
|
||||
* feat: add license info to Javascript files for LibreJS compatibility
|
||||
* feat: add `FETCH_BILIBILI_WATCH_TIME` config option
|
||||
* docs: update links to filtering rules
|
||||
* chore: avoid using legacy key/value format in Dockerfile
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.21.0` to `0.22.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.27.0` to `0.28.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.36` to `2.20.37`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build: update GitHub Actions to Go 1.23
|
||||
* build: publish OCI images only if `PUBLISH_DOCKER_IMAGES=true`
|
||||
* build: bump Alpine Linux build image to v3.20
|
||||
* build: add sha256 checksum file for published binaries
|
||||
|
||||
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)
|
||||
------------------------------
|
||||
|
||||
|
30
Makefile
30
Makefile
@ -6,7 +6,7 @@ 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
|
||||
DOCKER_PLATFORM := amd64
|
||||
|
||||
export PGPASSWORD := postgres
|
||||
|
||||
@ -51,33 +51,43 @@ miniflux-no-pie:
|
||||
|
||||
linux-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-arm64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv7:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv6:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv5:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-arm64:
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
freebsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
@ sha256sum $(APP)-$@.exe > $(APP)-$@.exe.sha256
|
||||
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
|
||||
|
||||
@ -104,7 +114,7 @@ run:
|
||||
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
@ -163,15 +173,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
|
||||
|
@ -613,6 +613,28 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
return feedIcon, nil
|
||||
}
|
||||
|
||||
// Enclosure fetches a specific enclosure.
|
||||
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var enclosure *Enclosure
|
||||
if err := json.NewDecoder(body).Decode(&enclosure); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error(%v)", err)
|
||||
}
|
||||
|
||||
return enclosure, nil
|
||||
}
|
||||
|
||||
// UpdateEnclosure updates an enclosure.
|
||||
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildFilterQueryString(path string, filter *Filter) string {
|
||||
if filter != nil {
|
||||
values := url.Values{}
|
||||
@ -685,6 +707,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
|
||||
values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10))
|
||||
}
|
||||
|
||||
if filter.GloballyVisible {
|
||||
values.Set("globally_visible", "true")
|
||||
}
|
||||
|
||||
for _, status := range filter.Statuses {
|
||||
values.Add("status", status)
|
||||
}
|
||||
|
@ -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.
|
||||
@ -244,6 +248,11 @@ type Enclosure struct {
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
type EnclosureUpdateRequest struct {
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
// Enclosures represents a list of attachments.
|
||||
@ -274,6 +283,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
// EntryResultSet represents the response when fetching entries.
|
||||
|
@ -27,6 +27,7 @@ var (
|
||||
ErrServerError = errors.New("miniflux: internal server error")
|
||||
ErrNotFound = errors.New("miniflux: resource not found")
|
||||
ErrBadRequest = errors.New("miniflux: bad request")
|
||||
ErrEmptyEndpoint = errors.New("miniflux: empty endpoint provided")
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
@ -62,6 +63,9 @@ func (r *request) Delete(path string) error {
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
if r.endpoint[len(r.endpoint)-1:] == "/" {
|
||||
r.endpoint = r.endpoint[:len(r.endpoint)-1]
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
53
go.mod
53
go.mod
@ -1,48 +1,49 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.22
|
||||
// +heroku goVersion go1.23
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.1
|
||||
github.com/PuerkitoBio/goquery v1.10.0
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/coreos/go-oidc/v3 v3.10.0
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/andybalholm/brotli v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/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.20.4
|
||||
github.com/tdewolff/minify/v2 v2.20.37
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/net v0.29.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/term v0.24.0
|
||||
golang.org/x/text v0.18.0
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.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
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
107
go.sum
107
go.sum
@ -1,72 +1,75 @@
|
||||
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.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/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-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/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
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.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
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=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.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_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=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/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.37 h1:Q97cx4STXCh1dlWDlNHZniE8BJ2EBL0+2b0n92BJQhw=
|
||||
github.com/tdewolff/minify/v2 v2.20.37/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.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
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 +77,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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.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,34 +91,28 @@ 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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.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.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
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=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
|
@ -72,6 +72,8 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
|
||||
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
@ -68,10 +67,14 @@ func TestIncorrectEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
client := miniflux.NewClient("incorrect url")
|
||||
_, err := client.Users()
|
||||
if err == nil {
|
||||
if _, err := client.Users(); err == nil {
|
||||
t.Fatal(`Using an incorrect URL should raise an error`)
|
||||
}
|
||||
|
||||
client = miniflux.NewClient("")
|
||||
if _, err := client.Users(); err == nil {
|
||||
t.Fatal(`Using an empty URL should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckEndpoint(t *testing.T) {
|
||||
@ -1987,6 +1990,176 @@ func TestGetAllEntriesEndpointWithFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
HideGlobally: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedIDEntry.HideGlobally != true {
|
||||
t.Fatalf(`Expected feed to have globally_hidden set to true, was false.`)
|
||||
}
|
||||
|
||||
/* Not filtering on GloballyVisible should return all entries */
|
||||
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feedEntries.Entries) == 0 {
|
||||
t.Fatalf(`Expected entries but response contained none.`)
|
||||
}
|
||||
|
||||
/* Feed is hidden globally, so this should be empty */
|
||||
globallyVisibleEntries, err := regularUserClient.Entries(&miniflux.Filter{GloballyVisible: true})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(globallyVisibleEntries.Entries) != 0 {
|
||||
t.Fatalf(`Expected no entries, got %d`, len(globallyVisibleEntries.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
var enclosure *miniflux.Enclosure
|
||||
for _, entry := range result.Entries {
|
||||
if len(entry.Enclosures) > 0 {
|
||||
enclosure = entry.Enclosures[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
t.Skip(`Skipping test, missing enclosure in feed.`)
|
||||
}
|
||||
|
||||
err = regularUserClient.UpdateEnclosure(enclosure.ID, &miniflux.EnclosureUpdateRequest{
|
||||
MediaProgression: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updatedEnclosure, err := regularUserClient.Enclosure(enclosure.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedEnclosure.MediaProgression != 20 {
|
||||
t.Fatalf(`Failed to update media_progression, expected %d but got %d`, 20, updatedEnclosure.MediaProgression)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
var expectedEnclosure *miniflux.Enclosure
|
||||
for _, entry := range result.Entries {
|
||||
if len(entry.Enclosures) > 0 {
|
||||
expectedEnclosure = entry.Enclosures[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if expectedEnclosure == nil {
|
||||
t.Skip(`Skipping test, missing enclosure in feed.`)
|
||||
}
|
||||
|
||||
enclosure, err := regularUserClient.Enclosure(expectedEnclosure.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if enclosure.ID != expectedEnclosure.ID {
|
||||
t.Fatalf(`Invalid enclosureID, got %d while expecting %d`, enclosure.ID, expectedEnclosure.ID)
|
||||
}
|
||||
|
||||
if _, err = regularUserClient.Enclosure(99999); err == nil {
|
||||
t.Fatalf(`Fetching an inexisting enclosure should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryEndpoints(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
79
internal/api/enclosure.go
Normal file
79
internal/api/enclosure.go
Normal file
@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(h.router)
|
||||
|
||||
json.OK(w, r, enclosure)
|
||||
}
|
||||
|
||||
func (h *handler) updateEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
|
||||
var enclosureUpdateRequest model.EnclosureUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
|
||||
if err := h.store.UpdateEnclosure(enclosure); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
@ -8,10 +8,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
@ -20,7 +18,6 @@ import (
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
@ -36,19 +33,9 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
|
||||
return
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
for i := range entry.Enclosures {
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
json.OK(w, r, entry)
|
||||
}
|
||||
@ -149,6 +136,15 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
|
||||
if globallyVisible {
|
||||
builder.WithGloballyVisible()
|
||||
}
|
||||
}
|
||||
|
||||
configureFilters(builder, r)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
@ -164,7 +160,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
}
|
||||
|
||||
for i := range entries {
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entries[i].Content)
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
|
@ -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
|
||||
|
@ -4,6 +4,7 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -88,6 +89,23 @@ func Parse() {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != "" {
|
||||
if oauth2Provider != "oidc" && oauth2Provider != "google" {
|
||||
printErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are "google" or "oidc")`, oauth2Provider))
|
||||
}
|
||||
}
|
||||
|
||||
if config.Opts.DisableLocalAuth() {
|
||||
switch {
|
||||
case config.Opts.OAuth2Provider() == "" && config.Opts.AuthProxyHeader() == "":
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source"))
|
||||
case config.Opts.OAuth2Provider() != "" && !config.Opts.IsOAuth2UserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled"))
|
||||
case config.Opts.AuthProxyHeader() != "" && !config.Opts.IsAuthProxyUserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
fmt.Print(config.Opts)
|
||||
return
|
||||
|
@ -259,6 +259,29 @@ func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomBaseURLWithCustomPort(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://example.org:88/folder/")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
if opts.BaseURL() != "http://example.org:88/folder" {
|
||||
t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
|
||||
}
|
||||
|
||||
if opts.RootURL() != "http://example.org:88" {
|
||||
t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
|
||||
}
|
||||
|
||||
if opts.BasePath() != "/folder" {
|
||||
t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLWithoutScheme(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "example.org/folder/")
|
||||
@ -2021,6 +2044,42 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBilibiliWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_BILIBILI_WATCH_TIME", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.FetchBilibiliWatchTime()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected FETCH_BILIBILI_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
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,8 @@ const (
|
||||
defaultMediaResourceTypes = "image"
|
||||
defaultMediaProxyURL = ""
|
||||
defaultFilterEntryMaxAgeDays = 0
|
||||
defaultFetchBilibiliWatchTime = false
|
||||
defaultFetchNebulaWatchTime = false
|
||||
defaultFetchOdyseeWatchTime = false
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||
@ -67,7 +69,9 @@ const (
|
||||
defaultOAuth2ClientSecret = ""
|
||||
defaultOAuth2RedirectURL = ""
|
||||
defaultOAuth2OidcDiscoveryEndpoint = ""
|
||||
defaultOauth2OidcProviderName = "OpenID Connect"
|
||||
defaultOAuth2Provider = ""
|
||||
defaultDisableLocalAuth = false
|
||||
defaultPocketConsumerKey = ""
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientMaxBodySize = 15
|
||||
@ -140,6 +144,8 @@ type Options struct {
|
||||
mediaProxyMode string
|
||||
mediaProxyResourceTypes []string
|
||||
mediaProxyCustomURL string
|
||||
fetchBilibiliWatchTime bool
|
||||
fetchNebulaWatchTime bool
|
||||
fetchOdyseeWatchTime bool
|
||||
fetchYouTubeWatchTime bool
|
||||
filterEntryMaxAgeDays int
|
||||
@ -149,7 +155,9 @@ type Options struct {
|
||||
oauth2ClientSecret string
|
||||
oauth2RedirectURL string
|
||||
oidcDiscoveryEndpoint string
|
||||
oidcProviderName string
|
||||
oauth2Provider string
|
||||
disableLocalAuth bool
|
||||
pocketConsumerKey string
|
||||
httpClientTimeout int
|
||||
httpClientMaxBodySize int64
|
||||
@ -216,6 +224,8 @@ func NewOptions() *Options {
|
||||
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||
fetchBilibiliWatchTime: defaultFetchBilibiliWatchTime,
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||
@ -224,7 +234,9 @@ func NewOptions() *Options {
|
||||
oauth2ClientSecret: defaultOAuth2ClientSecret,
|
||||
oauth2RedirectURL: defaultOAuth2RedirectURL,
|
||||
oidcDiscoveryEndpoint: defaultOAuth2OidcDiscoveryEndpoint,
|
||||
oidcProviderName: defaultOauth2OidcProviderName,
|
||||
oauth2Provider: defaultOAuth2Provider,
|
||||
disableLocalAuth: defaultDisableLocalAuth,
|
||||
pocketConsumerKey: defaultPocketConsumerKey,
|
||||
httpClientTimeout: defaultHTTPClientTimeout,
|
||||
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
|
||||
@ -445,11 +457,21 @@ func (o *Options) OIDCDiscoveryEndpoint() string {
|
||||
return o.oidcDiscoveryEndpoint
|
||||
}
|
||||
|
||||
// OIDCProviderName returns the OAuth2 OIDC provider's display name
|
||||
func (o *Options) OIDCProviderName() string {
|
||||
return o.oidcProviderName
|
||||
}
|
||||
|
||||
// OAuth2Provider returns the name of the OAuth2 provider configured.
|
||||
func (o *Options) OAuth2Provider() string {
|
||||
return o.oauth2Provider
|
||||
}
|
||||
|
||||
// DisableLocalAUth returns true if the local user database should not be used to authenticate users
|
||||
func (o *Options) DisableLocalAuth() bool {
|
||||
return o.disableLocalAuth
|
||||
}
|
||||
|
||||
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
|
||||
func (o *Options) HasHSTS() bool {
|
||||
return o.hsts
|
||||
@ -486,12 +508,24 @@ 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 {
|
||||
return o.fetchOdyseeWatchTime
|
||||
}
|
||||
|
||||
// FetchBilibiliWatchTime returns true if the Bilibili video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchBilibiliWatchTime() bool {
|
||||
return o.fetchBilibiliWatchTime
|
||||
}
|
||||
|
||||
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (o *Options) MediaProxyMode() string {
|
||||
return o.mediaProxyMode
|
||||
@ -647,7 +681,9 @@ 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,
|
||||
"FETCH_BILIBILI_WATCH_TIME": o.fetchBilibiliWatchTime,
|
||||
"HTTPS": o.HTTPS,
|
||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||
"HTTP_CLIENT_PROXY": o.httpClientProxy,
|
||||
@ -672,9 +708,11 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"OAUTH2_CLIENT_ID": o.oauth2ClientID,
|
||||
"OAUTH2_CLIENT_SECRET": redactSecretValue(o.oauth2ClientSecret, redactSecret),
|
||||
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": o.oidcDiscoveryEndpoint,
|
||||
"OAUTH2_OIDC_PROVIDER_NAME": o.oidcProviderName,
|
||||
"OAUTH2_PROVIDER": o.oauth2Provider,
|
||||
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
|
||||
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
|
||||
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
|
||||
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
|
||||
"POLLING_FREQUENCY": o.pollingFrequency,
|
||||
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
|
||||
|
@ -225,8 +225,12 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_OIDC_PROVIDER_NAME":
|
||||
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
case "DISABLE_LOCAL_AUTH":
|
||||
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
|
||||
case "HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
|
||||
case "HTTP_CLIENT_MAX_BODY_SIZE":
|
||||
@ -259,6 +263,10 @@ 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_BILIBILI_WATCH_TIME":
|
||||
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
|
||||
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":
|
||||
|
@ -32,7 +32,7 @@ func Migrate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
|
||||
slog.Debug("Running database migrations",
|
||||
slog.Info("Running database migrations",
|
||||
slog.Int("current_version", currentVersion),
|
||||
slog.Int("latest_version", schemaVersion),
|
||||
)
|
||||
|
@ -882,4 +882,64 @@ 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
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_topic text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_api_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_username text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_password text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_icon_url text default '';
|
||||
|
||||
ALTER TABLE feeds ADD COLUMN ntfy_enabled bool default 'f';
|
||||
ALTER TABLE feeds ADD COLUMN ntfy_priority int default '3';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
@ -247,7 +247,6 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithLimit(50)
|
||||
builder.WithSorting("id", model.DefaultSortingDirection)
|
||||
|
||||
switch {
|
||||
case request.HasQueryParam(r, "since_id"):
|
||||
@ -258,6 +257,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("since_id", sinceID),
|
||||
)
|
||||
builder.AfterEntryID(sinceID)
|
||||
builder.WithSorting("id", "ASC")
|
||||
}
|
||||
case request.HasQueryParam(r, "max_id"):
|
||||
maxID := request.QueryInt64Param(r, "max_id", 0)
|
||||
@ -324,7 +324,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
FeedID: entry.FeedID,
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content),
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
|
||||
URL: entry.URL,
|
||||
IsSaved: isSaved,
|
||||
IsRead: isRead,
|
||||
|
@ -24,7 +24,6 @@ import (
|
||||
mff "miniflux.app/v2/internal/reader/handler"
|
||||
mfs "miniflux.app/v2/internal/reader/subscription"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -1003,28 +1002,18 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
categories = append(categories, userStarred)
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
for i := range entry.Enclosures {
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
contentItems[i] = contentItem{
|
||||
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 {
|
||||
|
57
internal/integration/betula/betula.go
Normal file
57
internal/integration/betula/betula.go
Normal file
@ -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"
|
||||
@ -15,10 +16,12 @@ import (
|
||||
"miniflux.app/v2/internal/integration/linkwarden"
|
||||
"miniflux.app/v2/internal/integration/matrixbot"
|
||||
"miniflux.app/v2/internal/integration/notion"
|
||||
"miniflux.app/v2/internal/integration/ntfy"
|
||||
"miniflux.app/v2/internal/integration/nunuxkeeper"
|
||||
"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 +34,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 +386,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 +404,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.
|
||||
@ -425,6 +471,28 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.NtfyEnabled && feed.NtfyEnabled {
|
||||
slog.Debug("Sending new entries to Ntfy",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := ntfy.NewClient(
|
||||
userIntegrations.NtfyURL,
|
||||
userIntegrations.NtfyTopic,
|
||||
userIntegrations.NtfyAPIToken,
|
||||
userIntegrations.NtfyUsername,
|
||||
userIntegrations.NtfyPassword,
|
||||
userIntegrations.NtfyIconURL,
|
||||
feed.NtfyPriority,
|
||||
)
|
||||
|
||||
if err := client.SendMessages(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Ntfy", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
|
||||
for _, entry := range entries {
|
||||
|
@ -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()
|
||||
|
120
internal/integration/ntfy/ntfy.go
Normal file
120
internal/integration/ntfy/ntfy.go
Normal file
@ -0,0 +1,120 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ntfy // import "miniflux.app/v2/internal/integration/ntfy"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultNtfyURL = "https://ntfy.sh"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string
|
||||
ntfyPriority int
|
||||
}
|
||||
|
||||
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyPriority int) *Client {
|
||||
if ntfyURL == "" {
|
||||
ntfyURL = defaultNtfyURL
|
||||
}
|
||||
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyPriority}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
ntfyMessage := &ntfyMessage{
|
||||
Topic: c.ntfyTopic,
|
||||
Message: entry.Title,
|
||||
Title: feed.Title,
|
||||
Priority: c.ntfyPriority,
|
||||
Click: entry.URL,
|
||||
}
|
||||
|
||||
if c.ntfyIconURL != "" {
|
||||
ntfyMessage.Icon = c.ntfyIconURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending Ntfy message",
|
||||
slog.String("url", c.ntfyURL),
|
||||
slog.String("topic", c.ntfyTopic),
|
||||
slog.Int("priority", ntfyMessage.Priority),
|
||||
slog.String("message", ntfyMessage.Message),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if err := c.makeRequest(ntfyMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload any) error {
|
||||
requestBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.ntfyURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#access-tokens
|
||||
if c.ntfyApiToken != "" {
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ntfyApiToken))
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#username-password
|
||||
if c.ntfyUsername != "" && c.ntfyPassword != "" {
|
||||
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("ntfy: incorrect response status code %d for url %s", response.StatusCode, c.ntfyURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#publish-as-json
|
||||
type ntfyMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // https://docs.ntfy.sh/publish/#icons
|
||||
Click string `json:"click,omitempty"`
|
||||
Actions []ntfyAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#action-buttons
|
||||
type ntfyAction struct {
|
||||
Action string `json:"action"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
@ -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
|
||||
}
|
||||
|
62
internal/integration/pinboard/post.go
Normal file
62
internal/integration/pinboard/post.go
Normal file
@ -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)
|
||||
}
|
78
internal/integration/raindrop/raindrop.go
Normal file
78
internal/integration/raindrop/raindrop.go
Normal file
@ -0,0 +1,78 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
collectionID string
|
||||
tags []string
|
||||
}
|
||||
|
||||
func NewClient(token, collectionID, tags string) *Client {
|
||||
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
|
||||
}
|
||||
|
||||
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
|
||||
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
if c.token == "" {
|
||||
return fmt.Errorf("raindrop: missing token")
|
||||
}
|
||||
|
||||
var request *http.Request
|
||||
requestBodyJson, err := json.Marshal(&raindrop{
|
||||
Link: entryURL,
|
||||
Title: entryTitle,
|
||||
Collection: collection{Id: c.collectionID},
|
||||
Tags: c.tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type collection struct {
|
||||
Id string `json:"$id"`
|
||||
}
|
@ -91,7 +91,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
|
||||
contentBodyHeader, err := json.Marshal(&partContentHeader{
|
||||
Url: entryURL,
|
||||
ContentHeader: contentHeader{ContentType: "text/html"},
|
||||
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Alle als gelesen markieren",
|
||||
"menu.show_all_entries": "Zeige alle Artikel",
|
||||
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
|
||||
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
|
||||
"menu.refresh_feed": "Aktualisieren",
|
||||
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
|
||||
"menu.edit_feed": "Bearbeiten",
|
||||
@ -55,7 +56,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",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Einstellungen",
|
||||
"page.settings.link_google_account": "Google-Konto verknüpfen",
|
||||
"page.settings.unlink_google_account": "Verknüpfung mit Google-Konto entfernen",
|
||||
"page.settings.link_oidc_account": "OpenID-Connect-Konto verknüpfen",
|
||||
"page.settings.unlink_oidc_account": "Verknüpfung mit OpenID-Connect-Konto entfernen",
|
||||
"page.settings.link_oidc_account": "%s-Konto verknüpfen",
|
||||
"page.settings.unlink_oidc_account": "Verknüpfung mit %s-Konto entfernen",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Aktionen",
|
||||
"page.settings.webauthn.passkey_name": "Name des Passkeys",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Anmeldung",
|
||||
"page.login.google_signin": "Anmeldung mit Google",
|
||||
"page.login.oidc_signin": "Anmeldung mit OpenID Connect",
|
||||
"page.login.oidc_signin": "Anmeldung mit %s",
|
||||
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
|
||||
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
|
||||
"page.integrations.title": "Dienste",
|
||||
@ -258,6 +261,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 +303,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 +328,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",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.rules": "Regeln",
|
||||
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Standard-Startseite",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
|
||||
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Geheimnis",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API-Schlüsselbezeichnung",
|
||||
"form.submit.loading": "Lade...",
|
||||
"form.submit.saving": "Speichern...",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
|
||||
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
|
||||
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
|
||||
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
|
||||
"menu.refresh_feed": "Ανανέωση",
|
||||
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
|
||||
"menu.edit_feed": "Επεξεργασία",
|
||||
@ -55,7 +56,9 @@
|
||||
"search.label": "Αναζήτηση",
|
||||
"search.placeholder": "Αναζήτηση...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Επόμενη",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Προηγούμενη",
|
||||
"entry.status.unread": "Μη αναγνωσμένο",
|
||||
"entry.status.read": "Αναγνωσμένο",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Ρυθμίσεις",
|
||||
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
|
||||
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
|
||||
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου %s",
|
||||
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Είσοδος",
|
||||
"page.login.google_signin": "Συνδεθείτε με τo Google",
|
||||
"page.login.oidc_signin": "Συνδεθείτε με το OpenID Connect",
|
||||
"page.login.oidc_signin": "Συνδεθείτε με το %s",
|
||||
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
|
||||
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
|
||||
"page.integrations.title": "Ενσωμάτωση",
|
||||
@ -258,6 +261,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 +303,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 +330,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": "Όνομα Χρήστη ροής",
|
||||
@ -339,6 +352,13 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.category.label.title": "Τίτλος",
|
||||
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.user.label.username": "Χρήστης",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
|
||||
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
|
||||
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Ετικέτα κλειδιού API",
|
||||
"form.submit.loading": "Φόρτωση...",
|
||||
"form.submit.saving": "Αποθήκευση...",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -40,6 +40,7 @@
|
||||
"menu.mark_page_as_read": "Mark this page as read",
|
||||
"menu.mark_all_as_read": "Mark all as read",
|
||||
"menu.show_all_entries": "Show all entries",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.show_only_unread_entries": "Show only unread entries",
|
||||
"menu.refresh_feed": "Refresh",
|
||||
"menu.refresh_all_feeds": "Refresh all feeds in the background",
|
||||
@ -55,7 +56,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",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Settings",
|
||||
"page.settings.link_google_account": "Link my Google account",
|
||||
"page.settings.unlink_google_account": "Unlink my Google account",
|
||||
"page.settings.link_oidc_account": "Link my OpenID Connect account",
|
||||
"page.settings.unlink_oidc_account": "Unlink my OpenID Connect account",
|
||||
"page.settings.link_oidc_account": "Link my %s account",
|
||||
"page.settings.unlink_oidc_account": "Unlink my %s account",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Sign In",
|
||||
"page.login.google_signin": "Sign in with Google",
|
||||
"page.login.oidc_signin": "Sign in with OpenID Connect",
|
||||
"page.login.oidc_signin": "Sign in with %s",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.integrations.title": "Integrations",
|
||||
@ -258,6 +261,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 +303,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 +328,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",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.disabled": "Do not refresh this feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Hide entries in global unread list",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Default home page",
|
||||
"form.prefs.label.categories_sorting_order": "Categories sorting",
|
||||
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Loading…",
|
||||
"form.submit.saving": "Saving…",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Saltar al contenido",
|
||||
"confirm.question": "¿Estás seguro?",
|
||||
"confirm.question.refresh": "¿Quieres forzar la actualización?",
|
||||
"confirm.yes": "sí",
|
||||
@ -9,8 +9,8 @@
|
||||
"action.save": "Guardar",
|
||||
"action.or": "o",
|
||||
"action.cancel": "Cancelar",
|
||||
"action.remove": "Quitar",
|
||||
"action.remove_feed": "Quitar esta fuente",
|
||||
"action.remove": "Eliminar",
|
||||
"action.remove_feed": "Eliminar esta fuente",
|
||||
"action.update": "Actualizar",
|
||||
"action.edit": "Editar",
|
||||
"action.download": "Descargar",
|
||||
@ -19,8 +19,8 @@
|
||||
"action.home_screen": "Añadir a la pantalla principal",
|
||||
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
|
||||
"tooltip.logged_user": "Registrado como %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "Menú",
|
||||
"menu.home_page": "Página de inicio",
|
||||
"menu.unread": "No leídos",
|
||||
"menu.starred": "Marcadores",
|
||||
"menu.history": "Historial",
|
||||
@ -41,8 +41,9 @@
|
||||
"menu.mark_all_as_read": "Marcar todos como leídos",
|
||||
"menu.show_all_entries": "Mostrar todos los artículos",
|
||||
"menu.show_only_unread_entries": "Mostrar solo los artículos no leídos",
|
||||
"menu.show_only_starred_entries": "Mostrar solo los artículos marcados con una estrella",
|
||||
"menu.refresh_feed": "Refrescar",
|
||||
"menu.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
|
||||
"menu.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
|
||||
"menu.edit_feed": "Editar",
|
||||
"menu.edit_category": "Editar",
|
||||
"menu.add_feed": "Agregar fuente",
|
||||
@ -54,8 +55,10 @@
|
||||
"menu.shared_entries": "Artículos compartidos",
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Búsqueda...",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "Buscar",
|
||||
"pagination.last": "Último",
|
||||
"pagination.next": "Siguiente",
|
||||
"pagination.first": "Primero",
|
||||
"pagination.previous": "Anterior",
|
||||
"entry.status.unread": "No leído",
|
||||
"entry.status.read": "Leído",
|
||||
@ -90,22 +93,22 @@
|
||||
"entry.tags.label": "Etiquetas:",
|
||||
"page.shared_entries.title": "Artículos compartidos",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d artículo compartido",
|
||||
"%d artículos compartidos"
|
||||
],
|
||||
"page.unread.title": "No leídos",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d artículo no leído",
|
||||
"%d artículos no leídos"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d artículo en total",
|
||||
"%d artículos en total"
|
||||
],
|
||||
"page.starred.title": "Marcadores",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d artículo marcado",
|
||||
"%d artículos marcados"
|
||||
],
|
||||
"page.categories.title": "Categorías",
|
||||
"page.categories.no_feed": "Sin fuente.",
|
||||
@ -116,17 +119,17 @@
|
||||
"Hay %d fuentes."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d categoría",
|
||||
"%d categorías"
|
||||
],
|
||||
"page.new_category.title": "Nueva categoría",
|
||||
"page.new_user.title": "Nuevo usuario",
|
||||
"page.edit_category.title": "Editar categoría: %s",
|
||||
"page.edit_user.title": "Editar usuario: %s",
|
||||
"page.feeds.title": "Fuentes",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Categoría: %s",
|
||||
"page.feeds.last_check": "Última verificación:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Próxima verificación:",
|
||||
"page.feeds.read_counter": "Número de artículos leídos",
|
||||
"page.feeds.error_count": [
|
||||
"%d error",
|
||||
@ -134,15 +137,15 @@
|
||||
],
|
||||
"page.history.title": "Historial",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d artículo leído",
|
||||
"%d artículos leídos"
|
||||
],
|
||||
"page.import.title": "Importar",
|
||||
"page.search.title": "Resultados de la búsqueda",
|
||||
"page.about.title": "Acerca de",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.version": "Versión:",
|
||||
"page.about.build_date": "Fecha de construcción:",
|
||||
"page.about.build_date": "Fecha de compilación:",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.license": "Licencia:",
|
||||
"page.about.global_config_options": "Opciones de configuración global",
|
||||
@ -187,7 +190,7 @@
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Abrir enlace de comentarios en la pestaña actual",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
|
||||
@ -208,24 +211,24 @@
|
||||
"page.settings.title": "Ajustes",
|
||||
"page.settings.link_google_account": "Vincular mi cuenta de Google",
|
||||
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
|
||||
"page.settings.link_oidc_account": "Vincular mi cuenta de OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de OpenID Connect",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.link_oidc_account": "Vincular mi cuenta de %s",
|
||||
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
|
||||
"page.settings.webauthn.passkeys": "Claves de acceso",
|
||||
"page.settings.webauthn.actions": "Accioness",
|
||||
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
|
||||
"page.settings.webauthn.added_on": "Añadido",
|
||||
"page.settings.webauthn.last_seen_on": "Usado por última vez",
|
||||
"page.settings.webauthn.register": "Registrar clave de acceso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de paso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Eliminar %d clave de paso",
|
||||
"Eliminar %d claves de paso"
|
||||
"Eliminar %d clave de acceso",
|
||||
"Eliminar %d claves de acceso"
|
||||
],
|
||||
"page.login.title": "Iniciar sesión",
|
||||
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
|
||||
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de OpenID Connect",
|
||||
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de %s",
|
||||
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
|
||||
"page.integrations.title": "Integraciones",
|
||||
"page.integration.miniflux_api": "API de Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Extremo de API",
|
||||
@ -253,11 +256,12 @@
|
||||
"page.offline.title": "Modo offline",
|
||||
"page.offline.message": "Estas desconectado",
|
||||
"page.offline.refresh_page": "Intenta actualizar la página",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"page.webauthn_rename.title": "Renombrar clave de acceso",
|
||||
"alert.no_shared_entry": "No hay artículos compartidos.",
|
||||
"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 +296,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": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
|
||||
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Regla de mantenimiento no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
|
||||
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"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 +328,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",
|
||||
@ -324,21 +337,28 @@
|
||||
"form.feed.label.cookie": "Configurar las cookies",
|
||||
"form.feed.label.scraper_rules": "Reglas de extracción de información",
|
||||
"form.feed.label.rewrite_rules": "Reglas de reescribir",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
|
||||
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
|
||||
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
|
||||
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
|
||||
"form.feed.label.disabled": "No actualice este feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
|
||||
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioridad máxima a Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Prioridad alta a Ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Prioridad baja a Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.fieldset.rules": "Reglas",
|
||||
"form.feed.fieldset.network_settings": "Ajustes de red",
|
||||
"form.feed.fieldset.integration": "Servicios de terceros",
|
||||
"form.category.label.title": "Título",
|
||||
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.user.label.username": "Nombre de usuario",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Página de inicio por defecto",
|
||||
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Marcar como leído solo cuando la reproducción de audio/video alcance el 90%% de finalización",
|
||||
"form.prefs.label.mark_read_manually": "Marcar entradas como leídas manualmente",
|
||||
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
|
||||
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
|
||||
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
|
||||
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
|
||||
"form.import.label.file": "Archivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Guardar artículos en Betula",
|
||||
"form.integration.betula_url": "URL del servidor Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
"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",
|
||||
@ -406,12 +433,12 @@
|
||||
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
"form.integration.wallabag_password": "Contraseña de Wallabag",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.notion_activate": "Guardar entradas en Notion",
|
||||
"form.integration.notion_page_id": "ID de página de Notion",
|
||||
"form.integration.notion_token": "Token secreto de Notion",
|
||||
"form.integration.apprise_activate": "Enviar artículos a Apprise",
|
||||
"form.integration.apprise_url": "URL de la API de Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
|
||||
@ -422,26 +449,26 @@
|
||||
"form.integration.espial_endpoint": "Acceso API de Espial",
|
||||
"form.integration.espial_api_key": "Clave de API de Espial",
|
||||
"form.integration.espial_tags": "Etiquetas de Espial",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
|
||||
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram",
|
||||
"form.integration.telegram_bot_token": "Token de bot",
|
||||
"form.integration.telegram_chat_id": "ID de chat",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
|
||||
"form.integration.telegram_bot_disable_notification": "Deshabilitar notificación",
|
||||
"form.integration.telegram_bot_disable_buttons": "Deshabilitar botones",
|
||||
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_api_key": "Clave API de LinkAce",
|
||||
"form.integration.linkace_tags": "Etiquetas de LinkAce",
|
||||
"form.integration.linkace_is_private": "Marcar enlace como privado",
|
||||
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
|
||||
"form.integration.linkding_activate": "Enviar artículos a Linkding",
|
||||
"form.integration.linkding_endpoint": "Acceso API de Linkding",
|
||||
"form.integration.linkding_api_key": "Clave de API de Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
|
||||
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
|
||||
@ -451,23 +478,34 @@
|
||||
"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": "Guardar artículos en Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Colección ID",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"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",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Etiquetas de Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.shiori_activate": "Guardar artículos a Shiori",
|
||||
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
|
||||
"form.integration.shiori_username": "Nombre de usuario de Shiori",
|
||||
"form.integration.shiori_password": "Contraseña de Shiori",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
|
||||
"form.integration.shaarli_endpoint": "URL de Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
|
||||
"form.integration.webhook_activate": "Habilitar Webhook",
|
||||
"form.integration.webhook_url": "URL de Webhook",
|
||||
"form.integration.webhook_secret": "Secreto de Webhook",
|
||||
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
|
||||
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
|
||||
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
|
||||
"form.integration.ntfy_topic": "Tema Ntfy",
|
||||
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
|
||||
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
|
||||
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
|
||||
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.submit.loading": "Cargando...",
|
||||
"form.submit.saving": "Guardando...",
|
||||
@ -499,34 +537,43 @@
|
||||
"hace %d años"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
|
||||
"error.http_response_too_large": "La respuesta HTTP es demasiado grande. Puede aumentar el límite de tamaño de respuesta HTTP en la configuración global (requiere reiniciar el servidor).",
|
||||
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
|
||||
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
|
||||
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.tls_error": "Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.",
|
||||
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
|
||||
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
|
||||
"error.http_client_error": "Error cliente HTTP: %v.",
|
||||
"error.http_not_authorized": "El acceso a este sitio web no está autorizado. Podría ser un nombre de usuario o contraseña incorrectos.",
|
||||
"error.http_too_many_requests": "Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.",
|
||||
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.http_resource_not_found": "No se encuentra el recurso solicitado. Por favor, verifique la URL.",
|
||||
"error.http_internal_server_error": "El sitio web no está disponible en estos momentos debido a un error del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_service_unavailable": "El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_gateway_timeout": "El sitio web no está disponible en este momento debido a un error de tiempo de espera de la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_unexpected_status_code": "El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.database_error": "Error en la base de datos: %v.",
|
||||
"error.category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
|
||||
"error.duplicated_feed": "Este feed ya existe.",
|
||||
"error.unable_to_parse_feed": "No se puede analizar este feed: %v.",
|
||||
"error.feed_not_found": "Este feed no existe o no pertenece a este usuario.",
|
||||
"error.unable_to_detect_rssbridge": "No se puede detectar la fuente usando RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "No se puede detectar el formato del feed: %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" : "Buscar:",
|
||||
"enclosure_media_controls.seek.title" : "Buscar %s segundos",
|
||||
"enclosure_media_controls.speed" : "Velocidad:",
|
||||
"enclosure_media_controls.speed.faster" : "Más rápido",
|
||||
"enclosure_media_controls.speed.faster.title" : "Más rápido a %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Despacio",
|
||||
"enclosure_media_controls.speed.slower.title" : "Más despacio a %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Restablecer",
|
||||
"enclosure_media_controls.speed.reset.title" : "Restablecer la velocidad a 1x"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
|
||||
"menu.show_all_entries": "Näytä kaikki artikkelit",
|
||||
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "Päivitä",
|
||||
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
|
||||
"menu.edit_feed": "Muokkaa",
|
||||
@ -55,7 +56,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",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Asetukset",
|
||||
"page.settings.link_google_account": "Linkitä Google-tilini",
|
||||
"page.settings.unlink_google_account": "Poista Google-tilini linkitys",
|
||||
"page.settings.link_oidc_account": "Linkitä OpenID Connect -tilini",
|
||||
"page.settings.unlink_oidc_account": "Poista OpenID Connect -tilini linkitys",
|
||||
"page.settings.link_oidc_account": "Linkitä %s -tilini",
|
||||
"page.settings.unlink_oidc_account": "Poista %s -tilini linkitys",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Kirjaudu sisään",
|
||||
"page.login.google_signin": "Kirjaudu sisään Googlella",
|
||||
"page.login.oidc_signin": "Kirjaudu sisään OpenID Connectilla",
|
||||
"page.login.oidc_signin": "Kirjaudu sisään %silla",
|
||||
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
|
||||
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
|
||||
"page.integrations.title": "Integraatiot",
|
||||
@ -258,6 +261,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 +303,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 +330,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",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
|
||||
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Ladataan...",
|
||||
"form.submit.saving": "Tallennetaan...",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Tout marquer comme lu",
|
||||
"menu.show_all_entries": "Afficher tous les articles",
|
||||
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus",
|
||||
"menu.show_only_starred_entries": "Afficher uniquement les favoris",
|
||||
"menu.refresh_feed": "Actualiser",
|
||||
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
|
||||
"menu.edit_feed": "Modifier",
|
||||
@ -55,7 +56,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",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Réglages",
|
||||
"page.settings.link_google_account": "Associer mon compte Google",
|
||||
"page.settings.unlink_google_account": "Dissocier mon compte Google",
|
||||
"page.settings.link_oidc_account": "Associer mon compte OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Dissocier mon compte OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Associer mon compte %s",
|
||||
"page.settings.unlink_oidc_account": "Dissocier mon compte %s",
|
||||
"page.settings.webauthn.passkeys": "Clés d’accès",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Nom de la clé d’accès",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Connexion",
|
||||
"page.login.google_signin": "Se connecter avec Google",
|
||||
"page.login.oidc_signin": "Se connecter avec OpenID Connect",
|
||||
"page.login.oidc_signin": "Se connecter avec %s",
|
||||
"page.login.webauthn_login": "Se connecter avec une clé d’accès",
|
||||
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé d’accès",
|
||||
"page.integrations.title": "Intégrations",
|
||||
@ -258,6 +261,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 +296,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 +328,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",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.disabled": "Ne pas actualiser ce flux",
|
||||
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
|
||||
"form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
|
||||
"form.feed.label.ntfy_activate": "Activer les notifications",
|
||||
"form.feed.label.ntfy_priority": "Priorité de notification",
|
||||
"form.feed.label.ntfy_max_priority": "Priorité maximale de notification",
|
||||
"form.feed.label.ntfy_high_priority": "Priorité élevée de notification",
|
||||
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
|
||||
"form.feed.label.ntfy_low_priority": "Priorité basse de notification",
|
||||
"form.feed.label.ntfy_min_priority": "Priorité minimale de notification",
|
||||
"form.feed.fieldset.general": "Général",
|
||||
"form.feed.fieldset.rules": "Règles",
|
||||
"form.feed.fieldset.network_settings": "Paramètres réseau",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
|
||||
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
|
||||
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Marqué les entrées comme lues uniquement après 90%% de lecture de l'audio/vidéo",
|
||||
"form.prefs.label.mark_read_manually": "Marqué les entrées comme lues manuellement",
|
||||
"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 vers 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",
|
||||
@ -422,7 +449,7 @@
|
||||
"form.integration.espial_endpoint": "URL de l'API de Espial",
|
||||
"form.integration.espial_api_key": "Clé d'API de Espial",
|
||||
"form.integration.espial_tags": "Libellés de Espial",
|
||||
"form.integration.readwise_activate": "Enregistrer les entrées dans Readwise Reader",
|
||||
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
|
||||
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
|
||||
"form.integration.telegram_bot_activate": "Envoyer les nouveaux articles vers Telegram",
|
||||
@ -431,17 +458,17 @@
|
||||
"form.integration.telegram_topic_id": "Identifiant du sujet (Topic ID)",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Désactiver l'aperçu de la page Web",
|
||||
"form.integration.telegram_bot_disable_notification": "Désactiver les notifications",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.telegram_bot_disable_buttons": "Désactiver les boutons",
|
||||
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce",
|
||||
"form.integration.linkace_endpoint": "Point de terminaison de l'API LinkAce",
|
||||
"form.integration.linkace_api_key": "Clé d'API LinkAce",
|
||||
"form.integration.linkace_tags": "Étiquettes LinkAce",
|
||||
"form.integration.linkace_is_private": "Marquer le lien comme privé",
|
||||
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens",
|
||||
"form.integration.linkding_activate": "Sauvegarder les articles vers Linkding",
|
||||
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
|
||||
"form.integration.linkding_api_key": "Clé d'API de Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Libellés",
|
||||
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
|
||||
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL de l'API de Linkwarden",
|
||||
@ -451,6 +478,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": "Enregistrer les entrées vers Raindrop",
|
||||
"form.integration.raindrop_token": "Jeton d'accès de Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Identifiant de la collection",
|
||||
"form.integration.raindrop_tags": "Libellés (séparées par des virgules)",
|
||||
"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",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Secret du webhook",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Envoyer les entrées vers ntfy",
|
||||
"form.integration.ntfy_topic": "Sujet Ntfy",
|
||||
"form.integration.ntfy_url": "URL de Ntfy (optionnel, ntfy.sh par défaut)",
|
||||
"form.integration.ntfy_api_token": "Jeton d'API Ntfy (optionnel)",
|
||||
"form.integration.ntfy_username": "Nom d'utilisateur Ntfy (optionnel)",
|
||||
"form.integration.ntfy_password": "Mot de passe Ntfy (facultatif)",
|
||||
"form.integration.ntfy_icon_url": "URL de l'icône Ntfy (facultatif)",
|
||||
"form.api_key.label.description": "Libellé de la clé d'API",
|
||||
"form.submit.loading": "Chargement...",
|
||||
"form.submit.saving": "Sauvegarde en cours...",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
|
||||
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "ताज़ा करें",
|
||||
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
|
||||
"menu.edit_feed": "फ़ीड संपाद करे",
|
||||
@ -55,7 +56,9 @@
|
||||
"search.label": "खोजे",
|
||||
"search.placeholder": "खोजे...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "अगला",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "पिछला",
|
||||
"entry.status.unread": "अपठित",
|
||||
"entry.status.read": "पढ़े",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "समायोजन",
|
||||
"page.settings.link_google_account": "मेरा गूगल खाता जोरीय",
|
||||
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
|
||||
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय",
|
||||
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय",
|
||||
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय (%s)",
|
||||
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय (%s)",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "साइन इन करें",
|
||||
"page.login.google_signin": "गूगल के साथ साइन इन करें",
|
||||
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें",
|
||||
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें (%s)",
|
||||
"page.login.webauthn_login": "पासकी से लॉगिन करें",
|
||||
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
|
||||
"page.integrations.title": "एकीकरण",
|
||||
@ -258,6 +261,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 +303,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 +328,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": "फ़ीड उपयोगकर्ता नाम",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
|
||||
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
|
||||
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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 एपीआई कुंजी",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.submit.loading": "लोड हो रहा है...",
|
||||
"form.submit.saving": "सहेजा जा रहा है...",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
|
||||
"menu.show_all_entries": "Tampilkan semua entri",
|
||||
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "Muat ulang",
|
||||
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
|
||||
"menu.edit_feed": "Sunting",
|
||||
@ -56,6 +57,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",
|
||||
@ -199,8 +202,8 @@
|
||||
"page.settings.title": "Pengaturan",
|
||||
"page.settings.link_google_account": "Tautkan akun Google saya",
|
||||
"page.settings.unlink_google_account": "Putuskan akun Google saya",
|
||||
"page.settings.link_oidc_account": "Tautkan akun OpenID Connect saya",
|
||||
"page.settings.unlink_oidc_account": "Putuskan akun OpenID Connect saya",
|
||||
"page.settings.link_oidc_account": "Tautkan akun %s saya",
|
||||
"page.settings.unlink_oidc_account": "Putuskan akun %s saya",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -213,7 +216,7 @@
|
||||
],
|
||||
"page.login.title": "Masuk",
|
||||
"page.login.google_signin": "Masuk dengan Google",
|
||||
"page.login.oidc_signin": "Masuk dengan OpenID Connect",
|
||||
"page.login.oidc_signin": "Masuk dengan %s",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.integrations.title": "Integrasi",
|
||||
@ -248,6 +251,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 +293,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 +318,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",
|
||||
@ -325,6 +338,13 @@
|
||||
"form.feed.label.disabled": "Jangan perbarui umpan ini",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -365,11 +385,18 @@
|
||||
"form.prefs.label.default_home_page": "Beranda Baku",
|
||||
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
|
||||
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +468,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",
|
||||
@ -458,6 +489,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Label Kunci API",
|
||||
"form.submit.loading": "Memuat...",
|
||||
"form.submit.saving": "Menyimpan...",
|
||||
@ -511,5 +549,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
|
||||
"menu.show_all_entries": "Mostra tutte le voci",
|
||||
"menu.show_only_unread_entries": "Mostra solo voci non lette",
|
||||
"menu.show_only_starred_entries": "Mostra solo voci preferiti",
|
||||
"menu.refresh_feed": "Aggiorna",
|
||||
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
|
||||
"menu.edit_feed": "Modifica",
|
||||
@ -56,6 +57,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",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Impostazioni",
|
||||
"page.settings.link_google_account": "Collega il mio account Google",
|
||||
"page.settings.unlink_google_account": "Scollega il mio account Google",
|
||||
"page.settings.link_oidc_account": "Collega il mio account OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Scollega il mio account OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Collega il mio account %s",
|
||||
"page.settings.unlink_oidc_account": "Scollega il mio account %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Accedi",
|
||||
"page.login.google_signin": "Accedi tramite Google",
|
||||
"page.login.oidc_signin": "Accedi tramite OpenID Connect",
|
||||
"page.login.oidc_signin": "Accedi tramite %s",
|
||||
"page.login.webauthn_login": "Accedi con passkey",
|
||||
"page.login.webauthn_login.error": "Impossibile accedere con passkey",
|
||||
"page.integrations.title": "Integrazioni",
|
||||
@ -258,6 +261,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 +296,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 +328,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",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.disabled": "Non aggiornare questo feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
|
||||
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
|
||||
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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",
|
||||
@ -469,6 +500,13 @@
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.api_key.label.description": "Etichetta chiave API",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.submit.loading": "Caricamento in corso...",
|
||||
"form.submit.saving": "Salvataggio in corso...",
|
||||
"time_elapsed.not_yet": "non ancora",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "すべて既読にする",
|
||||
"menu.show_all_entries": "すべての記事を表示",
|
||||
"menu.show_only_unread_entries": "未読の記事だけを表示",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
|
||||
"menu.edit_feed": "編集",
|
||||
@ -55,7 +56,9 @@
|
||||
"search.label": "検索",
|
||||
"search.placeholder": "…を検索",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "次",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "前",
|
||||
"entry.status.unread": "未読にする",
|
||||
"entry.status.read": "既読にする",
|
||||
@ -199,8 +202,8 @@
|
||||
"page.settings.title": "設定",
|
||||
"page.settings.link_google_account": "Google アカウントと接続する",
|
||||
"page.settings.unlink_google_account": "Google アカウントと接続を解除する",
|
||||
"page.settings.link_oidc_account": "OpenID Connect アカウントと接続する",
|
||||
"page.settings.unlink_oidc_account": "OpenID Connect アカウントと接続を解除する",
|
||||
"page.settings.link_oidc_account": "%s アカウントと接続する",
|
||||
"page.settings.unlink_oidc_account": "%s アカウントと接続を解除する",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -213,7 +216,7 @@
|
||||
],
|
||||
"page.login.title": "ログイン",
|
||||
"page.login.google_signin": "Google アカウントでログイン",
|
||||
"page.login.oidc_signin": "OpenID Connect アカウントでログイン",
|
||||
"page.login.oidc_signin": "%s アカウントでログイン",
|
||||
"page.login.webauthn_login": "パスキーでログイン",
|
||||
"page.login.webauthn_login.error": "パスキーでログインできない",
|
||||
"page.integrations.title": "連携",
|
||||
@ -248,6 +251,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 +293,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 +318,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": "フィードのユーザー名",
|
||||
@ -325,6 +338,13 @@
|
||||
"form.feed.label.disabled": "このフィードを更新しない",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "未読一覧に記事を表示しない",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -365,11 +385,18 @@
|
||||
"form.prefs.label.default_home_page": "デフォルトのトップページ",
|
||||
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
|
||||
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +468,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",
|
||||
@ -458,6 +489,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API キーラベル",
|
||||
"form.submit.loading": "読み込み中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
@ -511,5 +549,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"
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Ga naar inhoud",
|
||||
"confirm.question": "Weet je het zeker?",
|
||||
"confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
|
||||
"confirm.question.refresh": "Wil je vernieuwen forceren?",
|
||||
"confirm.yes": "ja",
|
||||
"confirm.no": "nee",
|
||||
"confirm.loading": "Bezig...",
|
||||
"action.subscribe": "Abboneren",
|
||||
"action.subscribe": "Abonneren",
|
||||
"action.save": "Opslaan",
|
||||
"action.or": "of",
|
||||
"action.cancel": "annuleren",
|
||||
"action.remove": "Verwijderen",
|
||||
"action.remove_feed": "Verwijder deze feed",
|
||||
"action.update": "Updaten",
|
||||
"action.update": "Bijwerken",
|
||||
"action.edit": "Bewerken",
|
||||
"action.download": "Download",
|
||||
"action.import": "Importeren",
|
||||
@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
|
||||
"tooltip.logged_user": "Ingelogd als %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Startpagina",
|
||||
"menu.unread": "Ongelezen",
|
||||
"menu.starred": "Favorieten",
|
||||
"menu.history": "Geschiedenis",
|
||||
@ -31,16 +31,17 @@
|
||||
"menu.preferences": "Voorkeuren",
|
||||
"menu.integrations": "Integraties",
|
||||
"menu.sessions": "Sessies",
|
||||
"menu.users": "Users",
|
||||
"menu.users": "Gebruikers",
|
||||
"menu.about": "Over",
|
||||
"menu.export": "Exporteren",
|
||||
"menu.import": "Importeren",
|
||||
"menu.search": "Zoeken",
|
||||
"menu.create_category": "Categorie toevoegen",
|
||||
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alle items als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alles als gelezen",
|
||||
"menu.show_all_entries": "Toon alle artikelen",
|
||||
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
|
||||
"menu.show_only_starred_entries": "Toon alleen favorieten",
|
||||
"menu.refresh_feed": "Vernieuwen",
|
||||
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
|
||||
"menu.edit_feed": "Bewerken",
|
||||
@ -48,37 +49,39 @@
|
||||
"menu.add_feed": "Feed toevoegen",
|
||||
"menu.add_user": "Gebruiker toevoegen",
|
||||
"menu.flush_history": "Verwijder geschiedenis",
|
||||
"menu.feed_entries": "Lidwoord",
|
||||
"menu.feed_entries": "Artikelen",
|
||||
"menu.api_keys": "API-sleutels",
|
||||
"menu.create_api_key": "Maak een nieuwe API-sleutel",
|
||||
"menu.shared_entries": "Gedeelde vermeldingen",
|
||||
"menu.shared_entries": "Gedeelde artikelen",
|
||||
"search.label": "Zoeken",
|
||||
"search.placeholder": "Zoeken...",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "Zoeken",
|
||||
"pagination.last": "Laatste",
|
||||
"pagination.next": "Volgende",
|
||||
"pagination.first": "Eerste",
|
||||
"pagination.previous": "Vorige",
|
||||
"entry.status.unread": "Ongelezen",
|
||||
"entry.status.read": "Gelezen",
|
||||
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
|
||||
"entry.status.toast.read": "Gemarkeerd als gelezen",
|
||||
"entry.status.title": "Verander status van item",
|
||||
"entry.bookmark.toggle.on": "Ster toevoegen",
|
||||
"entry.bookmark.toggle.off": "Ster weghalen",
|
||||
"entry.bookmark.toast.on": "Met ster",
|
||||
"entry.bookmark.toast.off": "Ster verwijderd",
|
||||
"entry.state.saving": "Opslaag...",
|
||||
"entry.status.title": "Verander artikelstatus",
|
||||
"entry.bookmark.toggle.on": "Favoriet",
|
||||
"entry.bookmark.toggle.off": "Favoriet verwijderen",
|
||||
"entry.bookmark.toast.on": "Favoriet toegevoegd",
|
||||
"entry.bookmark.toast.off": "Favoriet verwijderd",
|
||||
"entry.state.saving": "Opslaan...",
|
||||
"entry.state.loading": "Laden...",
|
||||
"entry.save.label": "Opslaan",
|
||||
"entry.save.title": "Artikel opslaan",
|
||||
"entry.save.completed": "Done!",
|
||||
"entry.save.completed": "Klaar!",
|
||||
"entry.save.toast.completed": "Artikel opgeslagen",
|
||||
"entry.scraper.label": "Downloaden",
|
||||
"entry.scraper.title": "Fetch original content",
|
||||
"entry.scraper.title": "Originele inhoud ophalen",
|
||||
"entry.scraper.completed": "Klaar!",
|
||||
"entry.external_link.label": "Externe link",
|
||||
"entry.comments.label": "Comments",
|
||||
"entry.comments.title": "Bekijk de reacties",
|
||||
"entry.share.label": "Deel",
|
||||
"entry.comments.label": "Reacties",
|
||||
"entry.comments.title": "Bekijk reacties",
|
||||
"entry.share.label": "Delen",
|
||||
"entry.share.title": "Deel dit artikel",
|
||||
"entry.unshare.label": "Delen ongedaan maken",
|
||||
"entry.shared_entry.title": "Open de openbare link",
|
||||
@ -87,117 +90,117 @@
|
||||
"%d minuut leestijd",
|
||||
"%d minuten leestijd"
|
||||
],
|
||||
"entry.tags.label": "Labels:",
|
||||
"page.shared_entries.title": "Gedeelde vermeldingen",
|
||||
"entry.tags.label": "Tags:",
|
||||
"page.shared_entries.title": "Gedeelde artikelen",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d gedeeld artikel",
|
||||
"%d gedeelde artikelen"
|
||||
],
|
||||
"page.unread.title": "Ongelezen",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d ongelezen artikel",
|
||||
"%d ongelezen artikelen"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d artikel totaal",
|
||||
"%d artikelen totaal"
|
||||
],
|
||||
"page.starred.title": "Favorieten",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d favoriet artikel",
|
||||
"%d favoriete artikelen"
|
||||
],
|
||||
"page.categories.title": "Categorieën",
|
||||
"page.categories.no_feed": "Geen feeds.",
|
||||
"page.categories.entries": "Lidwoord",
|
||||
"page.categories.feeds": "Abonnementen",
|
||||
"page.categories.no_feed": "Geen feed.",
|
||||
"page.categories.entries": "Artikelen",
|
||||
"page.categories.feeds": "Feeds",
|
||||
"page.categories.feed_count": [
|
||||
"Er is %d feed.",
|
||||
"Er zijn %d feeds."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d categorie",
|
||||
"%d categorieën"
|
||||
],
|
||||
"page.new_category.title": "Nieuwe categorie",
|
||||
"page.new_user.title": "Nieuwe gebruiker",
|
||||
"page.edit_category.title": "Bewerken van categorie: %s",
|
||||
"page.edit_category.title": "Bewerk categorie: %s",
|
||||
"page.edit_user.title": "Bewerk gebruiker: %s",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.feeds.last_check": "Laatste update:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Aantal gelezen vermeldingen",
|
||||
"page.category_label": "Categorie: %s",
|
||||
"page.feeds.last_check": "Laatste controle:",
|
||||
"page.feeds.next_check": "Volgende controle:",
|
||||
"page.feeds.read_counter": "Aantal gelezen artikelen",
|
||||
"page.feeds.error_count": [
|
||||
"%d error",
|
||||
"%d errors"
|
||||
"%d fout",
|
||||
"%d fouten"
|
||||
],
|
||||
"page.history.title": "Geschiedenis",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d gelezen artikel",
|
||||
"%d gelezen artikelen"
|
||||
],
|
||||
"page.import.title": "Importeren",
|
||||
"page.login.title": "Inloggen",
|
||||
"page.search.title": "Zoekresultaten",
|
||||
"page.about.title": "Over",
|
||||
"page.about.credits": "Copyrights",
|
||||
"page.about.credits": "Credits",
|
||||
"page.about.version": "Versie:",
|
||||
"page.about.build_date": "Datum build:",
|
||||
"page.about.build_date": "Compilatiedatum:",
|
||||
"page.about.author": "Auteur:",
|
||||
"page.about.license": "Licentie:",
|
||||
"page.about.global_config_options": "globale configuratie-opties",
|
||||
"page.about.global_config_options": "Globale Configuratie Opties",
|
||||
"page.about.postgres_version": "Postgres versie:",
|
||||
"page.about.go_version": "Go versie:",
|
||||
"page.add_feed.title": "Nieuwe feed",
|
||||
"page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
|
||||
"page.add_feed.no_category": "Er is geen categorie. Je moet minstens één categorie hebben.",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.submit": "Feed zoeken",
|
||||
"page.add_feed.legend.advanced_options": "Geavanceerde mogelijkheden",
|
||||
"page.add_feed.legend.advanced_options": "Geavanceerde opties",
|
||||
"page.add_feed.choose_feed": "Feed kiezen",
|
||||
"page.edit_feed.title": "Bewerken van feed: %s",
|
||||
"page.edit_feed.last_check": "Laatste update:",
|
||||
"page.edit_feed.last_modified_header": "LastModified-header:",
|
||||
"page.edit_feed.etag_header": "ETAG-header:",
|
||||
"page.edit_feed.title": "Bewerk feed: %s",
|
||||
"page.edit_feed.last_check": "Laatste controle:",
|
||||
"page.edit_feed.last_modified_header": "LastModified header:",
|
||||
"page.edit_feed.etag_header": "ETAG header:",
|
||||
"page.edit_feed.no_header": "Geen",
|
||||
"page.edit_feed.last_parsing_error": "Laatste parse error",
|
||||
"page.edit_feed.last_parsing_error": "Laatste analysefout",
|
||||
"page.entry.attachments": "Bijlagen",
|
||||
"page.keyboard_shortcuts.title": "Sneltoetsen",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Naviguatie tussen menu's",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigatie tussen items",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Naviguatie tussen pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Actions",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigeren door artikelen",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Acties",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
|
||||
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Laat sneltoetsen zien",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Vorige item",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Volgende item",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Sneltoetsen tonen",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Vorig artikel",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Volgend artikel",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
|
||||
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste artikel",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste artikel",
|
||||
"page.keyboard_shortcuts.open_item": "Open geselecteerd artikel",
|
||||
"page.keyboard_shortcuts.open_original": "Open originele link",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
|
||||
"page.keyboard_shortcuts.open_comments": "Open opmerkingen link",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Open de reactiekoppeling op het huidige tabblad",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Open originele link in huidig tabblad",
|
||||
"page.keyboard_shortcuts.open_comments": "Open reacties",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Open reacties in huidig tabblad",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"page.keyboard_shortcuts.download_content": "Download originele content",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ster toevoegen/weghalen",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Markeer huidige pagina als gelezen",
|
||||
"page.keyboard_shortcuts.download_content": "Download originele inhoud",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.save_article": "Artikel opslaan",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
|
||||
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
|
||||
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.close_modal": "Sluit dialoogscherm",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
|
||||
"page.keyboard_shortcuts.close_modal": "Dialoogvenster sluiten",
|
||||
"page.users.title": "Gebruikers",
|
||||
"page.users.username": "Gebruikersnaam",
|
||||
"page.users.never_logged": "Nooit",
|
||||
@ -205,27 +208,27 @@
|
||||
"page.users.admin.no": "Nee",
|
||||
"page.users.actions": "Acties",
|
||||
"page.users.last_login": "Laatste login",
|
||||
"page.users.is_admin": "Administrator",
|
||||
"page.users.is_admin": "Beheerder",
|
||||
"page.settings.title": "Instellingen",
|
||||
"page.settings.link_google_account": "Koppel mijn Google-account",
|
||||
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
|
||||
"page.settings.link_oidc_account": "Koppel mijn OpenID Connect-account",
|
||||
"page.settings.unlink_oidc_account": "Ontkoppel mijn OpenID Connect-account",
|
||||
"page.settings.link_oidc_account": "Koppel mijn %s account",
|
||||
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s account",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Wachtwoord registreren",
|
||||
"page.settings.webauthn.register.error": "Kan wachtwoord niet registreren",
|
||||
"page.settings.webauthn.actions": "Acties",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Naam",
|
||||
"page.settings.webauthn.added_on": "Toegevoegd op",
|
||||
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
|
||||
"page.settings.webauthn.register": "Passkey registreren",
|
||||
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Verwijder %d wachtwoord",
|
||||
"Verwijder %d wachtwoordsleutels"
|
||||
"Verwijder %d passkey",
|
||||
"Verwijder %d passkeys"
|
||||
],
|
||||
"page.login.oidc_signin": "Inloggen via OpenID Connect",
|
||||
"page.login.webauthn_login": "Inloggen met wachtwoord",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord",
|
||||
"page.login.google_signin": "Inloggen via Google",
|
||||
"page.login.oidc_signin": "Inloggen met %s",
|
||||
"page.login.webauthn_login": "Inloggen met passkey",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
|
||||
"page.login.google_signin": "Inloggen met Google",
|
||||
"page.integrations.title": "Integraties",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API-URL",
|
||||
@ -235,7 +238,7 @@
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
|
||||
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.",
|
||||
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
|
||||
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abonneren op een website.",
|
||||
"page.sessions.title": "Sessies",
|
||||
"page.sessions.table.date": "Datum",
|
||||
"page.sessions.table.ip": "IP-adres",
|
||||
@ -243,8 +246,8 @@
|
||||
"page.sessions.table.actions": "Acties",
|
||||
"page.sessions.table.current_session": "Huidige sessie",
|
||||
"page.api_keys.title": "API-sleutels",
|
||||
"page.api_keys.table.description": "Beschrijving",
|
||||
"page.api_keys.table.token": "Blijk",
|
||||
"page.api_keys.table.description": "Omschrijving",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.table.last_used_at": "Laatst gebruikt",
|
||||
"page.api_keys.table.created_at": "Aanmaakdatum",
|
||||
"page.api_keys.table.actions": "Acties",
|
||||
@ -253,133 +256,157 @@
|
||||
"page.offline.title": "Offline modus",
|
||||
"page.offline.message": "Je bent offline",
|
||||
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"alert.no_shared_entry": "Er is geen gedeelde toegang.",
|
||||
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
|
||||
"page.webauthn_rename.title": "Hernoem Passkey",
|
||||
"alert.no_shared_entry": "Er is geen gedeeld artikel.",
|
||||
"alert.no_bookmark": "Er zijn geen favorieten.",
|
||||
"alert.no_category": "Er zijn geen categorieën.",
|
||||
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
|
||||
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
|
||||
"alert.no_tag_entry": "Er zijn geen artikelen 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.",
|
||||
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
|
||||
"alert.no_feed_in_category": "Er is geen feed voor deze categorie.",
|
||||
"alert.no_history": "Geschiedenis is op dit moment leeg.",
|
||||
"alert.feed_error": "Er is een probleem met deze feed",
|
||||
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
|
||||
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
|
||||
"alert.no_user": "Je bent de enige gebruiker.",
|
||||
"alert.account_unlinked": "Uw externe account is nu gedissocieerd!",
|
||||
"alert.account_linked": "Uw externe account is nu gekoppeld!",
|
||||
"alert.pocket_linked": "Uw Pocket-account is nu gekoppeld!",
|
||||
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
|
||||
"alert.account_linked": "Jouw externe account is nu gekoppeld!",
|
||||
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!",
|
||||
"alert.prefs_saved": "Instellingen opgeslagen!",
|
||||
"error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
|
||||
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
|
||||
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
|
||||
"error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
|
||||
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
|
||||
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
|
||||
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
|
||||
"error.category_already_exists": "Deze categorie bestaat al.",
|
||||
"error.unable_to_create_category": "Kan deze categorie niet maken.",
|
||||
"error.unable_to_update_category": "Kon categorie niet updaten.",
|
||||
"error.unable_to_create_category": "Kan deze categorie niet aanmaken.",
|
||||
"error.unable_to_update_category": "Kan categorie niet bijwerken.",
|
||||
"error.user_already_exists": "Deze gebruiker bestaat al.",
|
||||
"error.unable_to_create_user": "Kan deze gebruiker niet maken.",
|
||||
"error.unable_to_update_user": "Kan deze gebruiker niet updaten.",
|
||||
"error.unable_to_create_user": "Kan deze gebruiker niet aanmaken.",
|
||||
"error.unable_to_update_user": "Kan deze gebruiker niet bijwerken.",
|
||||
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
|
||||
"error.subscription_not_found": "Kon geen feeds vinden.",
|
||||
"error.subscription_not_found": "Kan geen feeds vinden.",
|
||||
"error.empty_file": "Dit bestand is leeg.",
|
||||
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
|
||||
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
|
||||
"error.title_required": "Naam van categorie is verplicht.",
|
||||
"error.title_required": "De titel is verplicht.",
|
||||
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
|
||||
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
|
||||
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.",
|
||||
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
|
||||
"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.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
|
||||
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Ongeldige bewaarregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.entries_per_page_invalid": "Het aantal artikelen per pagina is niet geldig.",
|
||||
"error.feed_mandatory_fields": "De velden URL en categorie zijn verplicht.",
|
||||
"error.feed_already_exists": "Deze feed bestaat al.",
|
||||
"error.invalid_feed_url": "Ongeldige feed-URL.",
|
||||
"error.invalid_site_url": "Ongeldige site-URL.",
|
||||
"error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.",
|
||||
"error.site_url_not_empty": "De site-URL mag niet leeg zijn.",
|
||||
"error.invalid_feed_url": "Ongeldige feed URL.",
|
||||
"error.invalid_site_url": "Ongeldige site URL.",
|
||||
"error.feed_url_not_empty": "De feed URL mag niet leeg zijn.",
|
||||
"error.site_url_not_empty": "De site URL mag niet leeg zijn.",
|
||||
"error.feed_title_not_empty": "De feed titel mag niet leeg zijn.",
|
||||
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
|
||||
"error.feed_invalid_blocklist_rule": "De regel voor de blokkeerlijst is ongeldig.",
|
||||
"error.feed_invalid_keeplist_rule": "De regel voor het bewaren van een lijst is ongeldig.",
|
||||
"error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.",
|
||||
"error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.",
|
||||
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
|
||||
"error.api_key_already_exists": "This API Key already exists.",
|
||||
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
|
||||
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
|
||||
"error.unable_to_create_api_key": "Kan deze API-sleutel niet aanmaken.",
|
||||
"error.invalid_theme": "Ongeldig thema.",
|
||||
"error.invalid_language": "Ongeldige taal.",
|
||||
"error.invalid_timezone": "Ongeldige tijdzone.",
|
||||
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
|
||||
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
|
||||
"error.invalid_default_home_page": "Ongeldige standaard homepage!",
|
||||
"form.feed.label.title": "Naam",
|
||||
"error.invalid_default_home_page": "Ongeldige startpagina!",
|
||||
"form.feed.label.title": "Titel",
|
||||
"form.feed.label.site_url": "Website URL",
|
||||
"form.feed.label.feed_url": "Feed URL",
|
||||
"form.feed.label.description": "Omschrijving",
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.crawler": "Download originele content",
|
||||
"form.feed.label.feed_username": "Feed-gebruikersnaam",
|
||||
"form.feed.label.crawler": "Download originele inhoud",
|
||||
"form.feed.label.feed_username": "Feed gebruikersnaam",
|
||||
"form.feed.label.feed_password": "Feed wachtwoord",
|
||||
"form.feed.label.user_agent": "Standaard User Agent overschrijven",
|
||||
"form.feed.label.user_agent": "Standaard User-agent overschrijven",
|
||||
"form.feed.label.cookie": "Cookies instellen",
|
||||
"form.feed.label.scraper_rules": "Scraper regels",
|
||||
"form.feed.label.rewrite_rules": "Rewrite regels",
|
||||
"form.feed.label.scraper_rules": "Extractieregels",
|
||||
"form.feed.label.rewrite_rules": "Herschrijfregels",
|
||||
"form.feed.label.blocklist_rules": "Blokkeerregels",
|
||||
"form.feed.label.keeplist_rules": "toestemmingsregels",
|
||||
"form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.keeplist_rules": "Bewaarregels",
|
||||
"form.feed.label.urlrewrite_rules": "Herschrijfregels voor URL's",
|
||||
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
|
||||
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
|
||||
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
|
||||
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
|
||||
"form.feed.label.disabled": "Vernieuw deze feed niet",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.category.label.title": "Naam",
|
||||
"form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
|
||||
"form.feed.label.disabled": "Deze feed niet vernieuwen",
|
||||
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
|
||||
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
|
||||
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy hoge prioriteit",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
|
||||
"form.feed.fieldset.general": "Algemeen",
|
||||
"form.feed.fieldset.rules": "Regels",
|
||||
"form.feed.fieldset.network_settings": "Netwerk Instellingen",
|
||||
"form.feed.fieldset.integration": "Diensten van derden",
|
||||
"form.category.label.title": "Titel",
|
||||
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.user.label.username": "Gebruikersnaam",
|
||||
"form.user.label.password": "Wachtwoord",
|
||||
"form.user.label.confirmation": "Bevestig wachtwoord",
|
||||
"form.user.label.admin": "Administrator",
|
||||
"form.user.label.admin": "Beheerder",
|
||||
"form.prefs.label.language": "Taal",
|
||||
"form.prefs.label.timezone": "Tijdzone",
|
||||
"form.prefs.label.theme": "Skin",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van items",
|
||||
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
|
||||
"form.prefs.label.theme": "Thema",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
|
||||
"form.prefs.label.entries_per_page": "Artikelen per pagina",
|
||||
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
|
||||
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
|
||||
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
|
||||
"form.prefs.select.older_first": "Oudere items eerst",
|
||||
"form.prefs.select.recent_first": "Recente items eerst",
|
||||
"form.prefs.select.older_first": "Oudere artikelen eerst",
|
||||
"form.prefs.select.recent_first": "Recente artikelen eerst",
|
||||
"form.prefs.select.fullscreen": "Volledig scherm",
|
||||
"form.prefs.select.standalone": "Standalone",
|
||||
"form.prefs.select.minimal_ui": "Minimaal",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.publish_time": "Tijd van binnenkomst",
|
||||
"form.prefs.select.created_time": "Tijdstip van binnenkomst",
|
||||
"form.prefs.select.publish_time": "Tijdstip van publiceren artikel",
|
||||
"form.prefs.select.created_time": "Tijdstip van aanmaken artikel",
|
||||
"form.prefs.select.alphabetical": "Alfabetisch",
|
||||
"form.prefs.select.unread_count": "Ongelezen tellen",
|
||||
"form.prefs.select.unread_count": "Aantal ongelezen artikelen",
|
||||
"form.prefs.select.none": "Geen",
|
||||
"form.prefs.select.tap": "Dubbeltik",
|
||||
"form.prefs.select.swipe": "Vegen",
|
||||
"form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
|
||||
"form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
|
||||
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
|
||||
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
|
||||
"form.prefs.label.custom_css": "Aangepaste CSS",
|
||||
"form.prefs.label.entry_order": "Ingang Sorteerkolom",
|
||||
"form.prefs.label.default_home_page": "Standaard startpagina",
|
||||
"form.prefs.label.categories_sorting_order": "Categorieën sorteren",
|
||||
"form.prefs.label.mark_read_on_view": "Items automatisch markeren als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.label.entry_order": "Artikelen sorteren",
|
||||
"form.prefs.label.default_home_page": "Startpagina",
|
||||
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
|
||||
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt",
|
||||
"form.prefs.label.mark_read_manually": "Markeer artikelen handmatig als gelezen",
|
||||
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
|
||||
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
|
||||
"form.import.label.file": "OPML-bestand",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Artikelen opslaan in 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",
|
||||
@ -387,91 +414,102 @@
|
||||
"form.integration.googlereader_activate": "Activeer Google Reader API",
|
||||
"form.integration.googlereader_username": "Google Reader gebruikersnaam",
|
||||
"form.integration.googlereader_password": "Google Reader wachtwoord",
|
||||
"form.integration.googlereader_endpoint": "Google Reader URL:",
|
||||
"form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API-endpoint:",
|
||||
"form.integration.pinboard_activate": "Artikelen opslaan in Pinboard",
|
||||
"form.integration.pinboard_token": "Pinboard API token",
|
||||
"form.integration.pinboard_tags": "Pinboard tags",
|
||||
"form.integration.pinboard_bookmark": "Markeer bookmark als gelezen",
|
||||
"form.integration.instapaper_activate": "Artikelen opstaan naar Instapaper",
|
||||
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
|
||||
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
|
||||
"form.integration.instapaper_password": "Instapaper wachtwoord",
|
||||
"form.integration.pocket_activate": "Bewaar artikelen in Pocket",
|
||||
"form.integration.pocket_activate": "Artikelen opslaan in Pocket",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pocket_access_token": "Pocket Access Token",
|
||||
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
|
||||
"form.integration.wallabag_activate": "Opslaan naar Wallabag",
|
||||
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
|
||||
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
|
||||
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
|
||||
"form.integration.wallabag_password": "Wallabag wachtwoord",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_activate": "Artikelen opslaan in Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_activate": "Artikelen opslaan in Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
|
||||
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.integration.nunux_keeper_activate": "Artikelen opslaan in Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
|
||||
"form.integration.omnivore_activate": "Opslaan naar Omnivore",
|
||||
"form.integration.omnivore_activate": "Artikelen opslaan in Omnivore",
|
||||
"form.integration.omnivore_url": "Omnivore URL",
|
||||
"form.integration.omnivore_api_key": "Omnivore API-sleutel",
|
||||
"form.integration.espial_activate": "Opslaan naar Espial",
|
||||
"form.integration.espial_activate": "Artikelen opslaan in Espial",
|
||||
"form.integration.espial_endpoint": "Espial URL",
|
||||
"form.integration.espial_api_key": "Espial API-sleutel",
|
||||
"form.integration.espial_tags": "Espial tags",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_activate": "Artikelen opslaan in Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.telegram_bot_activate": "Push nieuwe artikelen naar Telegram-chat",
|
||||
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen",
|
||||
"form.integration.telegram_bot_activate": "Stuur nieuwe artikelen naar Telegram",
|
||||
"form.integration.telegram_bot_token": "Bot token",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Webpaginavoorbeeld uitschakelen",
|
||||
"form.integration.telegram_bot_disable_notification": "Notificatie uitschakelen",
|
||||
"form.integration.telegram_bot_disable_buttons": "Knoppen uitschakelen",
|
||||
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkding_activate": "Opslaan naar Linkding",
|
||||
"form.integration.linkace_api_key": "LinkAce API-sleutel",
|
||||
"form.integration.linkace_tags": "LinkAce tags",
|
||||
"form.integration.linkace_is_private": "Koppeling als privé markeren",
|
||||
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
|
||||
"form.integration.linkding_activate": "Artikelen opslaan in Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_api_key": "Linkding API-sleutel",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
|
||||
"form.integration.linkwarden_activate": "Opslaan naar Linkwarden",
|
||||
"form.integration.linkding_tags": "Linkding tags",
|
||||
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden URL",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
|
||||
"form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
|
||||
"form.integration.matrix_bot_user": "Matrix gebruikersnaam",
|
||||
"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.readeck_activate": "Opslaan naar Readeck",
|
||||
"form.integration.raindrop_activate": "Artikelen opslaan in Raindrop",
|
||||
"form.integration.raindrop_token": "Raindrop Token",
|
||||
"form.integration.raindrop_collection_id": "Collectie ID",
|
||||
"form.integration.raindrop_tags": "Tags (commagescheiden)",
|
||||
"form.integration.readeck_activate": "Artikelen opslaan in Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.shiori_activate": "Opslaan naar Shiori",
|
||||
"form.integration.shiori_activate": "Artikelen opslaan in Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.shiori_username": "Shiori gebruikersnaam",
|
||||
"form.integration.shiori_password": "Shiori wachtwoord",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_activate": "Webhook activeren",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.api_key.label.description": "API-sleutellabel",
|
||||
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
|
||||
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
|
||||
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
|
||||
"form.api_key.label.description": "API-sleutel omschrijving",
|
||||
"form.submit.loading": "Laden...",
|
||||
"form.submit.saving": "Opslaag...",
|
||||
"time_elapsed.not_yet": "in de toekomst",
|
||||
"form.submit.saving": "Opslaan...",
|
||||
"time_elapsed.not_yet": "nog niet",
|
||||
"time_elapsed.yesterday": "gisteren",
|
||||
"time_elapsed.now": "minder dan een minuut geleden",
|
||||
"time_elapsed.minutes": [
|
||||
@ -499,34 +537,43 @@
|
||||
"%d jaar geleden"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
|
||||
"error.http_response_too_large": "De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)",
|
||||
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
|
||||
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
|
||||
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
|
||||
"error.tls_error": "TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.",
|
||||
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
|
||||
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
|
||||
"error.http_client_error": "HTTP-client-fout: %v.",
|
||||
"error.http_not_authorized": "Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.",
|
||||
"error.http_too_many_requests": "Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.",
|
||||
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
|
||||
"error.http_resource_not_found": "De gevraagde bron is niet gevonden. Controleer de URL.",
|
||||
"error.http_internal_server_error": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_service_unavailable": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_gateway_timeout": "De website is momenteel niet beschikbaar vanwege een timeout bij de gateway. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_unexpected_status_code": "De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.database_error": "Database fout: %v.",
|
||||
"error.category_not_found": "Deze categorie bestaat niet of hoort niet bij deze gebruiker.",
|
||||
"error.duplicated_feed": "Deze feed bestaat al.",
|
||||
"error.unable_to_parse_feed": "Kan deze feed niet verwerken: %v.",
|
||||
"error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.",
|
||||
"error.unable_to_detect_rssbridge": "Kan feed niet detecteren met RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %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" : "Vooruit/terug:",
|
||||
"enclosure_media_controls.seek.title" : " Vooruit/terug met %s seconden",
|
||||
"enclosure_media_controls.speed" : "Snelheid:",
|
||||
"enclosure_media_controls.speed.faster" : "Versnel",
|
||||
"enclosure_media_controls.speed.faster.title" : "Versnel met %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Vertraag",
|
||||
"enclosure_media_controls.speed.slower.title" : "Vertraag met %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset snelheid naar 1x"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
|
||||
"menu.show_all_entries": "Pokaż wszystkie artykuły",
|
||||
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane artykuły",
|
||||
"menu.show_only_starred_entries": "Pokaż tylko ulubione artykuły",
|
||||
"menu.refresh_feed": "Odśwież",
|
||||
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
|
||||
"menu.edit_feed": "Edytuj",
|
||||
@ -55,7 +56,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",
|
||||
@ -217,8 +220,8 @@
|
||||
"page.settings.title": "Ustawienia",
|
||||
"page.settings.link_google_account": "Połącz z moim kontem Google",
|
||||
"page.settings.unlink_google_account": "Odłącz moje konto Google",
|
||||
"page.settings.link_oidc_account": "Połącz z moim kontem OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Odłącz moje konto OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Połącz z moim kontem %s",
|
||||
"page.settings.unlink_oidc_account": "Odłącz moje konto %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -233,7 +236,7 @@
|
||||
],
|
||||
"page.login.title": "Zaloguj się",
|
||||
"page.login.google_signin": "Zaloguj przez Google",
|
||||
"page.login.oidc_signin": "Zaloguj przez OpenID Connect",
|
||||
"page.login.oidc_signin": "Zaloguj przez %s",
|
||||
"page.login.webauthn_login": "Zaloguj się za pomocą hasła",
|
||||
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
|
||||
"page.integrations.title": "Usługi",
|
||||
@ -268,6 +271,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 +306,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 +338,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",
|
||||
@ -345,6 +358,13 @@
|
||||
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -385,11 +405,18 @@
|
||||
"form.prefs.label.default_home_page": "Domyślna strona główna",
|
||||
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
|
||||
"form.prefs.label.mark_read_on_view": "Automatycznie oznaczaj wpisy jako przeczytane podczas przeglądania",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +488,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",
|
||||
@ -478,6 +509,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Etykieta klucza API",
|
||||
"form.submit.loading": "Ładowanie...",
|
||||
"form.submit.saving": "Zapisywanie...",
|
||||
@ -545,5 +583,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Marcar todos como lido",
|
||||
"menu.show_all_entries": "Mostrar todas os itens",
|
||||
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos",
|
||||
"menu.show_only_starred_entries": "Mostrar apenas os favoritos",
|
||||
"menu.refresh_feed": "Atualizar",
|
||||
"menu.refresh_all_feeds": "Atualizar todas as fontes",
|
||||
"menu.edit_feed": "Editar",
|
||||
@ -55,7 +56,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",
|
||||
@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Ajustes",
|
||||
"page.settings.link_google_account": "Vincular minha conta do Google",
|
||||
"page.settings.unlink_google_account": "Desvincular minha conta do Google",
|
||||
"page.settings.link_oidc_account": "Vincular minha conta do OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Desvincular minha conta do OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Vincular minha conta do %s",
|
||||
"page.settings.unlink_oidc_account": "Desvincular minha conta do %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -223,7 +226,7 @@
|
||||
],
|
||||
"page.login.title": "Iniciar Sessão",
|
||||
"page.login.google_signin": "Iniciar Sessão com sua conta do Google",
|
||||
"page.login.oidc_signin": "Iniciar Sessão com sua conta do OpenID Connect",
|
||||
"page.login.oidc_signin": "Iniciar Sessão com sua conta do %s",
|
||||
"page.login.webauthn_login": "Entrar com senha",
|
||||
"page.login.webauthn_login.error": "Não é possível fazer login com senha",
|
||||
"page.integrations.title": "Integrações",
|
||||
@ -258,6 +261,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 +296,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 +328,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",
|
||||
@ -335,6 +348,13 @@
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
|
||||
"form.feed.label.hide_globally": "Ocultar entradas na lista global não lida",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -375,11 +395,18 @@
|
||||
"form.prefs.label.default_home_page": "Página inicial predefinida",
|
||||
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas como lidas quando visualizadas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +478,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",
|
||||
@ -468,6 +499,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Etiqueta da chave de API",
|
||||
"form.submit.loading": "Carregando...",
|
||||
"form.submit.saving": "Salvando...",
|
||||
@ -528,5 +566,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"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Отметить всё как прочитанное",
|
||||
"menu.show_all_entries": "Показать все статьи",
|
||||
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
|
||||
"menu.show_only_starred_entries": "Показывать только избранные статьи",
|
||||
"menu.refresh_feed": "Обновить",
|
||||
"menu.refresh_all_feeds": "Обновить все подписки в фоне",
|
||||
"menu.edit_feed": "Изменить",
|
||||
@ -55,7 +56,9 @@
|
||||
"search.label": "Поиск",
|
||||
"search.placeholder": "Поиск…",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Следующая",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Предыдущая",
|
||||
"entry.status.unread": "Не прочитано",
|
||||
"entry.status.read": "Прочитано",
|
||||
@ -217,8 +220,8 @@
|
||||
"page.settings.title": "Настройки",
|
||||
"page.settings.link_google_account": "Привязать мой Google аккаунт",
|
||||
"page.settings.unlink_google_account": "Отвязать мой Google аккаунт",
|
||||
"page.settings.link_oidc_account": "Привязать мой OpenID Connect аккаунт",
|
||||
"page.settings.unlink_oidc_account": "Отвязать мой OpenID Connect аккаунт",
|
||||
"page.settings.link_oidc_account": "Привязать мой %s аккаунт",
|
||||
"page.settings.unlink_oidc_account": "Отвязать мой %s аккаунт",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -233,7 +236,7 @@
|
||||
],
|
||||
"page.login.title": "Войти",
|
||||
"page.login.google_signin": "Войти с помощью Google",
|
||||
"page.login.oidc_signin": "Войти с помощью OpenID Connect",
|
||||
"page.login.oidc_signin": "Войти с помощью %s",
|
||||
"page.login.webauthn_login": "Войти с паролем",
|
||||
"page.login.webauthn_login.error": "Невозможно войти с паролем",
|
||||
"page.integrations.title": "Интеграции",
|
||||
@ -268,6 +271,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 +306,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 +338,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": "Имя пользователя подписки",
|
||||
@ -345,6 +358,13 @@
|
||||
"form.feed.label.disabled": "Не обновлять эту подписку",
|
||||
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
|
||||
"form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@ -385,11 +405,18 @@
|
||||
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
|
||||
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
|
||||
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +488,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",
|
||||
@ -478,6 +509,13 @@
|
||||
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Описание API-ключа",
|
||||
"form.submit.loading": "Загрузка…",
|
||||
"form.submit.saving": "Сохранение…",
|
||||
@ -545,5 +583,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": "Geçersiz Engelleme kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
|
||||
"error.settings_block_rule_separator_required": "Geçersiz Engelleme kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
|
||||
"error.settings_block_rule_regex_required": "Geçersiz Engelleme kuralı: #%d kuralı modeli sağlanmadı",
|
||||
"error.settings_block_rule_invalid_regex": "Geçersiz Engelleme kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Geçersiz Koruma kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Geçersiz Koruma kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
|
||||
"error.settings_keep_rule_regex_required": "Geçersiz Koruma kuralı: #%d kuralı modeli sağlanmadı",
|
||||
"error.settings_keep_rule_invalid_regex": "Geçersiz Koruma kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
|
||||
"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,7 +177,10 @@
|
||||
"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.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.betula_activate": "Makaleleri Betula'ya kaydet",
|
||||
"form.integration.betula_url": "Betula sunucu URLsi",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.apprise_activate": "Makaleleri Apprise'a gönder",
|
||||
"form.integration.apprise_services_url": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
|
||||
@ -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": "Makaleleri Raindrop'a kaydet",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Koleksiyon ID",
|
||||
"form.integration.raindrop_tags": "Etiketler (virgülle ayrılmış)",
|
||||
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
|
||||
"form.integration.readeck_api_key": "Readeck API Anahtarı",
|
||||
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
|
||||
@ -255,9 +272,24 @@
|
||||
"form.integration.webhook_activate": "Webhook'u etkinleştir",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.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": "Genel Besleme Ayarları",
|
||||
"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",
|
||||
@ -272,6 +304,9 @@
|
||||
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
|
||||
"form.prefs.label.language": "Dil",
|
||||
"form.prefs.label.mark_read_on_view": "Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
|
||||
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
|
||||
"form.prefs.label.theme": "Tema",
|
||||
@ -324,6 +359,7 @@
|
||||
"menu.shared_entries": "Paylaşılan makaleler",
|
||||
"menu.show_all_entries": "Tüm makaleleri göster",
|
||||
"menu.show_only_unread_entries": "Sadece okunmamış makaleleri göster",
|
||||
"menu.show_only_starred_entries": "Sadece yıldızlanmış makaleleri göster",
|
||||
"menu.starred": "Yıldız",
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Okunmadı",
|
||||
@ -420,7 +456,7 @@
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Okundu/okunmadı arasında geçiş yap, sonrakine odaklan",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Okundu/okunmadı arasında geçiş yap, öncekine odaklan",
|
||||
"page.login.google_signin": "Google ile oturum aç",
|
||||
"page.login.oidc_signin": "OpenID Connect ile oturum aç",
|
||||
"page.login.oidc_signin": "%s ile oturum aç",
|
||||
"page.login.title": "Oturum aç",
|
||||
"page.login.webauthn_login": "Passkey ile giriş yap",
|
||||
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
|
||||
@ -439,10 +475,10 @@
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.title": "Oturumlar",
|
||||
"page.settings.link_google_account": "Google hesabımı bağla",
|
||||
"page.settings.link_oidc_account": "OpenID Connect hesabımı bağla",
|
||||
"page.settings.link_oidc_account": "%s hesabımı bağla",
|
||||
"page.settings.title": "Ayarlar",
|
||||
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
|
||||
"page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır",
|
||||
"page.settings.unlink_oidc_account": "%s hesabımın bağlantısını kaldır",
|
||||
"page.settings.webauthn.actions": "Eylemler",
|
||||
"page.settings.webauthn.added_on": "Eklendi",
|
||||
"page.settings.webauthn.delete": [
|
||||
@ -479,7 +515,9 @@
|
||||
"page.users.title": "Kullanıcılar",
|
||||
"page.users.username": "Kullanıcı adı",
|
||||
"page.webauthn_rename.title": "Passkey'i Yeniden Adlandır",
|
||||
"pagination.last": "Son",
|
||||
"pagination.next": "Sonraki",
|
||||
"pagination.first": "İlk",
|
||||
"pagination.previous": "Önceki",
|
||||
"search.label": "Ara",
|
||||
"search.placeholder": "Ara...",
|
||||
@ -495,5 +533,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" : "Sar:",
|
||||
"enclosure_media_controls.seek.title" : "%s saniye sar",
|
||||
"enclosure_media_controls.speed" : "Hız:",
|
||||
"enclosure_media_controls.speed.faster" : "Daha hızlı",
|
||||
"enclosure_media_controls.speed.faster.title" : "%sx kat daha hızlı",
|
||||
"enclosure_media_controls.speed.slower" : "Daha yavaş",
|
||||
"enclosure_media_controls.speed.slower.title" : "%sx kat daha yavaş",
|
||||
"enclosure_media_controls.speed.reset" : "Sıfırla",
|
||||
"enclosure_media_controls.speed.reset.title" : "Hızı 1x'e sıfırla"
|
||||
}
|
||||
|
@ -19,8 +19,8 @@
|
||||
"action.home_screen": "Додати до головного екрану",
|
||||
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
|
||||
"tooltip.logged_user": "Здійснено вхід як %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "Меню",
|
||||
"menu.home_page": "Головна сторінка",
|
||||
"menu.unread": "Непрочитане",
|
||||
"menu.starred": "З зірочкою",
|
||||
"menu.history": "Історія",
|
||||
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Відмітити все як прочитане",
|
||||
"menu.show_all_entries": "Показати всі записи",
|
||||
"menu.show_only_unread_entries": "Показати тільки непрочитані записи",
|
||||
"menu.show_only_starred_entries": "Показати тільки записи з зірочкою",
|
||||
"menu.refresh_feed": "Оновити",
|
||||
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
|
||||
"menu.edit_feed": "Редагувати",
|
||||
@ -54,9 +55,11 @@
|
||||
"menu.shared_entries": "Спільні записи",
|
||||
"search.label": "Пошук",
|
||||
"search.placeholder": "Шукати...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Вперед",
|
||||
"pagination.previous": "Назад",
|
||||
"search.submit": "Знайти",
|
||||
"pagination.last": "Остання",
|
||||
"pagination.next": "Наступна",
|
||||
"pagination.first": "Перша",
|
||||
"pagination.previous": "Попередня",
|
||||
"entry.status.unread": "Непрочитане",
|
||||
"entry.status.read": "Прочитане",
|
||||
"entry.status.toast.unread": "Відмічено непрочитаним",
|
||||
@ -89,7 +92,7 @@
|
||||
"читати %d хвилин"
|
||||
],
|
||||
"entry.tags.label": "Теги:",
|
||||
"page.shared_entries.title": "Спильні записи",
|
||||
"page.shared_entries.title": "Спільні записи",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries",
|
||||
@ -131,9 +134,9 @@
|
||||
"page.edit_category.title": "Редагування категорії: %s",
|
||||
"page.edit_user.title": "Редагування користувача: %s",
|
||||
"page.feeds.title": "Стрічки",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Категорія: %s",
|
||||
"page.feeds.last_check": "Остання перевірка:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Наступна перевірка:",
|
||||
"page.feeds.read_counter": "Кількість прочитаних записів",
|
||||
"page.feeds.error_count": [
|
||||
"%d помилка",
|
||||
@ -217,8 +220,8 @@
|
||||
"page.settings.title": "Налаштування ",
|
||||
"page.settings.link_google_account": "Підключити мій обліковий запис Google",
|
||||
"page.settings.unlink_google_account": "Відключити мій обліковий запис Google",
|
||||
"page.settings.link_oidc_account": "Підключити мій обліковий запис OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Підключити мій обліковий запис %s",
|
||||
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@ -233,7 +236,7 @@
|
||||
],
|
||||
"page.login.title": "Вхід",
|
||||
"page.login.google_signin": "Увійти через Google",
|
||||
"page.login.oidc_signin": "Увійти через OpenID Connect",
|
||||
"page.login.oidc_signin": "Увійти через %s",
|
||||
"page.login.webauthn_login": "Увійти за допомогою пароля",
|
||||
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
|
||||
"page.integrations.title": "Інтеграції",
|
||||
@ -268,6 +271,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 +313,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 +338,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": "Ім’я користувача для завантаження",
|
||||
@ -345,6 +358,13 @@
|
||||
"form.feed.label.disabled": "Не оновлювати цю стрічку",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.category.label.title": "Назва",
|
||||
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
|
||||
"form.feed.fieldset.general": "General",
|
||||
@ -385,11 +405,18 @@
|
||||
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
|
||||
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
|
||||
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +488,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",
|
||||
@ -478,6 +509,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Назва ключа API",
|
||||
"form.submit.loading": "Завантаження...",
|
||||
"form.submit.saving": "Зберігаю...",
|
||||
@ -545,5 +583,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"
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "跳转至内容",
|
||||
"confirm.question": "您确认吗?",
|
||||
"confirm.question.refresh": "您是否要强制刷新?",
|
||||
"confirm.yes": "是",
|
||||
@ -19,8 +19,8 @@
|
||||
"action.home_screen": "添加到主屏幕",
|
||||
"tooltip.keyboard_shortcuts": "快捷键: %s",
|
||||
"tooltip.logged_user": "当前登录 %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "菜单",
|
||||
"menu.home_page": "首页",
|
||||
"menu.unread": "未读",
|
||||
"menu.starred": "收藏",
|
||||
"menu.history": "历史",
|
||||
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "全部标为已读",
|
||||
"menu.show_all_entries": "显示所有文章",
|
||||
"menu.show_only_unread_entries": "仅显示未读文章",
|
||||
"menu.show_only_starred_entries": "仅显示已收藏文章",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "在后台更新全部源",
|
||||
"menu.edit_feed": "编辑",
|
||||
@ -54,8 +55,10 @@
|
||||
"menu.shared_entries": "已分享的文章",
|
||||
"search.label": "搜索",
|
||||
"search.placeholder": "搜索…",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "查找",
|
||||
"pagination.last": "最后一页",
|
||||
"pagination.next": "下一页",
|
||||
"pagination.first": "第一页",
|
||||
"pagination.previous": "上一页",
|
||||
"entry.status.unread": "标为未读",
|
||||
"entry.status.read": "标为已读",
|
||||
@ -89,18 +92,18 @@
|
||||
"entry.tags.label": "标签:",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry"
|
||||
"%d 已分享的文章"
|
||||
],
|
||||
"page.unread.title": "未读",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry"
|
||||
"%d 未读的文章"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total"
|
||||
"%d 文章总数"
|
||||
],
|
||||
"page.starred.title": "收藏",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry"
|
||||
"%d 收藏的文章"
|
||||
],
|
||||
"page.categories.title": "分类",
|
||||
"page.categories.no_feed": "没有源",
|
||||
@ -110,14 +113,14 @@
|
||||
"有 %d 个源"
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category"
|
||||
"%d 分类"
|
||||
],
|
||||
"page.new_category.title": "新分类",
|
||||
"page.new_user.title": "新用户",
|
||||
"page.edit_category.title": "编辑分类 : %s",
|
||||
"page.edit_user.title": "编辑用户 : %s",
|
||||
"page.feeds.title": "源",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "分类: %s",
|
||||
"page.feeds.last_check": "最后检查时间:",
|
||||
"page.feeds.next_check": "下次检查时间:",
|
||||
"page.feeds.read_counter": "已读文章数",
|
||||
@ -126,7 +129,7 @@
|
||||
],
|
||||
"page.history.title": "历史",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry"
|
||||
"%d 阅读文章"
|
||||
],
|
||||
"page.import.title": "导入",
|
||||
"page.search.title": "搜索结果",
|
||||
@ -199,8 +202,8 @@
|
||||
"page.settings.title": "设置",
|
||||
"page.settings.link_google_account": "关联我的 Google 账户",
|
||||
"page.settings.unlink_google_account": "解除 Google 账号关联",
|
||||
"page.settings.link_oidc_account": "关联我的 OpenID Connect 账户",
|
||||
"page.settings.unlink_oidc_account": "解除 OpenID Connect 账号关联",
|
||||
"page.settings.link_oidc_account": "关联我的 %s 账户",
|
||||
"page.settings.unlink_oidc_account": "解除 %s 账号关联",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "操作",
|
||||
"page.settings.webauthn.passkey_name": "Passkey 名称",
|
||||
@ -213,7 +216,7 @@
|
||||
],
|
||||
"page.login.title": "登录",
|
||||
"page.login.google_signin": "使用 Google 登录",
|
||||
"page.login.oidc_signin": "使用 OpenID Connect 登录",
|
||||
"page.login.oidc_signin": "使用 %s 登录",
|
||||
"page.login.webauthn_login": "使用密码登录",
|
||||
"page.login.webauthn_login.error": "无法使用密码登录",
|
||||
"page.integrations.title": "集成",
|
||||
@ -248,6 +251,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 +294,14 @@
|
||||
"error.site_url_not_empty": "源网站的网址不能为空。",
|
||||
"error.feed_title_not_empty": "订阅源的标题不能为空。",
|
||||
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
|
||||
"error.settings_block_rule_fieldname_invalid": "无效的阻止规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
|
||||
"error.settings_block_rule_separator_required": "无效的阻止规则: 规则 #%d 的模式字符必须用‘=’分开。",
|
||||
"error.settings_block_rule_regex_required": "无效的阻止规则: 规则 #%d 的模式字符没有提供。",
|
||||
"error.settings_block_rule_invalid_regex": "无效的阻止规则: 规则 #%d 的模式字符不是合法的正则表达式。",
|
||||
"error.settings_keep_rule_fieldname_invalid": "无效的保留规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
|
||||
"error.settings_keep_rule_separator_required": "无效的保留规则: 规则 #%d 的模式字符必须用‘=’分开。",
|
||||
"error.settings_keep_rule_regex_required": "无效的保留规则: 规则 #%d 的模式字符没有提供。",
|
||||
"error.settings_keep_rule_invalid_regex": "无效的保留规则: 规则 #%d 的模式字符不是合法的正则表达式。",
|
||||
"error.feed_category_not_found": "此类别不存在或不属于该用户。",
|
||||
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
|
||||
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
|
||||
@ -306,6 +318,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": "源用户名",
|
||||
@ -320,11 +333,18 @@
|
||||
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
|
||||
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "关闭 HTTP/2 避免记录指纹",
|
||||
"form.feed.label.fetch_via_proxy": "通过代理获取",
|
||||
"form.feed.label.disabled": "请勿刷新此源",
|
||||
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
|
||||
"form.feed.label.hide_globally": "隐藏全局未读列表中的文章",
|
||||
"form.feed.label.ntfy_activate": "推送条目到ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy优先级",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy最高优先级",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy高优先级",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy默认优先级",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy低优先级",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy最低优先级",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.rules": "规则",
|
||||
"form.feed.fieldset.network_settings": "网络设置",
|
||||
@ -365,11 +385,18 @@
|
||||
"form.prefs.label.default_home_page": "默认主页",
|
||||
"form.prefs.label.categories_sorting_order": "分类排序",
|
||||
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成90%%时标记为已读",
|
||||
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成90%%时标记为已读",
|
||||
"form.prefs.label.mark_read_manually": "手动标记条目为已读",
|
||||
"form.prefs.fieldset.application_settings": "应用设置",
|
||||
"form.prefs.fieldset.authentication_settings": "用户认证设置",
|
||||
"form.prefs.fieldset.reader_settings": "阅读器设置",
|
||||
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
|
||||
"form.import.label.file": "OPML 文件",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "保存文章到 Betula",
|
||||
"form.integration.betula_url": "Betula 服务地址",
|
||||
"form.integration.betula_token": "Betula 密钥",
|
||||
"form.integration.fever_activate": "启用 Fever API",
|
||||
"form.integration.fever_username": "Fever 用户名",
|
||||
"form.integration.fever_password": "Fever 密码",
|
||||
@ -379,7 +406,7 @@
|
||||
"form.integration.googlereader_password": "Google Reader 密码",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
|
||||
"form.integration.pinboard_activate": "保存文章到 Pinboard",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pinboard_token": "Pinboard API 密钥",
|
||||
"form.integration.pinboard_tags": "Pinboard 标签",
|
||||
"form.integration.pinboard_bookmark": "标记为未读",
|
||||
"form.integration.instapaper_activate": "保存文章到 Instapaper",
|
||||
@ -393,12 +420,12 @@
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端 Secret",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥",
|
||||
"form.integration.wallabag_username": "Wallabag 用户名",
|
||||
"form.integration.wallabag_password": "Wallabag 密码",
|
||||
"form.integration.notion_activate": "保存文章到 Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.notion_page_id": "Notion 页面ID",
|
||||
"form.integration.notion_token": "Notion 密钥",
|
||||
"form.integration.apprise_activate": "将新文章推送到 Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
@ -413,8 +440,8 @@
|
||||
"form.integration.espial_api_key": "Espial API 密钥",
|
||||
"form.integration.espial_tags": "Espial 标签",
|
||||
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "获取你的 Readwise Access Token",
|
||||
"form.integration.readwise_api_key": "Readwise Reader 密钥",
|
||||
"form.integration.readwise_api_key_link": "获取你的 Readwise 密钥",
|
||||
"form.integration.telegram_bot_activate": "将新文章推送到 Telegram",
|
||||
"form.integration.telegram_bot_token": "机器人令牌",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
@ -422,12 +449,12 @@
|
||||
"form.integration.telegram_bot_disable_notification": "禁用通知",
|
||||
"form.integration.telegram_bot_disable_buttons": "不展示按钮",
|
||||
"form.integration.telegram_chat_id": "聊天ID",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_activate": "保存文章到 LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API URL",
|
||||
"form.integration.linkace_api_key": "LinkAce API 密钥",
|
||||
"form.integration.linkace_tags": "LinkAce 标签",
|
||||
"form.integration.linkace_is_private": "将链接标记为私有",
|
||||
"form.integration.linkace_check_disabled": "关闭链接检查",
|
||||
"form.integration.linkding_activate": "保存文章到 Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding API 端点",
|
||||
"form.integration.linkding_api_key": "Linkding API 密钥",
|
||||
@ -441,6 +468,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": "保存文章到 Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) 密钥",
|
||||
"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 密钥",
|
||||
@ -452,12 +483,19 @@
|
||||
"form.integration.shiori_password": "Shiori 密码",
|
||||
"form.integration.shaarli_activate": "保存文章到 Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API 密钥",
|
||||
"form.integration.webhook_activate": "启用 Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.webhook_secret": "Webhook 密钥",
|
||||
"form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge 服务器 URL",
|
||||
"form.integration.ntfy_activate": "推送条目到ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy主题",
|
||||
"form.integration.ntfy_url": "Ntfy URL(可选,默认为ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API令牌(可选)",
|
||||
"form.integration.ntfy_username": "Ntfy用户名(可选)",
|
||||
"form.integration.ntfy_password": "Ntfy密码(可选)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy图标URL(可选)",
|
||||
"form.api_key.label.description": "API密钥标签",
|
||||
"form.submit.loading": "载入中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
@ -483,33 +521,42 @@
|
||||
"%d 年前"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
"多次触发订阅源更新,请等待 %d 分钟后重试。"
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "所有的订阅源都在后台刷新中。您可以继续使用Miniflux,同时此过程正在运行。",
|
||||
"error.http_response_too_large": "HTTP响应内容过大,您可以在全局设置中增加HTTP响应大小限制(需要服务器重新启动)。",
|
||||
"error.http_body_read": "无法读取HTTP主体: %v。",
|
||||
"error.http_empty_response_body": "HTTP响应主体为空。",
|
||||
"error.http_empty_response": "HTTP响应内容为空,该网站可能正在使用机器人保护机制。",
|
||||
"error.tls_error": "TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭TLS验证。",
|
||||
"error.network_operation": "Miniflux无法访问该网站由于网络错误: %v。",
|
||||
"error.network_timeout": "该网站响应过慢,请求超时: %v",
|
||||
"error.http_client_error": "HTTP 客户端错误r: %v。",
|
||||
"error.http_not_authorized": "该网站访问未授权,可能用户名和密码错误。",
|
||||
"error.http_too_many_requests": "Miniflux 对该网站请求过多次数,请稍后重试或修改应用配置项。",
|
||||
"error.http_forbidden": "该网站被禁止访问,网站可能有机器人保护机制?",
|
||||
"error.http_resource_not_found": "请求资源无法找到,请检查URL。",
|
||||
"error.http_internal_server_error": "当前由于服务器错误导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_bad_gateway": "当前由于错误的网关导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_service_unavailable": "当前由于服务器内部错误导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_gateway_timeout": "当前由于网关超时导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_unexpected_status_code": "当前由于意外的HTTP状态码:%d 导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.database_error": "数据库错误: %v。",
|
||||
"error.category_not_found": "该分类不存在或不属于该用户。",
|
||||
"error.duplicated_feed": "该订阅源已经存在。",
|
||||
"error.unable_to_parse_feed": "无法解析该订阅源: %v。",
|
||||
"error.feed_not_found": "该订阅源不存在或不属于该用户。",
|
||||
"error.unable_to_detect_rssbridge": "无法使用RSS-Bridge去检测订阅源: %v。",
|
||||
"error.feed_format_not_detected": "无法解析订阅源格式: %v。",
|
||||
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
|
||||
"error.settings_media_playback_rate_range": "播放速度超出范围"
|
||||
"error.settings_media_playback_rate_range": "播放速度超出范围",
|
||||
"enclosure_media_controls.seek" : "查找:",
|
||||
"enclosure_media_controls.seek.title" : "查找 %s 秒",
|
||||
"enclosure_media_controls.speed" : "速度:",
|
||||
"enclosure_media_controls.speed.faster" : "快进",
|
||||
"enclosure_media_controls.speed.faster.title" : "速度快进到 %sx",
|
||||
"enclosure_media_controls.speed.slower" : "减慢",
|
||||
"enclosure_media_controls.speed.slower.title" : "速度减慢到 %sx",
|
||||
"enclosure_media_controls.speed.reset" : "重置",
|
||||
"enclosure_media_controls.speed.reset.title" : "重置速度到 1x"
|
||||
}
|
||||
|
@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "全部標為已讀",
|
||||
"menu.show_all_entries": "顯示所有文章",
|
||||
"menu.show_only_unread_entries": "僅顯示未讀文章",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "背景更新全部Feeds",
|
||||
"menu.edit_feed": "編輯",
|
||||
@ -55,7 +56,9 @@
|
||||
"search.label": "搜尋",
|
||||
"search.placeholder": "搜尋…",
|
||||
"search.submit": "送出",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "下一頁",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "上一頁",
|
||||
"entry.status.unread": "標為未讀",
|
||||
"entry.status.read": "標為已讀",
|
||||
@ -199,8 +202,8 @@
|
||||
"page.settings.title": "設定",
|
||||
"page.settings.link_google_account": "關聯我的 Google 賬戶",
|
||||
"page.settings.unlink_google_account": "解除 Google 帳號關聯",
|
||||
"page.settings.link_oidc_account": "關聯我的 OpenID Connect 賬戶",
|
||||
"page.settings.unlink_oidc_account": "解除 OpenID Connect 帳號關聯",
|
||||
"page.settings.link_oidc_account": "關聯我的 %s 賬戶",
|
||||
"page.settings.unlink_oidc_account": "解除 %s 帳號關聯",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "操作",
|
||||
"page.settings.webauthn.passkey_name": "Passkey 名稱",
|
||||
@ -213,7 +216,7 @@
|
||||
],
|
||||
"page.login.title": "登入",
|
||||
"page.login.google_signin": "使用 Google 登入",
|
||||
"page.login.oidc_signin": "使用 OpenID Connect 登入",
|
||||
"page.login.oidc_signin": "使用 %s 登入",
|
||||
"page.login.webauthn_login": "使用密碼登錄",
|
||||
"page.login.webauthn_login.error": "無法使用密碼登錄",
|
||||
"page.integrations.title": "整合",
|
||||
@ -248,6 +251,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 +286,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 +318,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 使用者名稱",
|
||||
@ -325,6 +338,13 @@
|
||||
"form.feed.label.disabled": "請勿更新此 Feed",
|
||||
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
|
||||
"form.feed.label.hide_globally": "隱藏全域性未讀列表中的文章",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.rules": "規則",
|
||||
"form.feed.fieldset.network_settings": "網路設定",
|
||||
@ -365,11 +385,18 @@
|
||||
"form.prefs.label.default_home_page": "預設主頁",
|
||||
"form.prefs.label.categories_sorting_order": "分類排序",
|
||||
"form.prefs.label.mark_read_on_view": "查看時自動將條目標記為已讀",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.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 +468,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 金鑰",
|
||||
@ -458,6 +489,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge 伺服器的 URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API金鑰標籤",
|
||||
"form.submit.loading": "載入中…",
|
||||
"form.submit.saving": "儲存中…",
|
||||
@ -511,5 +549,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"
|
||||
}
|
||||
|
@ -174,7 +174,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
expected := `<p><img src="http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
@ -182,12 +182,10 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteProxyFilterWithHttpsScheme(t *testing.T) {
|
||||
func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "image")
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||
os.Setenv("HTTPS", "1")
|
||||
os.Setenv("BASE_URL", "http://example.org:88/folder/")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
@ -196,12 +194,25 @@ func TestAbsoluteProxyFilterWithHttpsScheme(t *testing.T) {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
if config.Opts.BaseURL() != "http://example.org:88/folder" {
|
||||
t.Fatalf(`Unexpected base URL, got "%s"`, config.Opts.BaseURL())
|
||||
}
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
expected := `<p><img src="https://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||
if config.Opts.RootURL() != "http://example.org:88" {
|
||||
t.Fatalf(`Unexpected root URL, got "%s"`, config.Opts.RootURL())
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
if config.Opts.BasePath() != "" {
|
||||
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
|
||||
}
|
||||
|
||||
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(router, input)
|
||||
expected := `<p><img src="http://example.org:88/folder/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||
@ -225,7 +236,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<audio src="https://website/folder/audio.mp3"></audio>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
expected := `<audio src="http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM="></audio>`
|
||||
|
||||
if expected != output {
|
||||
@ -300,7 +311,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
@ -553,3 +564,28 @@ func TestProxyFilterVideoPoster(t *testing.T) {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFilterVideoPosterOnce(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "image,video")
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
||||
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
|
||||
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@ -20,11 +21,8 @@ func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string
|
||||
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument)
|
||||
}
|
||||
|
||||
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, host, htmlDocument string) string {
|
||||
proxifyFunction := func(router *mux.Router, url string) string {
|
||||
return ProxifyAbsoluteURL(router, host, url)
|
||||
}
|
||||
return genericProxyRewriter(router, proxifyFunction, htmlDocument)
|
||||
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, htmlDocument string) string {
|
||||
return genericProxyRewriter(router, ProxifyAbsoluteURL, htmlDocument)
|
||||
}
|
||||
|
||||
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string {
|
||||
@ -53,6 +51,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
}
|
||||
})
|
||||
|
||||
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") {
|
||||
doc.Find("video").Each(func(i int, video *goquery.Selection) {
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
@ -60,6 +59,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
case "audio":
|
||||
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
|
||||
|
@ -9,13 +9,11 @@ import (
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
|
||||
@ -33,7 +31,7 @@ func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
|
||||
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
}
|
||||
|
||||
func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
|
||||
func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
|
||||
if mediaURL == "" {
|
||||
return ""
|
||||
}
|
||||
@ -42,13 +40,14 @@ func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
|
||||
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||
}
|
||||
|
||||
// Note that the proxyified URL is relative to the root URL.
|
||||
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
|
||||
scheme := "http"
|
||||
if config.Opts.HTTPS {
|
||||
scheme = "https"
|
||||
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
|
||||
if err != nil {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
return scheme + "://" + host + proxifiedUrl
|
||||
return absoluteURL
|
||||
}
|
||||
|
||||
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
@ -56,7 +55,7 @@ func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
proxyUrl, err := url.Parse(customProxyURL)
|
||||
absoluteURL, err := url.JoinPath(customProxyURL, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
if err != nil {
|
||||
slog.Error("Incorrect custom media proxy URL",
|
||||
slog.String("custom_proxy_url", customProxyURL),
|
||||
@ -65,6 +64,5 @@ func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
return proxyUrl.String()
|
||||
return absoluteURL
|
||||
}
|
||||
|
@ -2,6 +2,14 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// Enclosure represents an attachment.
|
||||
type Enclosure struct {
|
||||
@ -14,6 +22,10 @@ type Enclosure struct {
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
type EnclosureUpdateRequest struct {
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
|
||||
func (e Enclosure) Html5MimeType() string {
|
||||
if e.MimeType == "video/m4v" {
|
||||
@ -24,3 +36,42 @@ func (e Enclosure) Html5MimeType() string {
|
||||
|
||||
// EnclosureList represents a list of attachments.
|
||||
type EnclosureList []*Enclosure
|
||||
|
||||
func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
for _, enclosure := range el {
|
||||
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" {
|
||||
for i := range el {
|
||||
if urllib.IsHTTPS(el[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(el[i].MimeType, mediaType+"/") {
|
||||
el[i].URL = mediaproxy.ProxifyAbsoluteURL(router, el[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(e.URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(e.MimeType, mediaType+"/") {
|
||||
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +50,22 @@ func NewEntry() *Entry {
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldMarkAsReadOnView Return whether the entry should be marked as viewed considering all user settings and entry state.
|
||||
func (e *Entry) ShouldMarkAsReadOnView(user *User) bool {
|
||||
// Already read, no need to mark as read again. Removed entries are not marked as read
|
||||
if e.Status != EntryStatusUnread {
|
||||
return false
|
||||
}
|
||||
|
||||
// There is an enclosure, markAsRead will happen at enclosure completion time, no need to mark as read on view
|
||||
if user.MarkReadOnMediaPlayerCompletion && e.Enclosures.ContainsAudioOrVideo() {
|
||||
return false
|
||||
}
|
||||
|
||||
// The user wants to mark as read on view
|
||||
return user.MarkReadOnView
|
||||
}
|
||||
|
||||
// Entries represents a list of entries.
|
||||
type Entries []*Entry
|
||||
|
||||
|
@ -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"`
|
||||
@ -50,8 +51,10 @@ type Feed struct {
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
|
||||
// Non persisted attributes
|
||||
Category *Category `json:"category,omitempty"`
|
||||
@ -167,6 +170,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 +205,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,15 @@ type Integration struct {
|
||||
OmnivoreEnabled bool
|
||||
OmnivoreAPIKey string
|
||||
OmnivoreURL string
|
||||
RaindropEnabled bool
|
||||
RaindropToken string
|
||||
RaindropCollectionID string
|
||||
RaindropTags string
|
||||
NtfyEnabled bool
|
||||
NtfyTopic string
|
||||
NtfyURL string
|
||||
NtfyAPIToken string
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
}
|
||||
|
@ -35,7 +35,10 @@ type User struct {
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MarkReadOnMediaPlayerCompletion bool `json:"mark_read_on_media_player_completion"`
|
||||
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.
|
||||
@ -71,7 +74,10 @@ type UserModificationRequest struct {
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MarkReadOnMediaPlayerCompletion *bool `json:"mark_read_on_media_player_completion"`
|
||||
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.
|
||||
@ -164,9 +170,21 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
user.MarkReadOnView = *u.MarkReadOnView
|
||||
}
|
||||
|
||||
if u.MarkReadOnMediaPlayerCompletion != nil {
|
||||
user.MarkReadOnMediaPlayerCompletion = *u.MarkReadOnMediaPlayerCompletion
|
||||
}
|
||||
|
||||
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.
|
||||
|
55
internal/reader/fetcher/encoding_wrappers.go
Normal file
55
internal/reader/fetcher/encoding_wrappers.go
Normal file
@ -0,0 +1,55 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
type brotliReadCloser struct {
|
||||
body io.ReadCloser
|
||||
brotliReader io.Reader
|
||||
}
|
||||
|
||||
func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {
|
||||
return &brotliReadCloser{
|
||||
body: body,
|
||||
brotliReader: brotli.NewReader(body),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *brotliReadCloser) Read(p []byte) (n int, err error) {
|
||||
return b.brotliReader.Read(p)
|
||||
}
|
||||
|
||||
func (b *brotliReadCloser) Close() error {
|
||||
return b.body.Close()
|
||||
}
|
||||
|
||||
type gzipReadCloser struct {
|
||||
body io.ReadCloser
|
||||
gzipReader io.Reader
|
||||
gzipErr error
|
||||
}
|
||||
|
||||
func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {
|
||||
return &gzipReadCloser{body: body}
|
||||
}
|
||||
|
||||
func (gz *gzipReadCloser) Read(p []byte) (n int, err error) {
|
||||
if gz.gzipReader == nil {
|
||||
if gz.gzipErr == nil {
|
||||
gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)
|
||||
}
|
||||
if gz.gzipErr != nil {
|
||||
return 0, gz.gzipErr
|
||||
}
|
||||
}
|
||||
|
||||
return gz.gzipReader.Read(p)
|
||||
}
|
||||
|
||||
func (gz *gzipReadCloser) Close() error {
|
||||
return gz.body.Close()
|
||||
}
|
@ -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 {
|
||||
|
69
internal/reader/fetcher/response_handler_test.go
Normal file
69
internal/reader/fetcher/response_handler_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -169,6 +169,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
subscription.BlocklistRules = feedCreationRequest.BlocklistRules
|
||||
subscription.KeeplistRules = feedCreationRequest.KeeplistRules
|
||||
subscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules
|
||||
subscription.HideGlobally = feedCreationRequest.HideGlobally
|
||||
subscription.EtagHeader = responseHandler.ETag()
|
||||
subscription.LastModifiedHeader = responseHandler.LastModified()
|
||||
subscription.FeedURL = responseHandler.EffectiveURL()
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
@ -72,6 +73,7 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,7 @@ type opmlOutline struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -52,6 +52,7 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
|
||||
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.
|
||||
|
92
internal/reader/processor/bilibili.go
Normal file
92
internal/reader/processor/bilibili.go
Normal file
@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var (
|
||||
bilibiliURLRegex = regexp.MustCompile(`bilibili\.com/video/(.*)$`)
|
||||
bilibiliVideoIdRegex = regexp.MustCompile(`/video/(?:av(\d+)|BV([a-zA-Z0-9]+))`)
|
||||
)
|
||||
|
||||
func shouldFetchBilibiliWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchBilibiliWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := bilibiliURLRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesBilibiliPattern := len(matches) == 2
|
||||
return urlMatchesBilibiliPattern
|
||||
}
|
||||
|
||||
func extractBilibiliVideoID(websiteURL string) (string, string, error) {
|
||||
matches := bilibiliVideoIdRegex.FindStringSubmatch(websiteURL)
|
||||
if matches == nil {
|
||||
return "", "", fmt.Errorf("no video ID found in URL: %s", websiteURL)
|
||||
}
|
||||
if matches[1] != "" {
|
||||
return "aid", matches[1], nil
|
||||
}
|
||||
if matches[2] != "" {
|
||||
return "bvid", matches[2], nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unexpected regex match result for URL: %s", websiteURL)
|
||||
}
|
||||
|
||||
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
idType, videoID, extractErr := extractBilibiliVideoID(websiteURL)
|
||||
if extractErr != nil {
|
||||
return 0, extractErr
|
||||
}
|
||||
bilibiliApiURL := fmt.Sprintf("https://api.bilibili.com/x/web-interface/view?%s=%s", idType, videoID)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(bilibiliApiURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Bilibili API",
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("api_url", bilibiliApiURL),
|
||||
slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
doc := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr := doc.Decode(&result); docErr != nil {
|
||||
return 0, fmt.Errorf("failed to decode API response: %v", docErr)
|
||||
}
|
||||
|
||||
if code, ok := result["code"].(float64); !ok || code != 0 {
|
||||
return 0, fmt.Errorf("API returned error code: %v", result["code"])
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("data field not found or not an object")
|
||||
}
|
||||
|
||||
duration, ok := data["duration"].(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("duration not found or not a number")
|
||||
}
|
||||
intDuration := int(duration)
|
||||
durationMin := intDuration / 60
|
||||
if intDuration%60 != 0 {
|
||||
durationMin++
|
||||
}
|
||||
return durationMin, nil
|
||||
}
|
60
internal/reader/processor/nebula.go
Normal file
60
internal/reader/processor/nebula.go
Normal file
@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
|
||||
|
||||
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchNebulaWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := nebulaRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
60
internal/reader/processor/odysee.go
Normal file
60
internal/reader/processor/odysee.go
Normal file
@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := odyseeRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(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 Odysee 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="og: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
|
||||
}
|
@ -4,12 +4,10 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@ -20,17 +18,14 @@ import (
|
||||
"miniflux.app/v2/internal/reader/rewrite"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/reader/scraper"
|
||||
"miniflux.app/v2/internal/reader/urlcleaner"
|
||||
"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=(.*)$`)
|
||||
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\("(.*)"\|"(.*)"\)`)
|
||||
)
|
||||
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
|
||||
// ProcessFeedEntries downloads original web page for entries and apply filters.
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
|
||||
@ -42,24 +37,34 @@ 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)
|
||||
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(entry.URL); err == nil {
|
||||
entry.URL = cleanedURL
|
||||
}
|
||||
|
||||
pageBaseURL := ""
|
||||
rewrittenURL := rewriteEntryURL(feed, entry)
|
||||
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("rewritten_url", rewrittenURL),
|
||||
)
|
||||
|
||||
startTime := time.Now()
|
||||
@ -73,12 +78,16 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
content, scraperErr := scraper.ScrapeWebsite(
|
||||
scrapedPageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
websiteURL,
|
||||
rewrittenURL,
|
||||
feed.ScraperRules,
|
||||
)
|
||||
|
||||
if scrapedPageBaseURL != "" {
|
||||
pageBaseURL = scrapedPageBaseURL
|
||||
}
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
status := "success"
|
||||
if scraperErr != nil {
|
||||
@ -90,22 +99,25 @@ 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),
|
||||
slog.Any("error", scraperErr),
|
||||
)
|
||||
} else if content != "" {
|
||||
} else if extractedContent != "" {
|
||||
// We replace the entry content only if the scraper doesn't return any error.
|
||||
entry.Content = content
|
||||
entry.Content = minifyEntryContent(extractedContent)
|
||||
}
|
||||
}
|
||||
|
||||
rewrite.Rewriter(websiteURL, entry, feed.RewriteRules)
|
||||
rewrite.Rewriter(rewrittenURL, entry, feed.RewriteRules)
|
||||
|
||||
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered.
|
||||
entry.Content = sanitizer.Sanitize(websiteURL, entry.Content)
|
||||
if pageBaseURL == "" {
|
||||
pageBaseURL = rewrittenURL
|
||||
}
|
||||
|
||||
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered out.
|
||||
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
|
||||
|
||||
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
@ -114,7 +126,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 +185,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 +196,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 +255,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),
|
||||
@ -179,7 +268,7 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
|
||||
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
|
||||
startTime := time.Now()
|
||||
websiteURL := getUrlFromEntry(feed, entry)
|
||||
rewrittenEntryURL := rewriteEntryURL(feed, entry)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
@ -190,9 +279,9 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
content, scraperErr := scraper.ScrapeWebsite(
|
||||
pageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
websiteURL,
|
||||
rewrittenEntryURL,
|
||||
feed.ScraperRules,
|
||||
)
|
||||
|
||||
@ -208,49 +297,60 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
||||
return scraperErr
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
entry.Content = content
|
||||
if extractedContent != "" {
|
||||
entry.Content = minifyEntryContent(extractedContent)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
rewrite.Rewriter(websiteURL, entry, entry.Feed.RewriteRules)
|
||||
entry.Content = sanitizer.Sanitize(websiteURL, entry.Content)
|
||||
rewrite.Rewriter(rewrittenEntryURL, entry, entry.Feed.RewriteRules)
|
||||
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
|
||||
var url = entry.URL
|
||||
func rewriteEntryURL(feed *model.Feed, entry *model.Entry) string {
|
||||
var rewrittenURL = entry.URL
|
||||
if feed.UrlRewriteRules != "" {
|
||||
parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
|
||||
|
||||
if len(parts) >= 3 {
|
||||
re := regexp.MustCompile(parts[1])
|
||||
url = re.ReplaceAllString(entry.URL, parts[2])
|
||||
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 rewrittenURL
|
||||
}
|
||||
rewrittenURL = 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.String("rewritten_entry_url", rewrittenURL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
)
|
||||
} 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.String("rewritten_entry_url", rewrittenURL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
|
||||
)
|
||||
}
|
||||
}
|
||||
return url
|
||||
|
||||
return rewrittenURL
|
||||
}
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldFetchYouTubeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchYouTubeWatchTime(entry.URL)
|
||||
@ -266,7 +366,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,133 +404,34 @@ 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 shouldFetchBilibiliWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchBilibiliWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Bilibili watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YT error case and non-YT entries.
|
||||
if entry.ReadingTime == 0 {
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := odyseeRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(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 YouTube page", 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[itemprop="duration"]`).First().Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(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 Odysee 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="og: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
|
||||
}
|
||||
|
||||
// parseISO8601 parses an ISO 8601 duration string.
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
part := match[i]
|
||||
if i == 0 || name == "" || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func isRecentEntry(entry *model.Entry) bool {
|
||||
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
|
||||
@ -419,3 +439,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,58 +52,39 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseISO8601(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
duration string
|
||||
expected time.Duration
|
||||
}{
|
||||
// Live streams and radio.
|
||||
{"PT0M0S", 0},
|
||||
// https://www.youtube.com/watch?v=HLrqNhgdiC0
|
||||
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=LZa5KKfqHtA
|
||||
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=yIxEEgEuhT4
|
||||
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=bpHf1XcoiFs
|
||||
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result, err := parseISO8601(tc.duration)
|
||||
if err != nil {
|
||||
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
|
||||
}
|
||||
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRecentEntry(t *testing.T) {
|
||||
parser := config.NewParser()
|
||||
var err error
|
||||
@ -117,3 +108,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)
|
||||
}
|
||||
}
|
||||
|
100
internal/reader/processor/youtube.go
Normal file
100
internal/reader/processor/youtube.go
Normal file
@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
|
||||
)
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(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 YouTube page", 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[itemprop="duration"]`).First().Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
part := match[i]
|
||||
if i == 0 || name == "" || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
38
internal/reader/processor/youtube_test.go
Normal file
38
internal/reader/processor/youtube_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseISO8601(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
duration string
|
||||
expected time.Duration
|
||||
}{
|
||||
// Live streams and radio.
|
||||
{"PT0M0S", 0},
|
||||
// https://www.youtube.com/watch?v=HLrqNhgdiC0
|
||||
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=LZa5KKfqHtA
|
||||
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=yIxEEgEuhT4
|
||||
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=bpHf1XcoiFs
|
||||
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result, err := parseISO8601(tc.duration)
|
||||
if err != nil {
|
||||
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
|
||||
}
|
||||
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
|
||||
}
|
||||
}
|
||||
}
|
@ -12,6 +12,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
@ -69,10 +71,17 @@ func (c candidateList) String() string {
|
||||
}
|
||||
|
||||
// ExtractContent returns relevant content.
|
||||
func ExtractContent(page io.Reader) (string, error) {
|
||||
func ExtractContent(page io.Reader) (baseURL string, extractedContent string, err error) {
|
||||
document, err := goquery.NewDocumentFromReader(page)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
document.Find("script,style").Each(func(i int, s *goquery.Selection) {
|
||||
@ -86,12 +95,13 @@ func ExtractContent(page io.Reader) (string, error) {
|
||||
topCandidate := getTopCandidate(document, candidates)
|
||||
|
||||
slog.Debug("Readability parsing",
|
||||
slog.String("base_url", baseURL),
|
||||
slog.Any("candidates", candidates),
|
||||
slog.Any("topCandidate", topCandidate),
|
||||
)
|
||||
|
||||
output := getArticle(topCandidate, candidates)
|
||||
return output, nil
|
||||
extractedContent = getArticle(topCandidate, candidates)
|
||||
return baseURL, extractedContent, nil
|
||||
}
|
||||
|
||||
// Now that we have the top candidate, look through its siblings for content that might also be related.
|
||||
|
102
internal/reader/readability/readability_test.go
Normal file
102
internal/reader/readability/readability_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package readability // import "miniflux.app/v2/internal/reader/readability"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBaseURL(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<base href="https://example.org/ ">
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
Some content
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
baseURL, _, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if baseURL != "https://example.org/" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of "https://example.org/"`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleBaseURL(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<base href="https://example.org/ ">
|
||||
<base href="https://example.com/ ">
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
Some content
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
baseURL, _, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if baseURL != "https://example.org/" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of "https://example.org/"`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeBaseURL(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<base href="/test/ ">
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
Some content
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
baseURL, _, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if baseURL != "" {
|
||||
t.Errorf(`Unexpected base URL, got %q`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutBaseURL(t *testing.T) {
|
||||
html := `
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
Some content
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
baseURL, _, err := ExtractContent(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if baseURL != "" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of ""`, baseURL)
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/reader/urlcleaner"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
@ -23,6 +24,7 @@ var (
|
||||
"a": {"href", "title", "id"},
|
||||
"abbr": {"title"},
|
||||
"acronym": {"title"},
|
||||
"aside": {},
|
||||
"audio": {"src"},
|
||||
"blockquote": {},
|
||||
"br": {},
|
||||
@ -82,7 +84,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 +100,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 +118,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 +133,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 {
|
||||
@ -210,6 +213,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
if !hasValidURIScheme(value) || isBlockedResource(value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(value); err == nil {
|
||||
value = cleanedURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -490,6 +490,26 @@ func TestBlacklistedLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkWithTrackers(t *testing.T) {
|
||||
input := `<p>This link has trackers <a href="https://example.com/page?utm_source=newsletter">Test</a></p>`
|
||||
expected := `<p>This link has trackers <a href="https://example.com/page" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">Test</a></p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSrcWithTrackers(t *testing.T) {
|
||||
input := `<p>This image has trackers <img src="https://example.org/?id=123&utm_source=newsletter&utm_medium=email&fbclid=abc123"></p>`
|
||||
expected := `<p>This image has trackers <img src="https://example.org/?id=123" loading="lazy"></p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPixelTracker(t *testing.T) {
|
||||
input := `<p><img src="https://tracker1.example.org/" height="1" width="1"> and <img src="https://tracker2.example.org/" height="1" width="1"/></p>`
|
||||
expected := `<p> and </p>`
|
||||
@ -630,3 +650,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",
|
||||
|
@ -18,72 +18,77 @@ import (
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
func ScrapeWebsite(requestBuilder *fetcher.RequestBuilder, websiteURL, rules string) (string, error) {
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
func ScrapeWebsite(requestBuilder *fetcher.RequestBuilder, pageURL, rules string) (baseURL string, extractedContent string, err error) {
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(pageURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to scrape website", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return "", localizedError.Error()
|
||||
slog.Warn("Unable to scrape website", slog.String("website_url", pageURL), slog.Any("error", localizedError.Error()))
|
||||
return "", "", localizedError.Error()
|
||||
}
|
||||
|
||||
if !isAllowedContentType(responseHandler.ContentType()) {
|
||||
return "", fmt.Errorf("scraper: this resource is not a HTML document (%s)", responseHandler.ContentType())
|
||||
return "", "", fmt.Errorf("scraper: this resource is not a HTML document (%s)", responseHandler.ContentType())
|
||||
}
|
||||
|
||||
// The entry URL could redirect somewhere else.
|
||||
sameSite := urllib.Domain(websiteURL) == urllib.Domain(responseHandler.EffectiveURL())
|
||||
websiteURL = responseHandler.EffectiveURL()
|
||||
sameSite := urllib.Domain(pageURL) == urllib.Domain(responseHandler.EffectiveURL())
|
||||
pageURL = responseHandler.EffectiveURL()
|
||||
|
||||
if rules == "" {
|
||||
rules = getPredefinedScraperRules(websiteURL)
|
||||
rules = getPredefinedScraperRules(pageURL)
|
||||
}
|
||||
|
||||
var content string
|
||||
var err error
|
||||
|
||||
htmlDocumentReader, err := charset.NewReader(
|
||||
responseHandler.Body(config.Opts.HTTPClientMaxBodySize()),
|
||||
responseHandler.ContentType(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scraper: unable to read HTML document: %v", err)
|
||||
return "", "", fmt.Errorf("scraper: unable to read HTML document with charset reader: %v", err)
|
||||
}
|
||||
|
||||
if sameSite && rules != "" {
|
||||
slog.Debug("Extracting content with custom rules",
|
||||
"url", websiteURL,
|
||||
"url", pageURL,
|
||||
"rules", rules,
|
||||
)
|
||||
content, err = findContentUsingCustomRules(htmlDocumentReader, rules)
|
||||
baseURL, extractedContent, err = findContentUsingCustomRules(htmlDocumentReader, rules)
|
||||
} else {
|
||||
slog.Debug("Extracting content with readability",
|
||||
"url", websiteURL,
|
||||
"url", pageURL,
|
||||
)
|
||||
content, err = readability.ExtractContent(htmlDocumentReader)
|
||||
baseURL, extractedContent, err = readability.ExtractContent(htmlDocumentReader)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
if baseURL == "" {
|
||||
baseURL = pageURL
|
||||
} else {
|
||||
slog.Debug("Using base URL from HTML document", "base_url", baseURL)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
return baseURL, extractedContent, nil
|
||||
}
|
||||
|
||||
func findContentUsingCustomRules(page io.Reader, rules string) (string, error) {
|
||||
func findContentUsingCustomRules(page io.Reader, rules string) (baseURL string, extractedContent string, err error) {
|
||||
document, err := goquery.NewDocumentFromReader(page)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if hrefValue, exists := document.Find("head base").First().Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
baseURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
contents := ""
|
||||
document.Find(rules).Each(func(i int, s *goquery.Selection) {
|
||||
if content, err := goquery.OuterHtml(s); err == nil {
|
||||
contents += content
|
||||
extractedContent += content
|
||||
}
|
||||
})
|
||||
|
||||
return contents, nil
|
||||
return baseURL, extractedContent, nil
|
||||
}
|
||||
|
||||
func getPredefinedScraperRules(websiteURL string) string {
|
||||
|
@ -62,7 +62,7 @@ func TestSelectorRules(t *testing.T) {
|
||||
t.Fatalf(`Unable to read file %q: %v`, filename, err)
|
||||
}
|
||||
|
||||
actualResult, err := findContentUsingCustomRules(bytes.NewReader(html), rule)
|
||||
_, actualResult, err := findContentUsingCustomRules(bytes.NewReader(html), rule)
|
||||
if err != nil {
|
||||
t.Fatalf(`Scraping error for %q - %q: %v`, filename, rule, err)
|
||||
}
|
||||
@ -73,7 +73,67 @@ func TestSelectorRules(t *testing.T) {
|
||||
}
|
||||
|
||||
if actualResult != strings.TrimSpace(string(expectedResult)) {
|
||||
t.Errorf(`Unexpected result for %q, got "%s" instead of "%s"`, rule, actualResult, expectedResult)
|
||||
t.Errorf(`Unexpected result for %q, got %q instead of %q`, rule, actualResult, expectedResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBaseURLWithCustomRules(t *testing.T) {
|
||||
html := `<html><head><base href="https://example.com/"></head><body><img src="image.jpg"></body></html>`
|
||||
baseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), "img")
|
||||
if err != nil {
|
||||
t.Fatalf(`Scraping error: %v`, err)
|
||||
}
|
||||
|
||||
if baseURL != "https://example.com/" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of "https://example.com/"`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipleBaseURLWithCustomRules(t *testing.T) {
|
||||
html := `<html><head><base href="https://example.com/"><base href="https://example.org/"/></head><body><img src="image.jpg"></body></html>`
|
||||
baseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), "img")
|
||||
if err != nil {
|
||||
t.Fatalf(`Scraping error: %v`, err)
|
||||
}
|
||||
|
||||
if baseURL != "https://example.com/" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of "https://example.com/"`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRelativeBaseURLWithCustomRules(t *testing.T) {
|
||||
html := `<html><head><base href="/test"></head><body><img src="image.jpg"></body></html>`
|
||||
baseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), "img")
|
||||
if err != nil {
|
||||
t.Fatalf(`Scraping error: %v`, err)
|
||||
}
|
||||
|
||||
if baseURL != "" {
|
||||
t.Errorf(`Unexpected base URL, got %q`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptyBaseURLWithCustomRules(t *testing.T) {
|
||||
html := `<html><head><base href=" "></head><body><img src="image.jpg"></body></html>`
|
||||
baseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), "img")
|
||||
if err != nil {
|
||||
t.Fatalf(`Scraping error: %v`, err)
|
||||
}
|
||||
|
||||
if baseURL != "" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of ""`, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingBaseURLWithCustomRules(t *testing.T) {
|
||||
html := `<html><head></head><body><img src="image.jpg"></body></html>`
|
||||
baseURL, _, err := findContentUsingCustomRules(strings.NewReader(html), "img")
|
||||
if err != nil {
|
||||
t.Fatalf(`Scraping error: %v`, err)
|
||||
}
|
||||
|
||||
if baseURL != "" {
|
||||
t.Errorf(`Unexpected base URL, got %q instead of ""`, baseURL)
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/rssbridge"
|
||||
@ -23,8 +25,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)$`)
|
||||
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
youtubeHostRegex = regexp.MustCompile(`youtube\.com$`)
|
||||
youtubeChannelRegex = regexp.MustCompile(`channel/(.*)$`)
|
||||
)
|
||||
|
||||
type SubscriptionFinder struct {
|
||||
@ -68,7 +70,7 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
LastModified: responseHandler.LastModified(),
|
||||
}
|
||||
|
||||
// Step 1) Check if the website URL is a feed.
|
||||
// Step 1) Check if the website URL is already a feed.
|
||||
if feedFormat, _ := parser.DetectFeedFormat(f.feedResponseInfo.Content); feedFormat != parser.FormatUnknown {
|
||||
f.feedDownloaded = true
|
||||
return Subscriptions{NewSubscription(responseHandler.EffectiveURL(), responseHandler.EffectiveURL(), feedFormat)}, nil
|
||||
@ -76,25 +78,19 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
|
||||
// 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 {
|
||||
if subscriptions, localizedError := f.FindSubscriptionsFromYouTubeChannelPage(websiteURL); localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
|
||||
if len(subscriptions) > 0 {
|
||||
} else 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 {
|
||||
// Step 3) Check if the website URL is a YouTube playlist.
|
||||
slog.Debug("Try to detect feeds from YouTube playlist page", slog.String("website_url", websiteURL))
|
||||
if subscriptions, localizedError := f.FindSubscriptionsFromYouTubePlaylistPage(websiteURL); 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))
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from YouTube playlist page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
@ -103,12 +99,9 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("content_type", responseHandler.ContentType()),
|
||||
)
|
||||
subscriptions, localizedError = f.FindSubscriptionsFromWebPage(websiteURL, responseHandler.ContentType(), bytes.NewReader(responseBody))
|
||||
if localizedError != nil {
|
||||
if subscriptions, localizedError := f.FindSubscriptionsFromWebPage(websiteURL, responseHandler.ContentType(), bytes.NewReader(responseBody)); localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
|
||||
if len(subscriptions) > 0 {
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from web page", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
@ -116,12 +109,9 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
// Step 5) 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)
|
||||
if localizedError != nil {
|
||||
if subscriptions, localizedError := f.FindSubscriptionsFromRSSBridge(websiteURL, rssBridgeURL); localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
|
||||
if len(subscriptions) > 0 {
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found from RSS-Bridge", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
@ -129,12 +119,9 @@ func (f *SubscriptionFinder) FindSubscriptions(websiteURL, rssBridgeURL string)
|
||||
|
||||
// Step 6) 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 {
|
||||
if subscriptions, localizedError := f.FindSubscriptionsFromWellKnownURLs(websiteURL); localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
|
||||
if len(subscriptions) > 0 {
|
||||
} else if len(subscriptions) > 0 {
|
||||
slog.Debug("Subscriptions found with well-known URLs", slog.String("website_url", websiteURL), slog.Any("subscriptions", subscriptions))
|
||||
return subscriptions, nil
|
||||
}
|
||||
@ -160,6 +147,13 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWebPage(websiteURL, contentTyp
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.unable_to_parse_html_document", err)
|
||||
}
|
||||
|
||||
if hrefValue, exists := doc.Find("head base").First().Attr("href"); exists {
|
||||
hrefValue = strings.TrimSpace(hrefValue)
|
||||
if urllib.IsAbsoluteURL(hrefValue) {
|
||||
websiteURL = hrefValue
|
||||
}
|
||||
}
|
||||
|
||||
var subscriptions Subscriptions
|
||||
subscriptionURLs := make(map[string]bool)
|
||||
for query, kind := range queries {
|
||||
@ -285,38 +279,38 @@ func (f *SubscriptionFinder) FindSubscriptionsFromRSSBridge(websiteURL, rssBridg
|
||||
}
|
||||
|
||||
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubeChannelPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
matches := youtubeChannelRegex.FindStringSubmatch(websiteURL)
|
||||
decodedUrl, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if len(matches) == 2 {
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if matches := youtubeChannelRegex.FindStringSubmatch(decodedUrl.Path); len(matches) == 2 {
|
||||
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, matches[1])
|
||||
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) {
|
||||
slog.Debug("This website is not a YouTube video page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
func (f *SubscriptionFinder) FindSubscriptionsFromYouTubePlaylistPage(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
decodedUrl, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(err, "error.invalid_site_url", err)
|
||||
}
|
||||
|
||||
if !youtubeHostRegex.MatchString(decodedUrl.Host) {
|
||||
slog.Debug("This website is not a YouTube page, the regex doesn't match", slog.String("website_url", websiteURL))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
return nil, localizedError
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(docErr, "error.unable_to_parse_html_document", docErr)
|
||||
}
|
||||
|
||||
if channelID, exists := doc.Find(`meta[itemprop="channelId"]`).First().Attr("content"); exists {
|
||||
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?channel_id=%s`, channelID)
|
||||
if (strings.HasPrefix(decodedUrl.Path, "/watch") && decodedUrl.Query().Has("list")) || strings.HasPrefix(decodedUrl.Path, "/playlist") {
|
||||
playlistID := decodedUrl.Query().Get("list")
|
||||
feedURL := fmt.Sprintf(`https://www.youtube.com/feeds/videos.xml?playlist_id=%s`, playlistID)
|
||||
return Subscriptions{NewSubscription(websiteURL, feedURL, parser.FormatAtom)}, nil
|
||||
}
|
||||
|
||||
|
@ -8,23 +8,180 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
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",
|
||||
func TestFindYoutubePlaylistFeed(t *testing.T) {
|
||||
type testResult struct {
|
||||
websiteURL string
|
||||
feedURL string
|
||||
discoveryError bool
|
||||
}
|
||||
|
||||
for websiteURL, expectedFeedURL := range scenarios {
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubeChannelPage(websiteURL)
|
||||
scenarios := []testResult{
|
||||
// Video URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
feedURL: "",
|
||||
},
|
||||
// Video URL with position argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1",
|
||||
feedURL: "",
|
||||
},
|
||||
// Video URL with position argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ",
|
||||
feedURL: "",
|
||||
},
|
||||
// Channel URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
|
||||
feedURL: "",
|
||||
},
|
||||
// Channel URL with name
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/@ABCDEFG",
|
||||
feedURL: "",
|
||||
},
|
||||
// Playlist URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
},
|
||||
// Playlist URL with video ID
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
},
|
||||
// Playlist URL with video ID and index argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?playlist_id=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
},
|
||||
// Non-Youtube URL
|
||||
{
|
||||
websiteURL: "https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
|
||||
feedURL: "",
|
||||
},
|
||||
// Invalid URL
|
||||
{
|
||||
websiteURL: "https://example|org/",
|
||||
feedURL: "",
|
||||
discoveryError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubePlaylistPage(scenario.websiteURL)
|
||||
if scenario.discoveryError {
|
||||
if localizedError == nil {
|
||||
t.Fatalf(`Parsing an invalid URL should return an error`)
|
||||
}
|
||||
}
|
||||
|
||||
if scenario.feedURL == "" {
|
||||
if len(subscriptions) > 0 {
|
||||
t.Fatalf(`Parsing a non-playlist URL should not return any subscription: %q`, scenario.websiteURL)
|
||||
}
|
||||
} else {
|
||||
if localizedError != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted YouTube playlist page should not return any error: %v`, localizedError)
|
||||
}
|
||||
|
||||
if len(subscriptions) != 1 {
|
||||
t.Fatalf(`Incorrect number of subscriptions returned`)
|
||||
}
|
||||
|
||||
if subscriptions[0].URL != scenario.feedURL {
|
||||
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindYoutubeChannelFeed(t *testing.T) {
|
||||
type testResult struct {
|
||||
websiteURL string
|
||||
feedURL string
|
||||
discoveryError bool
|
||||
}
|
||||
|
||||
scenarios := []testResult{
|
||||
// Video URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
feedURL: "",
|
||||
},
|
||||
// Video URL with position argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1",
|
||||
feedURL: "",
|
||||
},
|
||||
// Video URL with position argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?t=1&v=dQw4w9WgXcQ",
|
||||
feedURL: "",
|
||||
},
|
||||
// Channel URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
|
||||
feedURL: "https://www.youtube.com/feeds/videos.xml?channel_id=UC-Qj80avWItNRjkZ41rzHyw",
|
||||
},
|
||||
// Channel URL with name
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/@ABCDEFG",
|
||||
feedURL: "",
|
||||
},
|
||||
// Playlist URL
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/playlist?list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR",
|
||||
feedURL: "",
|
||||
},
|
||||
// Playlist URL with video ID
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLOOwEPgFWm_N42HlCLhqyJ0ZBWr5K1QDM",
|
||||
feedURL: "",
|
||||
},
|
||||
// Playlist URL with video ID and index argument
|
||||
{
|
||||
websiteURL: "https://www.youtube.com/watch?v=6IutBmRJNLk&list=PLOOwEPgFWm_NHcQd9aCi5JXWASHO_n5uR&index=4",
|
||||
feedURL: "",
|
||||
},
|
||||
// Non-Youtube URL
|
||||
{
|
||||
websiteURL: "https://www.example.com/channel/UC-Qj80avWItNRjkZ41rzHyw",
|
||||
feedURL: "",
|
||||
},
|
||||
// Invalid URL
|
||||
{
|
||||
websiteURL: "https://example|org/",
|
||||
feedURL: "",
|
||||
discoveryError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
subscriptions, localizedError := NewSubscriptionFinder(nil).FindSubscriptionsFromYouTubeChannelPage(scenario.websiteURL)
|
||||
if scenario.discoveryError {
|
||||
if localizedError == nil {
|
||||
t.Fatalf(`Parsing an invalid URL should return an error`)
|
||||
}
|
||||
}
|
||||
|
||||
if scenario.feedURL == "" {
|
||||
if len(subscriptions) > 0 {
|
||||
t.Fatalf(`Parsing a non-channel URL should not return any subscription: %q`, scenario.websiteURL)
|
||||
}
|
||||
} else {
|
||||
if localizedError != nil {
|
||||
t.Fatalf(`Parsing a correctly formatted YouTube channel page should not return any error: %v`, localizedError)
|
||||
}
|
||||
|
||||
if len(subscriptions) != 1 {
|
||||
t.Fatal(`Incorrect number of subscriptions returned`)
|
||||
t.Fatalf(`Incorrect number of subscriptions returned`)
|
||||
}
|
||||
|
||||
if subscriptions[0].URL != expectedFeedURL {
|
||||
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, expectedFeedURL)
|
||||
if subscriptions[0].URL != scenario.feedURL {
|
||||
t.Errorf(`Unexpected Feed, got %s, instead of %s`, subscriptions[0].URL, scenario.feedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
103
internal/reader/urlcleaner/urlcleaner.go
Normal file
103
internal/reader/urlcleaner/urlcleaner.go
Normal file
@ -0,0 +1,103 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package urlcleaner // import "miniflux.app/v2/internal/reader/urlcleaner"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Interesting lists:
|
||||
// https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/TrackParamFilter/sections/general_url.txt
|
||||
// https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/query-stripping/records
|
||||
var trackingParams = map[string]bool{
|
||||
// https://en.wikipedia.org/wiki/UTM_parameters#Parameters
|
||||
"utm_source": true,
|
||||
"utm_medium": true,
|
||||
"utm_campaign": true,
|
||||
"utm_term": true,
|
||||
"utm_content": true,
|
||||
|
||||
// Facebook Click Identifiers
|
||||
"fbclid": true,
|
||||
"_openstat": true,
|
||||
|
||||
// Google Click Identifiers
|
||||
"gclid": true,
|
||||
"dclid": true,
|
||||
"gbraid": true,
|
||||
"wbraid": true,
|
||||
|
||||
// Yandex Click Identifiers
|
||||
"yclid": true,
|
||||
"ysclid": true,
|
||||
|
||||
// Twitter Click Identifier
|
||||
"twclid": true,
|
||||
|
||||
// Microsoft Click Identifier
|
||||
"msclkid": true,
|
||||
|
||||
// Mailchimp Click Identifiers
|
||||
"mc_cid": true,
|
||||
"mc_eid": true,
|
||||
|
||||
// Wicked Reports click tracking
|
||||
"wickedid": true,
|
||||
|
||||
// Hubspot Click Identifiers
|
||||
"hsa_cam": true,
|
||||
"_hsenc": true,
|
||||
"__hssc": true,
|
||||
"__hstc": true,
|
||||
"__hsfp": true,
|
||||
"hsctatracking": true,
|
||||
|
||||
// Olytics
|
||||
"rb_clickid": true,
|
||||
"oly_anon_id": true,
|
||||
"oly_enc_id": true,
|
||||
|
||||
// Vero Click Identifier
|
||||
"vero_id": true,
|
||||
|
||||
// Marketo email tracking
|
||||
"mkt_tok": true,
|
||||
}
|
||||
|
||||
func RemoveTrackingParameters(inputURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(inputURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("urlcleaner: error parsing URL: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(parsedURL.Scheme, "http") {
|
||||
return inputURL, nil
|
||||
}
|
||||
|
||||
queryParams := parsedURL.Query()
|
||||
hasTrackers := false
|
||||
|
||||
// Remove tracking parameters
|
||||
for param := range queryParams {
|
||||
if trackingParams[strings.ToLower(param)] {
|
||||
queryParams.Del(param)
|
||||
hasTrackers = true
|
||||
}
|
||||
}
|
||||
|
||||
// Do not modify the URL if there are no tracking parameters
|
||||
if !hasTrackers {
|
||||
return inputURL, nil
|
||||
}
|
||||
|
||||
parsedURL.RawQuery = queryParams.Encode()
|
||||
|
||||
// Remove trailing "?" if query string is empty
|
||||
cleanedURL := parsedURL.String()
|
||||
cleanedURL = strings.TrimSuffix(cleanedURL, "?")
|
||||
|
||||
return cleanedURL, nil
|
||||
}
|
120
internal/reader/urlcleaner/urlcleaner_test.go
Normal file
120
internal/reader/urlcleaner/urlcleaner_test.go
Normal file
@ -0,0 +1,120 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package urlcleaner // import "miniflux.app/v2/internal/reader/urlcleaner"
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveTrackingParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
strictComparison bool
|
||||
}{
|
||||
{
|
||||
name: "URL with tracking parameters",
|
||||
input: "https://example.com/page?id=123&utm_source=newsletter&utm_medium=email&fbclid=abc123",
|
||||
expected: "https://example.com/page?id=123",
|
||||
},
|
||||
{
|
||||
name: "URL with only tracking parameters",
|
||||
input: "https://example.com/page?utm_source=newsletter&utm_medium=email",
|
||||
expected: "https://example.com/page",
|
||||
},
|
||||
{
|
||||
name: "URL with no tracking parameters",
|
||||
input: "https://example.com/page?id=123&foo=bar",
|
||||
expected: "https://example.com/page?id=123&foo=bar",
|
||||
},
|
||||
{
|
||||
name: "URL with no parameters",
|
||||
input: "https://example.com/page",
|
||||
expected: "https://example.com/page",
|
||||
strictComparison: true,
|
||||
},
|
||||
{
|
||||
name: "URL with mixed case tracking parameters",
|
||||
input: "https://example.com/page?UTM_SOURCE=newsletter&utm_MEDIUM=email",
|
||||
expected: "https://example.com/page",
|
||||
},
|
||||
{
|
||||
name: "URL with tracking parameters and fragments",
|
||||
input: "https://example.com/page?id=123&utm_source=newsletter#section1",
|
||||
expected: "https://example.com/page?id=123#section1",
|
||||
},
|
||||
{
|
||||
name: "URL with only tracking parameters and fragments",
|
||||
input: "https://example.com/page?utm_source=newsletter#section1",
|
||||
expected: "https://example.com/page#section1",
|
||||
},
|
||||
{
|
||||
name: "URL with only one tracking parameter",
|
||||
input: "https://example.com/page?utm_source=newsletter",
|
||||
expected: "https://example.com/page",
|
||||
},
|
||||
{
|
||||
name: "URL with encoded characters",
|
||||
input: "https://example.com/page?name=John%20Doe&utm_source=newsletter",
|
||||
expected: "https://example.com/page?name=John+Doe",
|
||||
},
|
||||
{
|
||||
name: "Non-standard URL parameter with no tracker",
|
||||
input: "https://example.com/foo.jpg?crop/1420x708/format/webp",
|
||||
expected: "https://example.com/foo.jpg?crop/1420x708/format/webp",
|
||||
strictComparison: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid URL",
|
||||
input: "https://example|org/",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Non-HTTP URL",
|
||||
input: "mailto:user@example.org",
|
||||
expected: "mailto:user@example.org",
|
||||
strictComparison: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := RemoveTrackingParameters(tt.input)
|
||||
if tt.expected == "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error for invalid URL, but got none")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if tt.strictComparison && result != tt.expected {
|
||||
t.Errorf("removeTrackingParams(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
if !urlsEqual(result, tt.expected) {
|
||||
t.Errorf("removeTrackingParams(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// urlsEqual compares two URLs for equality, ignoring the order of query parameters
|
||||
func urlsEqual(url1, url2 string) bool {
|
||||
u1, err1 := url.Parse(url1)
|
||||
u2, err2 := url.Parse(url2)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if u1.Scheme != u2.Scheme || u1.Host != u2.Host || u1.Path != u2.Path || u1.Fragment != u2.Fragment {
|
||||
return false
|
||||
}
|
||||
|
||||
return reflect.DeepEqual(u1.Query(), u2.Query())
|
||||
}
|
@ -90,7 +90,9 @@ func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
|
||||
&enclosure.MediaProgression,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user