Compare commits

..

No commits in common. "main" and "2.1.1" have entirely different histories.
main ... 2.1.1

238 changed files with 8846 additions and 18758 deletions

View File

@ -1,7 +1,7 @@
version: '3.8' version: '3.8'
services: services:
app: app:
image: mcr.microsoft.com/devcontainers/go:1.23 image: mcr.microsoft.com/devcontainers/go
volumes: volumes:
- ..:/workspace:cached - ..:/workspace:cached
command: sleep infinity command: sleep infinity
@ -24,7 +24,7 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
apprise: apprise:
image: caronc/apprise:1.0 image: caronc/apprise:latest
restart: unless-stopped restart: unless-stopped
hostname: apprise hostname: apprise
volumes: volumes:

View File

@ -1,7 +1,4 @@
Do you follow the guidelines? Do you follow the guidelines?
- [ ] I have tested my changes - [ ] I have tested my changes
- [ ] There is no breaking changes
- [ ] I really tested my changes and there is no regression
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
- [ ] I read this document: https://miniflux.app/faq.html#pull-request - [ ] I read this document: https://miniflux.app/faq.html#pull-request

View File

@ -12,8 +12,7 @@ jobs:
- name: Set up Golang - name: Set up Golang
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.22"
check-latest: true
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Compile binaries - name: Compile binaries

View File

@ -31,7 +31,7 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.22"
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v3

View File

@ -56,7 +56,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 3 retention-days: 3
publish-packages: publish-packages:
if: github.event_name == 'push' if: github.event.push
name: Publish Packages name: Publish Packages
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

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

View File

@ -13,27 +13,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install linters - name: Install jshint
run: | run: |
sudo npm install -g jshint@2.13.6 eslint@8.57.0 sudo npm install -g jshint@2.13.3
- name: Run jshint - name: Run jshint
run: jshint internal/ui/static/js/*.js run: jshint ui/static/js/*.js
- name: Run ESLint
run: eslint internal/ui/static/js/*.js
golangci: golangci:
name: Golang Linters name: Golang Linter
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.22"
- run: "go vet ./..." - run: "go vet ./..."
- uses: golangci/golangci-lint-action@v6 - uses: golangci/golangci-lint-action@v4
with: with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
- uses: dominikh/staticcheck-action@v1.3.1 - uses: dominikh/staticcheck-action@v1.3.0
with: with:
version: "2024.1.1" version: "2023.1.7"
install-go: false install-go: false

View File

@ -38,7 +38,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 3 retention-days: 3
publish-package: publish-package:
if: github.event_name == 'push' if: github.event.push
name: Publish Packages name: Publish Packages
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -15,7 +15,7 @@ jobs:
max-parallel: 4 max-parallel: 4
matrix: matrix:
os: [ubuntu-latest, windows-latest, macOS-latest] os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: ["1.23.x"] go-version: ["1.22"]
steps: steps:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
@ -43,7 +43,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.22"
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Postgres client - name: Install Postgres client

9
.gitignore vendored
View File

@ -1,7 +1,6 @@
./*.sha256 miniflux-*
./miniflux ./miniflux
.idea
.vscode
*.deb
*.rpm *.rpm
miniflux-* *.deb
.idea
.vscode

191
ChangeLog
View File

@ -1,194 +1,3 @@
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)
------------------------------
* `api`: rewrite API integration tests without build tags
* `ci`: add basic ESLinter checks
* `ci`: enable go-critic linter and fix various issues detected
* `ci`: fix JavaScript linter path in GitHub Actions
* `cli`: avoid misleading error message when creating an admin user automatically
* `config`: add `FILTER_ENTRY_MAX_AGE_DAYS` option
* `config`: bump the number of simultaneous workers
* `config`: rename `PROXY_*` options to `MEDIA_PROXY_*`
* `config`: use `crypto.GenerateRandomBytes` instead of doing it by hand
* `http/request`: refactor conditions to be more idiomatic
* `http/response`: remove legacy `X-XSS-Protection` header
* `integration/rssbrige`: fix rssbrige import
* `integration/shaarli`: factorize the header+payload concatenation as data
* `integration/shaarli`: no need to base64-encode then remove the padding when we can simply encode without padding
* `integration/shaarli`: the JWT token was declared as using HS256 as algorithm, but was using HS512
* `integration/webhook`: add category title to request body
* `locale`: update Turkish translations
* `man page`: sort config options in alphabetical order
* `mediaproxy`: reduce the internal indentation of `ProxifiedUrl` by inverting some conditions
* `mediaproxy`: simplify and refactor the package
* `model`: replace` Optional{Int,Int64,Float64}` with a generic function `OptionalNumber()`
* `model`: use struct embedding for `FeedCreationRequestFromSubscriptionDiscovery` to reduce code duplication
* `reader/atom`: avoid debug message when the date is empty
* `reader/atom`: change `if !a { a = } if !a {a = }` constructs into `if !a { a = ; if !a {a = }}` to reduce the number of comparisons and improve readability
* `reader/atom`: Move the population of the feed's entries into a new function, to make BuildFeed easier to understand/separate concerns/implementation details
* `reader/atom`: refactor Atom parser to use an adapter
* `reader/atom`: use `sort+compact` instead of `compact+sort` to remove duplicates
* `reader/atom`: when detecting the format, detect its version as well
* `reader/encoding`: inline a one-liner function
* `reader/handler`: fix force refresh feature
* `reader/json`: refactor JSON Feed parser to use an adapter
* `reader/media`: remove a superfluous error-check: `strconv.ParseInt` returns `0` when passed an empty string
* `reader/media`: simplify switch-case by moving a common condition above it
* `reader/processor`: compile block/keep regex only once per feed
* `reader/rdf`: refactor RDF parser to use an adapter
* `reader/rewrite`: inline some one-line functions
* `reader/rewrite`: simplify `removeClickbait`
* `reader/rewrite`: transform a free-standing function into a method
* `reader/rewrite`: use a proper constant instead of a magic number in `applyFuncOnTextContent`
* `reader/rss`: add support for `<media:category>` element
* `reader/rss`: don't add empty tags to RSS items
* `reader/rss`: refactor RSS parser to use a default namespace to avoid some limitations of the Go XML parser
* `reader/rss`: refactor RSS Parser to use an adapter
* `reader/rss`: remove some duplicated code in RSS parser
* `reader`: ensure that enclosure URLs are always absolute
* `reader`: move iTunes and GooglePlay XML definitions to their own packages
* `reader`: parse podcast categories
* `reader`: remove trailing space in `SiteURL` and `FeedURL`
* `storage`: do not store empty tags
* `storage`: simplify `removeDuplicates()` to use a `sort`+`compact` construct instead of doing it by hand with a hashmap
* `storage`: Use plain strings concatenation instead of building an array and then joining it
* `timezone`: make sure the tests pass when the timezone database is not installed on the host
* `ui/css`: align `min-width` with the other `min-width` values
* `ui/css`: fix regression: "Add to Home Screen" button is unreadable
* `ui/js`: don't use lambdas to return a function, use directly the function instead
* `ui/js`: enable trusted-types
* `ui/js`: fix download button loading label
* `ui/js`: fix JavaScript error on the login page when the user not authenticated
* `ui/js`: inline one-line functions
* `ui/js`: inline some `querySelectorAll` calls
* `ui/js`: reduce the scope of some variables
* `ui/js`: remove a hack for "Chrome 67 and earlier" since it was released in 2018
* `ui/js`: replace `DomHelper.findParent` with `.closest`
* `ui/js`: replace `let` with `const`
* `ui/js`: simplify `DomHelper.getVisibleElements` by using a `filter` instead of a loop with an index
* `ui/js`: use a `Set` instead of an array in a `KeyboardHandler`'s member
* `ui/js`: use some ternaries where it makes sense
* `ui/static`: make use of `HashFromBytes` everywhere
* `ui/static`: set minifier ECMAScript version
* `ui`: add keyboard shortcuts for scrolling to top/bottom of the item list
* `ui`: add media player control playback speed
* `ui`: remove unused variables and improve JSON decoding in `saveEnclosureProgression()`
* `validator`: display an error message on edit feed page when the feed URL is not unique
* Bump `github.com/coreos/go-oidc/v3` from `3.9.0` to `3.10.0`
* Bump `github.com/go-webauthn/webauthn` from `0.10.1` to `0.10.2`
* Bump `github.com/tdewolff/minify/v2` from `2.20.18` to `2.20.19`
* Bump `google.golang.org/protobuf` from `1.32.0` to `1.33.0`
Version 2.1.1 (March 10, 2024) Version 2.1.1 (March 10, 2024)
----------------------------- -----------------------------

View File

@ -1,12 +1,12 @@
APP := miniflux APP := miniflux
DOCKER_IMAGE := miniflux/miniflux DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null) VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_DATE := `date +%FT%T%z` 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)'" 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/) PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
DOCKER_PLATFORM := amd64 DEB_IMG_ARCH := amd64
export PGPASSWORD := postgres export PGPASSWORD := postgres
@ -51,43 +51,33 @@ miniflux-no-pie:
linux-amd64: linux-amd64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-arm64: linux-arm64:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv7: linux-armv7:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv6: linux-armv6:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
linux-armv5: linux-armv5:
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-amd64: darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
darwin-arm64: darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
freebsd-amd64: freebsd-amd64:
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
openbsd-amd64: openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go @ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
windows-amd64: windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go @ 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 build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
@ -111,10 +101,10 @@ windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go @ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
run: run:
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go @ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
clean: clean:
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256 @ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
test: test:
go test -cover -race -count=1 ./... go test -cover -race -count=1 ./...
@ -138,11 +128,7 @@ integration-test:
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid" ./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! nc -z localhost 8080; do sleep 1; done while ! nc -z localhost 8080; do sleep 1; done
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
TEST_MINIFLUX_ADMIN_USERNAME=admin \
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
go test -v -count=1 ./internal/api
clean-integration-test: clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid` @ kill -9 `cat /tmp/miniflux.pid`
@ -173,15 +159,15 @@ rpm: clean
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
debian: debian:
@ docker buildx build --load \ @ docker build --load \
--platform linux/$(DOCKER_PLATFORM) \ --build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
-t miniflux-deb-builder \ -t $(DEB_IMG_ARCH)/miniflux-deb-builder \
-f packaging/debian/Dockerfile \ -f packaging/debian/Dockerfile \
. .
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \ @ docker run --rm \
-v ${PWD}:/pkg miniflux-deb-builder -v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
debian-packages: clean debian-packages: clean
$(MAKE) debian DOCKER_PLATFORM=amd64 $(MAKE) debian DEB_IMG_ARCH=amd64
$(MAKE) debian DOCKER_PLATFORM=arm64 $(MAKE) debian DEB_IMG_ARCH=arm64v8
$(MAKE) debian DOCKER_PLATFORM=arm/v7 $(MAKE) debian DEB_IMG_ARCH=arm32v7

View File

@ -18,44 +18,16 @@ type Client struct {
} }
// New returns a new Miniflux client. // New returns a new Miniflux client.
// Deprecated: use NewClient instead.
func New(endpoint string, credentials ...string) *Client { func New(endpoint string, credentials ...string) *Client {
return NewClient(endpoint, credentials...) // Web gives "API Endpoint = https://miniflux.app/v1/", it doesn't work (/v1/v1/me)
}
// NewClient returns a new Miniflux client.
func NewClient(endpoint string, credentials ...string) *Client {
// Trim trailing slashes and /v1 from the endpoint.
endpoint = strings.TrimSuffix(endpoint, "/") endpoint = strings.TrimSuffix(endpoint, "/")
endpoint = strings.TrimSuffix(endpoint, "/v1") endpoint = strings.TrimSuffix(endpoint, "/v1")
switch len(credentials) { // trim to https://miniflux.app
case 2:
if len(credentials) == 2 {
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}} return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
case 1:
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
default:
return &Client{request: &request{endpoint: endpoint}}
} }
} return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
// Healthcheck checks if the application is up and running.
func (c *Client) Healthcheck() error {
body, err := c.request.Get("/healthcheck")
if err != nil {
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
}
defer body.Close()
responseBodyContent, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("miniflux: unable to read healthcheck response: %w", err)
}
if string(responseBodyContent) != "OK" {
return fmt.Errorf("miniflux: invalid healthcheck response: %q", responseBodyContent)
}
return nil
} }
// Version returns the version of the Miniflux instance. // Version returns the version of the Miniflux instance.
@ -556,25 +528,6 @@ func (c *Client) SaveEntry(entryID int64) error {
return err return err
} }
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
if err != nil {
return "", err
}
defer body.Close()
var response struct {
Content string `json:"content"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return "", fmt.Errorf("miniflux: response error (%v)", err)
}
return response.Content, nil
}
// FetchCounters fetches feed counters. // FetchCounters fetches feed counters.
func (c *Client) FetchCounters() (*FeedCounters, error) { func (c *Client) FetchCounters() (*FeedCounters, error) {
body, err := c.request.Get("/v1/feeds/counters") body, err := c.request.Get("/v1/feeds/counters")
@ -613,28 +566,6 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
return feedIcon, nil 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 { func buildFilterQueryString(path string, filter *Filter) string {
if filter != nil { if filter != nil {
values := url.Values{} values := url.Values{}
@ -707,10 +638,6 @@ func buildFilterQueryString(path string, filter *Filter) string {
values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10)) values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10))
} }
if filter.GloballyVisible {
values.Set("globally_visible", "true")
}
for _, status := range filter.Statuses { for _, status := range filter.Statuses {
values.Add("status", status) values.Add("status", status)
} }

View File

@ -12,7 +12,7 @@ This code snippet fetch the list of users:
miniflux "miniflux.app/v2/client" miniflux "miniflux.app/v2/client"
) )
client := miniflux.NewClient("https://api.example.org", "admin", "secret") client := miniflux.New("https://api.example.org", "admin", "secret")
users, err := client.Users() users, err := client.Users()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)

View File

@ -41,9 +41,6 @@ type User struct {
DefaultHomePage string `json:"default_home_page"` DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"` CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"` 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 { func (u User) String() string {
@ -61,31 +58,28 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user. // UserModificationRequest represents the request to update a user.
type UserModificationRequest struct { type UserModificationRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
Password *string `json:"password"` Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"` IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"` Theme *string `json:"theme"`
Language *string `json:"language"` Language *string `json:"language"`
Timezone *string `json:"timezone"` Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"` EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"` EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"` Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"` GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"` OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"` EntriesPerPage *int `json:"entries_per_page"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"` KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"` ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"` EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"` GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"` DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"` DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"` CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"` DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"` CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"` 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. // Users represents a list of users.
@ -242,17 +236,12 @@ type Entries []*Entry
// Enclosure represents an attachment. // Enclosure represents an attachment.
type Enclosure struct { type Enclosure struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
EntryID int64 `json:"entry_id"` EntryID int64 `json:"entry_id"`
URL string `json:"url"` URL string `json:"url"`
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
Size int `json:"size"` Size int `json:"size"`
MediaProgression int64 `json:"media_progression"`
}
type EnclosureUpdateRequest struct {
MediaProgression int64 `json:"media_progression"`
} }
// Enclosures represents a list of attachments. // Enclosures represents a list of attachments.
@ -283,7 +272,6 @@ type Filter struct {
CategoryID int64 CategoryID int64
FeedID int64 FeedID int64
Statuses []string Statuses []string
GloballyVisible bool
} }
// EntryResultSet represents the response when fetching entries. // EntryResultSet represents the response when fetching entries.
@ -302,7 +290,3 @@ type VersionResponse struct {
Arch string `json:"arch"` Arch string `json:"arch"`
OS string `json:"os"` OS string `json:"os"`
} }
func SetOptionalField[T any](value T) *T {
return &value
}

View File

@ -26,8 +26,6 @@ var (
ErrForbidden = errors.New("miniflux: access forbidden") ErrForbidden = errors.New("miniflux: access forbidden")
ErrServerError = errors.New("miniflux: internal server error") ErrServerError = errors.New("miniflux: internal server error")
ErrNotFound = errors.New("miniflux: resource not found") ErrNotFound = errors.New("miniflux: resource not found")
ErrBadRequest = errors.New("miniflux: bad request")
ErrEmptyEndpoint = errors.New("miniflux: empty endpoint provided")
) )
type errorResponse struct { type errorResponse struct {
@ -63,9 +61,6 @@ func (r *request) Delete(path string) error {
} }
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, 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:] == "/" { if r.endpoint[len(r.endpoint)-1:] == "/" {
r.endpoint = r.endpoint[:len(r.endpoint)-1] r.endpoint = r.endpoint[:len(r.endpoint)-1]
} }
@ -129,10 +124,10 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
var resp errorResponse var resp errorResponse
decoder := json.NewDecoder(response.Body) decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&resp); err != nil { if err := decoder.Decode(&resp); err != nil {
return nil, fmt.Errorf("%w (%v)", ErrBadRequest, err) return nil, fmt.Errorf("miniflux: bad request error (%v)", err)
} }
return nil, fmt.Errorf("%w (%s)", ErrBadRequest, resp.ErrorMessage) return nil, fmt.Errorf("miniflux: bad request (%s)", resp.ErrorMessage)
} }
if response.StatusCode > 400 { if response.StatusCode > 400 {

File diff suppressed because it is too large Load Diff

55
go.mod
View File

@ -1,49 +1,48 @@
module miniflux.app/v2 module miniflux.app/v2
// +heroku goVersion go1.23 // +heroku goVersion go1.22
require ( require (
github.com/PuerkitoBio/goquery v1.10.0 github.com/PuerkitoBio/goquery v1.9.1
github.com/abadojack/whatlanggo v1.0.1 github.com/abadojack/whatlanggo v1.0.1
github.com/andybalholm/brotli v1.1.0 github.com/coreos/go-oidc/v3 v3.9.0
github.com/coreos/go-oidc/v3 v3.11.0 github.com/go-webauthn/webauthn v0.10.1
github.com/go-webauthn/webauthn v0.11.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_golang v1.19.0
github.com/tdewolff/minify/v2 v2.20.37 github.com/tdewolff/minify/v2 v2.20.18
github.com/yuin/goldmark v1.7.4 github.com/yuin/goldmark v1.7.0
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.21.0
golang.org/x/net v0.29.0 golang.org/x/net v0.22.0
golang.org/x/oauth2 v0.23.0 golang.org/x/oauth2 v0.18.0
golang.org/x/term v0.24.0 golang.org/x/term v0.18.0
golang.org/x/text v0.18.0
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.5.0
) )
require ( require (
github.com/go-webauthn/x v0.1.14 // indirect github.com/go-webauthn/x v0.1.8 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
github.com/google/go-tpm v0.9.1 // indirect github.com/google/go-tpm v0.9.0 // indirect
) )
require ( require (
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/fxamacker/cbor/v2 v2.5.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // 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/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/tdewolff/parse/v2 v2.7.12 // indirect
github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.18.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.32.0 // indirect
) )
go 1.23 go 1.22

127
go.sum
View File

@ -1,75 +1,77 @@
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= 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 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.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.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI=
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc=
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= github.com/go-webauthn/x v0.1.8/go.mod h1:i8UNlGVt3oy6oAFcP4SZB1djZLx/4pbekCbWowjTaJg=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tdewolff/minify/v2 v2.20.37 h1:Q97cx4STXCh1dlWDlNHZniE8BJ2EBL0+2b0n92BJQhw= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tdewolff/minify/v2 v2.20.37/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw= github.com/tdewolff/minify/v2 v2.20.18 h1:y+s6OzlZwFqApgNXWNtaMuEMEPbHT72zrCyb9Az35Xo=
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= github.com/tdewolff/minify/v2 v2.20.18/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/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 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 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.0/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-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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= 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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -77,10 +79,11 @@ 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.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.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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -91,28 +94,40 @@ 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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.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.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.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 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.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=

View File

@ -72,8 +72,6 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet) sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete) sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet) 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) sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +0,0 @@
// 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)
}

View File

@ -8,16 +8,19 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/processor" "miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime" "miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/validator" "miniflux.app/v2/internal/validator"
) )
@ -33,9 +36,19 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
return return
} }
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content) entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
entry.Enclosures.ProxifyEnclosureURL(h.router) for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
}
}
json.OK(w, r, entry) json.OK(w, r, entry)
} }
@ -136,15 +149,6 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
builder.WithLimit(limit) builder.WithLimit(limit)
builder.WithTags(tags) builder.WithTags(tags)
builder.WithEnclosures() builder.WithEnclosures()
if request.HasQueryParam(r, "globally_visible") {
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
if globallyVisible {
builder.WithGloballyVisible()
}
}
configureFilters(builder, r) configureFilters(builder, r)
entries, err := builder.GetEntries() entries, err := builder.GetEntries()
@ -160,7 +164,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
} }
for i := range entries { for i := range entries {
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content) entries[i].Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entries[i].Content)
} }
json.OK(w, r, &entriesResponse{Total: count, Entries: entries}) json.OK(w, r, &entriesResponse{Total: count, Entries: entries})

View File

@ -115,7 +115,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
return return
} }
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil { if validationErr := validator.ValidateFeedModification(h.store, userID, &feedModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error()) json.BadRequest(w, r, validationErr.Error())
return return
} }

View File

@ -7,8 +7,6 @@ import (
json_parser "encoding/json" json_parser "encoding/json"
"errors" "errors"
"net/http" "net/http"
"regexp"
"strings"
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
@ -84,18 +82,6 @@ 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 { if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
json.BadRequest(w, r, validationErr.Error()) json.BadRequest(w, r, validationErr.Error())
return return

View File

@ -16,7 +16,7 @@ func askCredentials() (string, string) {
fd := int(os.Stdin.Fd()) fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) { if !term.IsTerminal(fd) {
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting")) printErrorAndExit(fmt.Errorf("this is not a terminal, exiting"))
} }
fmt.Print("Enter Username: ") fmt.Print("Enter Username: ")

View File

@ -4,7 +4,6 @@
package cli // import "miniflux.app/v2/internal/cli" package cli // import "miniflux.app/v2/internal/cli"
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -24,7 +23,7 @@ const (
flagVersionHelp = "Show application version" flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations" flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)" flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create an admin user from an interactive terminal" flagCreateAdminHelp = "Create admin user"
flagResetPasswordHelp = "Reset user password" flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users" flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs" flagDebugModeHelp = "Show debug logs"
@ -89,23 +88,6 @@ func Parse() {
printErrorAndExit(err) 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 { if flagConfigDump {
fmt.Print(config.Opts) fmt.Print(config.Opts)
return return
@ -209,7 +191,7 @@ func Parse() {
} }
if flagCreateAdmin { if flagCreateAdmin {
createAdminUserFromInteractiveTerminal(store) createAdmin(store)
return return
} }
@ -229,8 +211,9 @@ func Parse() {
printErrorAndExit(err) printErrorAndExit(err)
} }
// Create admin user and start the daemon.
if config.Opts.CreateAdmin() { if config.Opts.CreateAdmin() {
createAdminUserFromEnvironmentVariables(store) createAdmin(store)
} }
if flagRefreshFeeds { if flagRefreshFeeds {

View File

@ -12,22 +12,17 @@ import (
"miniflux.app/v2/internal/validator" "miniflux.app/v2/internal/validator"
) )
func createAdminUserFromEnvironmentVariables(store *storage.Storage) { func createAdmin(store *storage.Storage) {
createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())
}
func createAdminUserFromInteractiveTerminal(store *storage.Storage) {
username, password := askCredentials()
createAdminUser(store, username, password)
}
func createAdminUser(store *storage.Storage, username, password string) {
userCreationRequest := &model.UserCreationRequest{ userCreationRequest := &model.UserCreationRequest{
Username: username, Username: config.Opts.AdminUsername(),
Password: password, Password: config.Opts.AdminPassword(),
IsAdmin: true, IsAdmin: true,
} }
if userCreationRequest.Username == "" || userCreationRequest.Password == "" {
userCreationRequest.Username, userCreationRequest.Password = askCredentials()
}
if store.UserExists(userCreationRequest.Username) { if store.UserExists(userCreationRequest.Username) {
slog.Info("Skipping admin user creation because it already exists", slog.Info("Skipping admin user creation because it already exists",
slog.String("username", userCreationRequest.Username), slog.String("username", userCreationRequest.Username),
@ -39,12 +34,7 @@ func createAdminUser(store *storage.Storage, username, password string) {
printErrorAndExit(validationErr.Error()) printErrorAndExit(validationErr.Error())
} }
if user, err := store.CreateUser(userCreationRequest); err != nil { if _, err := store.CreateUser(userCreationRequest); err != nil {
printErrorAndExit(err) printErrorAndExit(err)
} else {
slog.Info("Created new admin user",
slog.String("username", user.Username),
slog.Int64("user_id", user.ID),
)
} }
} }

View File

@ -4,7 +4,6 @@
package config // import "miniflux.app/v2/internal/config" package config // import "miniflux.app/v2/internal/config"
import ( import (
"bytes"
"os" "os"
"testing" "testing"
) )
@ -259,29 +258,6 @@ 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) { func TestBaseURLWithoutScheme(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("BASE_URL", "example.org/folder/") os.Setenv("BASE_URL", "example.org/folder/")
@ -1466,9 +1442,9 @@ func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
} }
} }
func TestMediaProxyMode(t *testing.T) { func TestProxyOption(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all") os.Setenv("PROXY_OPTION", "all")
parser := NewParser() parser := NewParser()
opts, err := parser.ParseEnvironmentVariables() opts, err := parser.ParseEnvironmentVariables()
@ -1477,14 +1453,14 @@ func TestMediaProxyMode(t *testing.T) {
} }
expected := "all" expected := "all"
result := opts.MediaProxyMode() result := opts.ProxyOption()
if result != expected { if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected) t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
} }
} }
func TestDefaultMediaProxyModeValue(t *testing.T) { func TestDefaultProxyOptionValue(t *testing.T) {
os.Clearenv() os.Clearenv()
parser := NewParser() parser := NewParser()
@ -1493,17 +1469,17 @@ func TestDefaultMediaProxyModeValue(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := defaultMediaProxyMode expected := defaultProxyOption
result := opts.MediaProxyMode() result := opts.ProxyOption()
if result != expected { if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected) t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
} }
} }
func TestMediaProxyResourceTypes(t *testing.T) { func TestProxyMediaTypes(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio") os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
parser := NewParser() parser := NewParser()
opts, err := parser.ParseEnvironmentVariables() opts, err := parser.ParseEnvironmentVariables()
@ -1513,25 +1489,25 @@ func TestMediaProxyResourceTypes(t *testing.T) {
expected := []string{"audio", "image"} expected := []string{"audio", "image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) { if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
resultMap := make(map[string]bool) resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() { for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true resultMap[mediaType] = true
} }
for _, mediaType := range expected { for _, mediaType := range expected {
if !resultMap[mediaType] { if !resultMap[mediaType] {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
} }
} }
func TestMediaProxyResourceTypesWithDuplicatedValues(t *testing.T) { func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio, image") os.Setenv("PROXY_MEDIA_TYPES", "image,audio, image")
parser := NewParser() parser := NewParser()
opts, err := parser.ParseEnvironmentVariables() opts, err := parser.ParseEnvironmentVariables()
@ -1540,119 +1516,23 @@ func TestMediaProxyResourceTypesWithDuplicatedValues(t *testing.T) {
} }
expected := []string{"audio", "image"} expected := []string{"audio", "image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) { if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
resultMap := make(map[string]bool) resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() { for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true resultMap[mediaType] = true
} }
for _, mediaType := range expected { for _, mediaType := range expected {
if !resultMap[mediaType] { if !resultMap[mediaType] {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
} }
} }
func TestDefaultMediaProxyResourceTypes(t *testing.T) { func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() {
resultMap[mediaType] = true
}
for _, mediaType := range expected {
if !resultMap[mediaType] {
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
}
}
}
func TestMediaProxyHTTPClientTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_HTTP_CLIENT_TIMEOUT", "24")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := 24
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestDefaultMediaProxyHTTPClientTimeoutValue(t *testing.T) {
os.Clearenv()
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := defaultMediaProxyHTTPClientTimeout
result := opts.MediaProxyHTTPClientTimeout()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
}
}
func TestMediaProxyCustomURL(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
}
}
func TestMediaProxyPrivateKey(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "foobar")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey()
if !bytes.Equal(result, expected) {
t.Fatalf(`Unexpected MEDIA_PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
}
}
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("PROXY_IMAGES", "all") os.Setenv("PROXY_IMAGES", "all")
@ -1663,31 +1543,30 @@ func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
} }
expected := []string{"image"} expected := []string{"image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) { if len(expected) != len(opts.ProxyMediaTypes()) {
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
resultMap := make(map[string]bool) resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() { for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true resultMap[mediaType] = true
} }
for _, mediaType := range expected { for _, mediaType := range expected {
if !resultMap[mediaType] { if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
} }
expectedProxyOption := "all" expectedProxyOption := "all"
result := opts.MediaProxyMode() result := opts.ProxyOption()
if result != expectedProxyOption { if result != expectedProxyOption {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption) t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
} }
} }
func TestProxyImageURLForBackwardCompatibility(t *testing.T) { func TestDefaultProxyMediaTypes(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("PROXY_IMAGE_URL", "http://example.org/proxy")
parser := NewParser() parser := NewParser()
opts, err := parser.ParseEnvironmentVariables() opts, err := parser.ParseEnvironmentVariables()
@ -1695,73 +1574,25 @@ func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := "http://example.org/proxy" expected := []string{"image"}
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_IMAGE_URL value, got %q instead of %q`, result, expected)
}
}
func TestProxyURLOptionForBackwardCompatibility(t *testing.T) { if len(expected) != len(opts.ProxyMediaTypes()) {
os.Clearenv() t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
os.Setenv("PROXY_URL", "http://example.org/proxy")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "http://example.org/proxy"
result := opts.MediaCustomProxyURL()
if result != expected {
t.Fatalf(`Unexpected PROXY_URL value, got %q instead of %q`, result, expected)
}
}
func TestProxyMediaTypesOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := []string{"audio", "image"}
if len(expected) != len(opts.MediaProxyResourceTypes()) {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
} }
resultMap := make(map[string]bool) resultMap := make(map[string]bool)
for _, mediaType := range opts.MediaProxyResourceTypes() { for _, mediaType := range opts.ProxyMediaTypes() {
resultMap[mediaType] = true resultMap[mediaType] = true
} }
for _, mediaType := range expected { for _, mediaType := range expected {
if !resultMap[mediaType] { if !resultMap[mediaType] {
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected) t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
} }
} }
} }
func TestProxyOptionForBackwardCompatibility(t *testing.T) { func TestProxyHTTPClientTimeout(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := "all"
result := opts.MediaProxyMode()
if result != expected {
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
}
}
func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24") os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
@ -1770,26 +1601,29 @@ func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := 24 expected := 24
result := opts.MediaProxyHTTPClientTimeout() result := opts.ProxyHTTPClientTimeout()
if result != expected { if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected) t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
} }
} }
func TestProxyPrivateKeyOptionForBackwardCompatibility(t *testing.T) { func TestDefaultProxyHTTPClientTimeoutValue(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("PROXY_PRIVATE_KEY", "foobar")
parser := NewParser() parser := NewParser()
opts, err := parser.ParseEnvironmentVariables() opts, err := parser.ParseEnvironmentVariables()
if err != nil { if err != nil {
t.Fatalf(`Parsing failure: %v`, err) t.Fatalf(`Parsing failure: %v`, err)
} }
expected := []byte("foobar")
result := opts.MediaProxyPrivateKey() expected := defaultProxyHTTPClientTimeout
if !bytes.Equal(result, expected) { result := opts.ProxyHTTPClientTimeout()
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
if result != expected {
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
} }
} }
@ -2044,42 +1878,6 @@ 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) { func TestFetchOdyseeWatchTime(t *testing.T) {
os.Clearenv() os.Clearenv()
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1") os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")

View File

@ -4,12 +4,12 @@
package config // import "miniflux.app/v2/internal/config" package config // import "miniflux.app/v2/internal/config"
import ( import (
"crypto/rand"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"time" "time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/version" "miniflux.app/v2/internal/version"
) )
@ -27,7 +27,7 @@ const (
defaultBaseURL = "http://localhost" defaultBaseURL = "http://localhost"
defaultRootURL = "http://localhost" defaultRootURL = "http://localhost"
defaultBasePath = "" defaultBasePath = ""
defaultWorkerPoolSize = 16 defaultWorkerPoolSize = 5
defaultPollingFrequency = 60 defaultPollingFrequency = 60
defaultForceRefreshInterval = 30 defaultForceRefreshInterval = 30
defaultBatchSize = 100 defaultBatchSize = 100
@ -51,13 +51,10 @@ const (
defaultCleanupArchiveUnreadDays = 180 defaultCleanupArchiveUnreadDays = 180
defaultCleanupArchiveBatchSize = 10000 defaultCleanupArchiveBatchSize = 10000
defaultCleanupRemoveSessionsDays = 30 defaultCleanupRemoveSessionsDays = 30
defaultMediaProxyHTTPClientTimeout = 120 defaultProxyHTTPClientTimeout = 120
defaultMediaProxyMode = "http-only" defaultProxyOption = "http-only"
defaultMediaResourceTypes = "image" defaultProxyMediaTypes = "image"
defaultMediaProxyURL = "" defaultProxyUrl = ""
defaultFilterEntryMaxAgeDays = 0
defaultFetchBilibiliWatchTime = false
defaultFetchNebulaWatchTime = false
defaultFetchOdyseeWatchTime = false defaultFetchOdyseeWatchTime = false
defaultFetchYouTubeWatchTime = false defaultFetchYouTubeWatchTime = false
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/" defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
@ -69,9 +66,7 @@ const (
defaultOAuth2ClientSecret = "" defaultOAuth2ClientSecret = ""
defaultOAuth2RedirectURL = "" defaultOAuth2RedirectURL = ""
defaultOAuth2OidcDiscoveryEndpoint = "" defaultOAuth2OidcDiscoveryEndpoint = ""
defaultOauth2OidcProviderName = "OpenID Connect"
defaultOAuth2Provider = "" defaultOAuth2Provider = ""
defaultDisableLocalAuth = false
defaultPocketConsumerKey = "" defaultPocketConsumerKey = ""
defaultHTTPClientTimeout = 20 defaultHTTPClientTimeout = 20
defaultHTTPClientMaxBodySize = 15 defaultHTTPClientMaxBodySize = 15
@ -140,24 +135,19 @@ type Options struct {
createAdmin bool createAdmin bool
adminUsername string adminUsername string
adminPassword string adminPassword string
mediaProxyHTTPClientTimeout int proxyHTTPClientTimeout int
mediaProxyMode string proxyOption string
mediaProxyResourceTypes []string proxyMediaTypes []string
mediaProxyCustomURL string proxyUrl string
fetchBilibiliWatchTime bool
fetchNebulaWatchTime bool
fetchOdyseeWatchTime bool fetchOdyseeWatchTime bool
fetchYouTubeWatchTime bool fetchYouTubeWatchTime bool
filterEntryMaxAgeDays int
youTubeEmbedUrlOverride string youTubeEmbedUrlOverride string
oauth2UserCreationAllowed bool oauth2UserCreationAllowed bool
oauth2ClientID string oauth2ClientID string
oauth2ClientSecret string oauth2ClientSecret string
oauth2RedirectURL string oauth2RedirectURL string
oidcDiscoveryEndpoint string oidcDiscoveryEndpoint string
oidcProviderName string
oauth2Provider string oauth2Provider string
disableLocalAuth bool
pocketConsumerKey string pocketConsumerKey string
httpClientTimeout int httpClientTimeout int
httpClientMaxBodySize int64 httpClientMaxBodySize int64
@ -175,12 +165,15 @@ type Options struct {
metricsPassword string metricsPassword string
watchdog bool watchdog bool
invidiousInstance string invidiousInstance string
mediaProxyPrivateKey []byte proxyPrivateKey []byte
webAuthn bool webAuthn bool
} }
// NewOptions returns Options with default values. // NewOptions returns Options with default values.
func NewOptions() *Options { func NewOptions() *Options {
randomKey := make([]byte, 16)
rand.Read(randomKey)
return &Options{ return &Options{
HTTPS: defaultHTTPS, HTTPS: defaultHTTPS,
logFile: defaultLogFile, logFile: defaultLogFile,
@ -219,13 +212,10 @@ func NewOptions() *Options {
pollingParsingErrorLimit: defaultPollingParsingErrorLimit, pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
workerPoolSize: defaultWorkerPoolSize, workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin, createAdmin: defaultCreateAdmin,
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout, proxyHTTPClientTimeout: defaultProxyHTTPClientTimeout,
mediaProxyMode: defaultMediaProxyMode, proxyOption: defaultProxyOption,
mediaProxyResourceTypes: []string{defaultMediaResourceTypes}, proxyMediaTypes: []string{defaultProxyMediaTypes},
mediaProxyCustomURL: defaultMediaProxyURL, proxyUrl: defaultProxyUrl,
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
fetchBilibiliWatchTime: defaultFetchBilibiliWatchTime,
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime, fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime, fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride, youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
@ -234,9 +224,7 @@ func NewOptions() *Options {
oauth2ClientSecret: defaultOAuth2ClientSecret, oauth2ClientSecret: defaultOAuth2ClientSecret,
oauth2RedirectURL: defaultOAuth2RedirectURL, oauth2RedirectURL: defaultOAuth2RedirectURL,
oidcDiscoveryEndpoint: defaultOAuth2OidcDiscoveryEndpoint, oidcDiscoveryEndpoint: defaultOAuth2OidcDiscoveryEndpoint,
oidcProviderName: defaultOauth2OidcProviderName,
oauth2Provider: defaultOAuth2Provider, oauth2Provider: defaultOAuth2Provider,
disableLocalAuth: defaultDisableLocalAuth,
pocketConsumerKey: defaultPocketConsumerKey, pocketConsumerKey: defaultPocketConsumerKey,
httpClientTimeout: defaultHTTPClientTimeout, httpClientTimeout: defaultHTTPClientTimeout,
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024, httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
@ -254,7 +242,7 @@ func NewOptions() *Options {
metricsPassword: defaultMetricsPassword, metricsPassword: defaultMetricsPassword,
watchdog: defaultWatchdog, watchdog: defaultWatchdog,
invidiousInstance: defaultInvidiousInstance, invidiousInstance: defaultInvidiousInstance,
mediaProxyPrivateKey: crypto.GenerateRandomBytes(16), proxyPrivateKey: randomKey,
webAuthn: defaultWebAuthn, webAuthn: defaultWebAuthn,
} }
} }
@ -457,21 +445,11 @@ func (o *Options) OIDCDiscoveryEndpoint() string {
return o.oidcDiscoveryEndpoint 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. // OAuth2Provider returns the name of the OAuth2 provider configured.
func (o *Options) OAuth2Provider() string { func (o *Options) OAuth2Provider() string {
return o.oauth2Provider 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. // HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (o *Options) HasHSTS() bool { func (o *Options) HasHSTS() bool {
return o.hsts return o.hsts
@ -508,47 +486,30 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
return o.youTubeEmbedUrlOverride 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 // FetchOdyseeWatchTime returns true if the Odysee video duration
// should be fetched and used as a reading time. // should be fetched and used as a reading time.
func (o *Options) FetchOdyseeWatchTime() bool { func (o *Options) FetchOdyseeWatchTime() bool {
return o.fetchOdyseeWatchTime return o.fetchOdyseeWatchTime
} }
// FetchBilibiliWatchTime returns true if the Bilibili video duration // ProxyOption returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
// should be fetched and used as a reading time. func (o *Options) ProxyOption() string {
func (o *Options) FetchBilibiliWatchTime() bool { return o.proxyOption
return o.fetchBilibiliWatchTime
} }
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy. // ProxyMediaTypes returns a slice of media types to proxy.
func (o *Options) MediaProxyMode() string { func (o *Options) ProxyMediaTypes() []string {
return o.mediaProxyMode return o.proxyMediaTypes
} }
// MediaProxyResourceTypes returns a slice of resource types to proxy. // ProxyUrl returns a string of a URL to use to proxy image requests
func (o *Options) MediaProxyResourceTypes() []string { func (o *Options) ProxyUrl() string {
return o.mediaProxyResourceTypes return o.proxyUrl
} }
// MediaCustomProxyURL returns the custom proxy URL for medias. // ProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) MediaCustomProxyURL() string { func (o *Options) ProxyHTTPClientTimeout() int {
return o.mediaProxyCustomURL return o.proxyHTTPClientTimeout
}
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
func (o *Options) MediaProxyHTTPClientTimeout() int {
return o.mediaProxyHTTPClientTimeout
}
// MediaProxyPrivateKey returns the private key used by the media proxy.
func (o *Options) MediaProxyPrivateKey() []byte {
return o.mediaProxyPrivateKey
} }
// HasHTTPService returns true if the HTTP service is enabled. // HasHTTPService returns true if the HTTP service is enabled.
@ -644,16 +605,16 @@ func (o *Options) InvidiousInstance() string {
return o.invidiousInstance return o.invidiousInstance
} }
// ProxyPrivateKey returns the private key used by the media proxy
func (o *Options) ProxyPrivateKey() []byte {
return o.proxyPrivateKey
}
// WebAuthn returns true if WebAuthn logins are supported // WebAuthn returns true if WebAuthn logins are supported
func (o *Options) WebAuthn() bool { func (o *Options) WebAuthn() bool {
return o.webAuthn return o.webAuthn
} }
// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
func (o *Options) FilterEntryMaxAgeDays() int {
return o.filterEntryMaxAgeDays
}
// SortedOptions returns options as a list of key value pairs, sorted by keys. // SortedOptions returns options as a list of key value pairs, sorted by keys.
func (o *Options) SortedOptions(redactSecret bool) []*Option { func (o *Options) SortedOptions(redactSecret bool) []*Option {
var keyValues = map[string]interface{}{ var keyValues = map[string]interface{}{
@ -679,11 +640,8 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"DISABLE_HSTS": !o.hsts, "DISABLE_HSTS": !o.hsts,
"DISABLE_HTTP_SERVICE": !o.httpService, "DISABLE_HTTP_SERVICE": !o.httpService,
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService, "DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime, "FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime, "FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
"FETCH_BILIBILI_WATCH_TIME": o.fetchBilibiliWatchTime,
"HTTPS": o.HTTPS, "HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize, "HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
"HTTP_CLIENT_PROXY": o.httpClientProxy, "HTTP_CLIENT_PROXY": o.httpClientProxy,
@ -708,21 +666,19 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"OAUTH2_CLIENT_ID": o.oauth2ClientID, "OAUTH2_CLIENT_ID": o.oauth2ClientID,
"OAUTH2_CLIENT_SECRET": redactSecretValue(o.oauth2ClientSecret, redactSecret), "OAUTH2_CLIENT_SECRET": redactSecretValue(o.oauth2ClientSecret, redactSecret),
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": o.oidcDiscoveryEndpoint, "OAUTH2_OIDC_DISCOVERY_ENDPOINT": o.oidcDiscoveryEndpoint,
"OAUTH2_OIDC_PROVIDER_NAME": o.oidcProviderName,
"OAUTH2_PROVIDER": o.oauth2Provider, "OAUTH2_PROVIDER": o.oauth2Provider,
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL, "OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed, "OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret), "POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
"POLLING_FREQUENCY": o.pollingFrequency, "POLLING_FREQUENCY": o.pollingFrequency,
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval, "FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit, "POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
"POLLING_SCHEDULER": o.pollingScheduler, "POLLING_SCHEDULER": o.pollingScheduler,
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout, "PROXY_HTTP_CLIENT_TIMEOUT": o.proxyHTTPClientTimeout,
"MEDIA_PROXY_RESOURCE_TYPES": o.mediaProxyResourceTypes, "PROXY_MEDIA_TYPES": o.proxyMediaTypes,
"MEDIA_PROXY_MODE": o.mediaProxyMode, "PROXY_OPTION": o.proxyOption,
"MEDIA_PROXY_PRIVATE_KEY": redactSecretValue(string(o.mediaProxyPrivateKey), redactSecret), "PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL, "PROXY_URL": o.proxyUrl,
"ROOT_URL": o.rootURL, "ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations, "RUN_MIGRATIONS": o.runMigrations,
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval, "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,

View File

@ -10,7 +10,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@ -88,7 +87,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.logFormat = parsedValue p.opts.logFormat = parsedValue
} }
case "DEBUG": case "DEBUG":
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
parsedValue := parseBool(value, defaultDebug) parsedValue := parseBool(value, defaultDebug)
if parsedValue { if parsedValue {
p.opts.logLevel = "debug" p.opts.logLevel = "debug"
@ -114,8 +112,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns) p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
case "DATABASE_CONNECTION_LIFETIME": case "DATABASE_CONNECTION_LIFETIME":
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime) p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
case "FILTER_ENTRY_MAX_AGE_DAYS":
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
case "RUN_MIGRATIONS": case "RUN_MIGRATIONS":
p.opts.runMigrations = parseBool(value, defaultRunMigrations) p.opts.runMigrations = parseBool(value, defaultRunMigrations)
case "DISABLE_HSTS": case "DISABLE_HSTS":
@ -162,41 +158,20 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval) p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
case "POLLING_PARSING_ERROR_LIMIT": case "POLLING_PARSING_ERROR_LIMIT":
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit) p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
// kept for compatibility purpose
case "PROXY_IMAGES": case "PROXY_IMAGES":
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead") p.opts.proxyOption = parseString(value, defaultProxyOption)
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_HTTP_CLIENT_TIMEOUT": case "PROXY_HTTP_CLIENT_TIMEOUT":
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead") p.opts.proxyHTTPClientTimeout = parseInt(value, defaultProxyHTTPClientTimeout)
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
case "PROXY_OPTION": case "PROXY_OPTION":
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead") p.opts.proxyOption = parseString(value, defaultProxyOption)
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "MEDIA_PROXY_MODE":
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
case "PROXY_MEDIA_TYPES": case "PROXY_MEDIA_TYPES":
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead") p.opts.proxyMediaTypes = parseStringList(value, []string{defaultProxyMediaTypes})
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes}) // kept for compatibility purpose
case "MEDIA_PROXY_RESOURCE_TYPES":
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
case "PROXY_IMAGE_URL": case "PROXY_IMAGE_URL":
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead") p.opts.proxyUrl = parseString(value, defaultProxyUrl)
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_URL": case "PROXY_URL":
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead") p.opts.proxyUrl = parseString(value, defaultProxyUrl)
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "PROXY_PRIVATE_KEY":
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
case "MEDIA_PROXY_CUSTOM_URL":
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
case "CREATE_ADMIN": case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin) p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "ADMIN_USERNAME": case "ADMIN_USERNAME":
@ -225,12 +200,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL) p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT": case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint) p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
case "OAUTH2_OIDC_PROVIDER_NAME":
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
case "OAUTH2_PROVIDER": case "OAUTH2_PROVIDER":
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider) p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
case "DISABLE_LOCAL_AUTH":
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
case "HTTP_CLIENT_TIMEOUT": case "HTTP_CLIENT_TIMEOUT":
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout) p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
case "HTTP_CLIENT_MAX_BODY_SIZE": case "HTTP_CLIENT_MAX_BODY_SIZE":
@ -263,10 +234,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.metricsPassword = parseString(value, defaultMetricsPassword) p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
case "METRICS_PASSWORD_FILE": case "METRICS_PASSWORD_FILE":
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword) 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": case "FETCH_ODYSEE_WATCH_TIME":
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime) p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
case "FETCH_YOUTUBE_WATCH_TIME": case "FETCH_YOUTUBE_WATCH_TIME":
@ -277,6 +244,10 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.watchdog = parseBool(value, defaultWatchdog) p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE": case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance) p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
case "WEBAUTHN": case "WEBAUTHN":
p.opts.webAuthn = parseBool(value, defaultWebAuthn) p.opts.webAuthn = parseBool(value, defaultWebAuthn)
} }

View File

@ -17,7 +17,8 @@ import (
// HashFromBytes returns a SHA-256 checksum of the input. // HashFromBytes returns a SHA-256 checksum of the input.
func HashFromBytes(value []byte) string { func HashFromBytes(value []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(value)) sum := sha256.Sum256(value)
return fmt.Sprintf("%x", sum)
} }
// Hash returns a SHA-256 checksum of a string. // Hash returns a SHA-256 checksum of a string.

View File

@ -32,7 +32,7 @@ func Migrate(db *sql.DB) error {
var currentVersion int var currentVersion int
db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion) db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)
slog.Info("Running database migrations", slog.Debug("Running database migrations",
slog.Int("current_version", currentVersion), slog.Int("current_version", currentVersion),
slog.Int("latest_version", schemaVersion), slog.Int("latest_version", schemaVersion),
) )

View File

@ -871,75 +871,4 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql) _, err = tx.Exec(sql)
return err return err
}, },
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// the WHERE part speed-up the request a lot
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
// 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
},
} }

View File

@ -13,8 +13,8 @@ import (
"miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -247,6 +247,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50) builder.WithLimit(50)
builder.WithSorting("id", model.DefaultSortingDirection)
switch { switch {
case request.HasQueryParam(r, "since_id"): case request.HasQueryParam(r, "since_id"):
@ -257,7 +258,6 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
slog.Int64("since_id", sinceID), slog.Int64("since_id", sinceID),
) )
builder.AfterEntryID(sinceID) builder.AfterEntryID(sinceID)
builder.WithSorting("id", "ASC")
} }
case request.HasQueryParam(r, "max_id"): case request.HasQueryParam(r, "max_id"):
maxID := request.QueryInt64Param(r, "max_id", 0) maxID := request.QueryInt64Param(r, "max_id", 0)
@ -324,7 +324,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
FeedID: entry.FeedID, FeedID: entry.FeedID,
Title: entry.Title, Title: entry.Title,
Author: entry.Author, Author: entry.Author,
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content), HTML: proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content),
URL: entry.URL, URL: entry.URL,
IsSaved: isSaved, IsSaved: isSaved,
IsRead: isRead, IsRead: isRead,

View File

@ -18,12 +18,13 @@ import (
"miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/http/route" "miniflux.app/v2/internal/http/route"
"miniflux.app/v2/internal/integration" "miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/proxy"
"miniflux.app/v2/internal/reader/fetcher" "miniflux.app/v2/internal/reader/fetcher"
mff "miniflux.app/v2/internal/reader/handler" mff "miniflux.app/v2/internal/reader/handler"
mfs "miniflux.app/v2/internal/reader/subscription" mfs "miniflux.app/v2/internal/reader/subscription"
"miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/urllib"
"miniflux.app/v2/internal/validator" "miniflux.app/v2/internal/validator"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -264,10 +265,9 @@ func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
} }
func getStream(streamID string, userID int64) (Stream, error) { func getStream(streamID string, userID int64) (Stream, error) {
switch { if strings.HasPrefix(streamID, FeedPrefix) {
case strings.HasPrefix(streamID, FeedPrefix):
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix): } else if strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix) {
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
id = strings.TrimPrefix(id, StreamPrefix) id = strings.TrimPrefix(id, StreamPrefix)
switch id { switch id {
@ -288,15 +288,15 @@ func getStream(streamID string, userID int64) (Stream, error) {
default: default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id) return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
} }
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix): } else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
id = strings.TrimPrefix(id, LabelPrefix) id = strings.TrimPrefix(id, LabelPrefix)
return Stream{LabelStream, id}, nil return Stream{LabelStream, id}, nil
case streamID == "": } else if streamID == "" {
return Stream{NoStream, ""}, nil return Stream{NoStream, ""}, nil
default:
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
} }
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
} }
func getStreams(streamIDs []string, userID int64) ([]Stream, error) { func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
@ -382,7 +382,7 @@ func getItemIDs(r *http.Request) ([]int64, error) {
return itemIDs, nil return itemIDs, nil
} }
func checkOutputFormat(r *http.Request) error { func checkOutputFormat(w http.ResponseWriter, r *http.Request) error {
var output string var output string
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
err := r.ParseForm() err := r.ParseForm()
@ -736,12 +736,11 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
} }
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) { func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
switch { if category.ID == "" {
case category.ID == "":
return store.FirstCategory(userID) return store.FirstCategory(userID)
case store.CategoryTitleExists(userID, category.ID): } else if store.CategoryTitleExists(userID, category.ID) {
return store.CategoryByTitle(userID, category.ID) return store.CategoryByTitle(userID, category.ID)
default: } else {
catRequest := model.CategoryRequest{ catRequest := model.CategoryRequest{
Title: category.ID, Title: category.ID,
} }
@ -765,7 +764,7 @@ func subscribe(newFeed Stream, category Stream, title string, store *storage.Sto
} }
created, localizedError := mff.CreateFeed(store, userID, &feedRequest) created, localizedError := mff.CreateFeed(store, userID, &feedRequest)
if localizedError != nil { if err != nil {
return nil, localizedError.Error() return nil, localizedError.Error()
} }
@ -909,7 +908,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
slog.Int64("user_id", userID), slog.Int64("user_id", userID),
) )
if err := checkOutputFormat(r); err != nil { if err := checkOutputFormat(w, r); err != nil {
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
@ -1002,18 +1001,28 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
categories = append(categories, userStarred) categories = append(categories, userStarred)
} }
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content) entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
proxyOption := config.Opts.ProxyOption()
entry.Enclosures.ProxifyEnclosureURL(h.router) for i := range entry.Enclosures {
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
for _, mediaType := range config.Opts.ProxyMediaTypes() {
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
break
}
}
}
}
contentItems[i] = contentItem{ contentItems[i] = contentItem{
ID: fmt.Sprintf(EntryIDLong, entry.ID), ID: fmt.Sprintf(EntryIDLong, entry.ID),
Title: entry.Title, Title: entry.Title,
Author: entry.Author, Author: entry.Author,
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()), TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()), CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
Published: entry.Date.Unix(), Published: entry.Date.Unix(),
Updated: entry.ChangedAt.Unix(), Updated: entry.Date.Unix(),
Categories: categories, Categories: categories,
Canonical: []contentHREF{ Canonical: []contentHREF{
{ {
@ -1161,7 +1170,7 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()), slog.String("user_agent", r.UserAgent()),
) )
if err := checkOutputFormat(r); err != nil { if err := checkOutputFormat(w, r); err != nil {
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
@ -1196,7 +1205,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
slog.String("user_agent", r.UserAgent()), slog.String("user_agent", r.UserAgent()),
) )
if err := checkOutputFormat(r); err != nil { if err := checkOutputFormat(w, r); err != nil {
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
@ -1215,7 +1224,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
URL: feed.FeedURL, URL: feed.FeedURL,
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}}, Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
HTMLURL: feed.SiteURL, HTMLURL: feed.SiteURL,
IconURL: "", // TODO: Icons are base64 encoded in the DB. IconURL: "", //TODO Icons are only base64 encode in DB yet
}) })
} }
json.OK(w, r, result) json.OK(w, r, result)
@ -1242,7 +1251,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
slog.String("user_agent", r.UserAgent()), slog.String("user_agent", r.UserAgent()),
) )
if err := checkOutputFormat(r); err != nil { if err := checkOutputFormat(w, r); err != nil {
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
@ -1267,7 +1276,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
slog.Int64("user_id", userID), slog.Int64("user_id", userID),
) )
if err := checkOutputFormat(r); err != nil { if err := checkOutputFormat(w, r); err != nil {
json.BadRequest(w, r, err) json.BadRequest(w, r, err)
return return
} }
@ -1468,7 +1477,8 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
if len(rm.ExcludeTargets) > 0 { if len(rm.ExcludeTargets) > 0 {
for _, s := range rm.ExcludeTargets { for _, s := range rm.ExcludeTargets {
if s.Type == ReadStream { switch s.Type {
case ReadStream:
builder.WithoutStatus(model.EntryStatusRead) builder.WithoutStatus(model.EntryStatusRead)
} }
} }

View File

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

View File

@ -37,10 +37,14 @@ const (
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession { func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
if v := r.Context().Value(WebAuthnDataContextKey); v != nil { if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
if value, valid := v.(model.WebAuthnSession); valid { value, valid := v.(model.WebAuthnSession)
return &value if !valid {
return nil
} }
return &value
} }
return nil return nil
} }
@ -147,27 +151,39 @@ func ClientIP(r *http.Request) string {
func getContextStringValue(r *http.Request, key ContextKey) string { func getContextStringValue(r *http.Request, key ContextKey) string {
if v := r.Context().Value(key); v != nil { if v := r.Context().Value(key); v != nil {
if value, valid := v.(string); valid { value, valid := v.(string)
return value if !valid {
return ""
} }
return value
} }
return "" return ""
} }
func getContextBoolValue(r *http.Request, key ContextKey) bool { func getContextBoolValue(r *http.Request, key ContextKey) bool {
if v := r.Context().Value(key); v != nil { if v := r.Context().Value(key); v != nil {
if value, valid := v.(bool); valid { value, valid := v.(bool)
return value if !valid {
return false
} }
return value
} }
return false return false
} }
func getContextInt64Value(r *http.Request, key ContextKey) int64 { func getContextInt64Value(r *http.Request, key ContextKey) int64 {
if v := r.Context().Value(key); v != nil { if v := r.Context().Value(key); v != nil {
if value, valid := v.(int64); valid { value, valid := v.(int64)
return value if !valid {
return 0
} }
return value
} }
return 0 return 0
} }

View File

@ -12,8 +12,6 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/andybalholm/brotli"
) )
const compressionThreshold = 1024 const compressionThreshold = 1024
@ -98,6 +96,7 @@ func (b *Builder) Write() {
} }
func (b *Builder) writeHeaders() { func (b *Builder) writeHeaders() {
b.headers["X-XSS-Protection"] = "1; mode=block"
b.headers["X-Content-Type-Options"] = "nosniff" b.headers["X-Content-Type-Options"] = "nosniff"
b.headers["X-Frame-Options"] = "DENY" b.headers["X-Frame-Options"] = "DENY"
b.headers["Referrer-Policy"] = "no-referrer" b.headers["Referrer-Policy"] = "no-referrer"
@ -112,15 +111,8 @@ func (b *Builder) writeHeaders() {
func (b *Builder) compress(data []byte) { func (b *Builder) compress(data []byte) {
if b.enableCompression && len(data) > compressionThreshold { if b.enableCompression && len(data) > compressionThreshold {
acceptEncoding := b.r.Header.Get("Accept-Encoding") 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) switch {
defer brotliWriter.Close()
brotliWriter.Write(data)
return
case strings.Contains(acceptEncoding, "gzip"): case strings.Contains(acceptEncoding, "gzip"):
b.headers["Content-Encoding"] = "gzip" b.headers["Content-Encoding"] = "gzip"
b.writeHeaders() b.writeHeaders()

View File

@ -28,6 +28,7 @@ func TestResponseHasCommonHeaders(t *testing.T) {
resp := w.Result() resp := w.Result()
headers := map[string]string{ headers := map[string]string{
"X-XSS-Protection": "1; mode=block",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY", "X-Frame-Options": "DENY",
} }
@ -228,34 +229,10 @@ func TestBuildResponseWithCachingAndEtag(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")
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 := "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) { func TestBuildResponseWithGzipCompression(t *testing.T) {
body := strings.Repeat("a", compressionThreshold+1) body := strings.Repeat("a", compressionThreshold+1)
r, err := http.NewRequest("GET", "/", nil) r, err := http.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Encoding", "gzip, deflate") r.Header.Set("Accept-Encoding", "gzip, deflate, br")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -1,57 +0,0 @@
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
}

View File

@ -8,7 +8,6 @@ import (
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise" "miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/espial" "miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper" "miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkace" "miniflux.app/v2/internal/integration/linkace"
@ -16,12 +15,10 @@ import (
"miniflux.app/v2/internal/integration/linkwarden" "miniflux.app/v2/internal/integration/linkwarden"
"miniflux.app/v2/internal/integration/matrixbot" "miniflux.app/v2/internal/integration/matrixbot"
"miniflux.app/v2/internal/integration/notion" "miniflux.app/v2/internal/integration/notion"
"miniflux.app/v2/internal/integration/ntfy"
"miniflux.app/v2/internal/integration/nunuxkeeper" "miniflux.app/v2/internal/integration/nunuxkeeper"
"miniflux.app/v2/internal/integration/omnivore" "miniflux.app/v2/internal/integration/omnivore"
"miniflux.app/v2/internal/integration/pinboard" "miniflux.app/v2/internal/integration/pinboard"
"miniflux.app/v2/internal/integration/pocket" "miniflux.app/v2/internal/integration/pocket"
"miniflux.app/v2/internal/integration/raindrop"
"miniflux.app/v2/internal/integration/readeck" "miniflux.app/v2/internal/integration/readeck"
"miniflux.app/v2/internal/integration/readwise" "miniflux.app/v2/internal/integration/readwise"
"miniflux.app/v2/internal/integration/shaarli" "miniflux.app/v2/internal/integration/shaarli"
@ -34,30 +31,6 @@ import (
// SendEntry sends the entry to third-party providers when the user click on "Save". // SendEntry sends the entry to third-party providers when the user click on "Save".
func SendEntry(entry *model.Entry, userIntegrations *model.Integration) { 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 { if userIntegrations.PinboardEnabled {
slog.Debug("Sending entry to Pinboard", slog.Debug("Sending entry to Pinboard",
slog.Int64("user_id", userIntegrations.UserID), slog.Int64("user_id", userIntegrations.UserID),
@ -386,7 +359,6 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
) )
} }
} }
if userIntegrations.OmnivoreEnabled { if userIntegrations.OmnivoreEnabled {
slog.Debug("Sending entry to Omnivore", slog.Debug("Sending entry to Omnivore",
slog.Int64("user_id", userIntegrations.UserID), slog.Int64("user_id", userIntegrations.UserID),
@ -404,24 +376,6 @@ 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. // PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
@ -471,28 +425,6 @@ 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 // Integrations that only support sending individual entries
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled { if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
for _, entry := range entries { for _, entry := range entries {

View File

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

View File

@ -1,120 +0,0 @@
// 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"`
}

View File

@ -4,8 +4,6 @@
package pinboard // import "miniflux.app/v2/internal/integration/pinboard" package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
import ( import (
"encoding/xml"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -14,9 +12,6 @@ import (
"miniflux.app/v2/internal/version" "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 const defaultClientTimeout = 10 * time.Second
type Client struct { type Client struct {
@ -29,27 +24,20 @@ func NewClient(authToken string) *Client {
func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error { func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
if c.authToken == "" { if c.authToken == "" {
return errMissingCredentials return fmt.Errorf("pinboard: missing auth token")
} }
// We check if the url is already bookmarked to avoid overriding existing data. toRead := "no"
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 { if markAsUnread {
post.SetToread() toRead = "yes"
} }
values := url.Values{} values := url.Values{}
values.Add("auth_token", c.authToken) values.Add("auth_token", c.authToken)
post.AddValues(values) values.Add("url", entryURL)
values.Add("description", entryTitle)
values.Add("tags", pinboardTags)
values.Add("toread", toRead)
apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode() apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil) request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
@ -73,46 +61,3 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
return nil 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
}

View File

@ -1,62 +0,0 @@
// 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)
}

View File

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

View File

@ -91,7 +91,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
contentBodyHeader, err := json.Marshal(&partContentHeader{ contentBodyHeader, err := json.Marshal(&partContentHeader{
Url: entryURL, Url: entryURL,
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"}, ContentHeader: contentHeader{ContentType: "text/html"},
}) })
if err != nil { if err != nil {
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err) return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package rssbridge // import "miniflux.app/v2/internal/integration/rssbridge" package rssbridge // import "miniflux.app/integration/rssbridge"
import ( import (
"encoding/json" "encoding/json"

View File

@ -11,6 +11,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/urllib"
@ -73,15 +74,14 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
} }
func (c *Client) generateBearerToken() string { func (c *Client) generateBearerToken() string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`)) header := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(`{"typ":"JWT", "alg":"HS256"}`)), "=")
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix()))) payload := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat": %d}`, time.Now().Unix()))), "=")
data := header + "." + payload
mac := hmac.New(sha512.New, []byte(c.apiSecret)) mac := hmac.New(sha512.New, []byte(c.apiSecret))
mac.Write([]byte(data)) mac.Write([]byte(header + "." + payload))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) signature := strings.TrimRight(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=")
return data + "." + signature return header + "." + payload + "." + signature
} }
type addLinkRequest struct { type addLinkRequest struct {

View File

@ -11,8 +11,7 @@ import (
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error { func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
formattedText := fmt.Sprintf( formattedText := fmt.Sprintf(
`<b>%s</b> - <a href=%q>%s</a>`, `<a href=%q>%s</a>`,
feed.Title,
entry.URL, entry.URL,
entry.Title, entry.Title,
) )

View File

@ -57,7 +57,6 @@ func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error {
ID: entry.Feed.ID, ID: entry.Feed.ID,
UserID: entry.Feed.UserID, UserID: entry.Feed.UserID,
CategoryID: entry.Feed.Category.ID, CategoryID: entry.Feed.Category.ID,
Category: &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title},
FeedURL: entry.Feed.FeedURL, FeedURL: entry.Feed.FeedURL,
SiteURL: entry.Feed.SiteURL, SiteURL: entry.Feed.SiteURL,
Title: entry.Feed.Title, Title: entry.Feed.Title,
@ -95,13 +94,13 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
Tags: entry.Tags, Tags: entry.Tags,
}) })
} }
return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{ return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{
EventType: NewEntriesEventType, EventType: NewEntriesEventType,
Feed: &WebhookFeed{ Feed: &WebhookFeed{
ID: feed.ID, ID: feed.ID,
UserID: feed.UserID, UserID: feed.UserID,
CategoryID: feed.Category.ID, CategoryID: feed.Category.ID,
Category: &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title},
FeedURL: feed.FeedURL, FeedURL: feed.FeedURL,
SiteURL: feed.SiteURL, SiteURL: feed.SiteURL,
Title: feed.Title, Title: feed.Title,
@ -146,19 +145,13 @@ func (c *Client) makeRequest(eventType string, payload any) error {
} }
type WebhookFeed struct { type WebhookFeed struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
CategoryID int64 `json:"category_id"` CategoryID int64 `json:"category_id"`
Category *WebhookCategory `json:"category,omitempty"` FeedURL string `json:"feed_url"`
FeedURL string `json:"feed_url"` SiteURL string `json:"site_url"`
SiteURL string `json:"site_url"` Title string `json:"title"`
Title string `json:"title"` CheckedAt time.Time `json:"checked_at"`
CheckedAt time.Time `json:"checked_at"`
}
type WebhookCategory struct {
ID int64 `json:"id"`
Title string `json:"title"`
} }
type WebhookEntry struct { type WebhookEntry struct {

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Alle als gelesen markieren", "menu.mark_all_as_read": "Alle als gelesen markieren",
"menu.show_all_entries": "Zeige alle Artikel", "menu.show_all_entries": "Zeige alle Artikel",
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen", "menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
"menu.refresh_feed": "Aktualisieren", "menu.refresh_feed": "Aktualisieren",
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren", "menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
"menu.edit_feed": "Bearbeiten", "menu.edit_feed": "Bearbeiten",
@ -56,9 +55,7 @@
"search.label": "Suche", "search.label": "Suche",
"search.placeholder": "Suche...", "search.placeholder": "Suche...",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Nächste", "pagination.next": "Nächste",
"pagination.first": "First",
"pagination.previous": "Vorherige", "pagination.previous": "Vorherige",
"entry.status.unread": "Ungelesen", "entry.status.unread": "Ungelesen",
"entry.status.read": "Gelesen", "entry.status.read": "Gelesen",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen", "page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen", "page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen", "page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
"page.keyboard_shortcuts.go_to_bottom_item": "Gehen Sie zum untersten Element",
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
"page.keyboard_shortcuts.open_item": "Gewählten Artikel öffnen", "page.keyboard_shortcuts.open_item": "Gewählten Artikel öffnen",
"page.keyboard_shortcuts.open_original": "Original-Artikel öffnen", "page.keyboard_shortcuts.open_original": "Original-Artikel öffnen",
"page.keyboard_shortcuts.open_original_same_window": "Öffne den Original-Link in der aktuellen Registerkarte", "page.keyboard_shortcuts.open_original_same_window": "Öffne den Original-Link in der aktuellen Registerkarte",
@ -211,8 +206,8 @@
"page.settings.title": "Einstellungen", "page.settings.title": "Einstellungen",
"page.settings.link_google_account": "Google-Konto verknüpfen", "page.settings.link_google_account": "Google-Konto verknüpfen",
"page.settings.unlink_google_account": "Verknüpfung mit Google-Konto entfernen", "page.settings.unlink_google_account": "Verknüpfung mit Google-Konto entfernen",
"page.settings.link_oidc_account": "%s-Konto verknüpfen", "page.settings.link_oidc_account": "OpenID-Connect-Konto verknüpfen",
"page.settings.unlink_oidc_account": "Verknüpfung mit %s-Konto entfernen", "page.settings.unlink_oidc_account": "Verknüpfung mit OpenID-Connect-Konto entfernen",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Aktionen", "page.settings.webauthn.actions": "Aktionen",
"page.settings.webauthn.passkey_name": "Name des Passkeys", "page.settings.webauthn.passkey_name": "Name des Passkeys",
@ -226,7 +221,7 @@
], ],
"page.login.title": "Anmeldung", "page.login.title": "Anmeldung",
"page.login.google_signin": "Anmeldung mit Google", "page.login.google_signin": "Anmeldung mit Google",
"page.login.oidc_signin": "Anmeldung mit %s", "page.login.oidc_signin": "Anmeldung mit OpenID Connect",
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an", "page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich", "page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
"page.integrations.title": "Dienste", "page.integrations.title": "Dienste",
@ -261,7 +256,6 @@
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.", "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.", "alert.no_category": "Es ist keine Kategorie vorhanden.",
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.", "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_entry": "Es existiert kein Artikel für dieses Abonnement.",
"alert.no_feed": "Es sind keine Abonnements vorhanden.", "alert.no_feed": "Es sind keine Abonnements vorhanden.",
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.", "alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
@ -303,14 +297,6 @@
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.", "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_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_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.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_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
"error.feed_already_exists": "Dieser Feed existiert bereits.", "error.feed_already_exists": "Dieser Feed existiert bereits.",
@ -328,7 +314,6 @@
"form.feed.label.title": "Titel", "form.feed.label.title": "Titel",
"form.feed.label.site_url": "URL der Webseite", "form.feed.label.site_url": "URL der Webseite",
"form.feed.label.feed_url": "URL des Abonnements", "form.feed.label.feed_url": "URL des Abonnements",
"form.feed.label.description": "Beschreibung",
"form.feed.label.category": "Kategorie", "form.feed.label.category": "Kategorie",
"form.feed.label.crawler": "Originalinhalt herunterladen", "form.feed.label.crawler": "Originalinhalt herunterladen",
"form.feed.label.feed_username": "Benutzername des Abonnements", "form.feed.label.feed_username": "Benutzername des Abonnements",
@ -348,13 +333,6 @@
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren", "form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)", "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.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.general": "Allgemein",
"form.feed.fieldset.rules": "Regeln", "form.feed.fieldset.rules": "Regeln",
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen", "form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Standard-Startseite", "form.prefs.label.default_home_page": "Standard-Startseite",
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung", "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": "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.application_settings": "Anwendungseinstellungen",
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen", "form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen", "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.file": "OPML Datei",
"form.import.label.url": "URL", "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_activate": "Fever API aktivieren",
"form.integration.fever_username": "Fever Benutzername", "form.integration.fever_username": "Fever Benutzername",
"form.integration.fever_password": "Fever Passwort", "form.integration.fever_password": "Fever Passwort",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer", "form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
"form.integration.matrix_bot_url": "URL des Matrix-Servers", "form.integration.matrix_bot_url": "URL des Matrix-Servers",
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums", "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_activate": "Artikel in Readeck speichern",
"form.integration.readeck_endpoint": "Readeck API-Endpunkt", "form.integration.readeck_endpoint": "Readeck API-Endpunkt",
"form.integration.readeck_api_key": "Readeck API-Schlüssel", "form.integration.readeck_api_key": "Readeck API-Schlüssel",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Webhook Geheimnis", "form.integration.webhook_secret": "Webhook Geheimnis",
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.", "form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "API-Schlüsselbezeichnung",
"form.submit.loading": "Lade...", "form.submit.loading": "Lade...",
"form.submit.saving": "Speichern...", "form.submit.saving": "Speichern...",
@ -545,7 +505,7 @@
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v", "error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.", "error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?", "error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
"error.tls_error": "TLS-Fehler: %q. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.", "error.tls_error": "TLS-Fehler: %v. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.",
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v", "error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.", "error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
"error.http_client_error": "HTTP-Client-Fehler: %v.", "error.http_client_error": "HTTP-Client-Fehler: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.", "error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
"error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.", "error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.", "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.", "error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v."
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα", "menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων", "menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων", "menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
"menu.refresh_feed": "Ανανέωση", "menu.refresh_feed": "Ανανέωση",
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο", "menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
"menu.edit_feed": "Επεξεργασία", "menu.edit_feed": "Επεξεργασία",
@ -56,9 +55,7 @@
"search.label": "Αναζήτηση", "search.label": "Αναζήτηση",
"search.placeholder": "Αναζήτηση...", "search.placeholder": "Αναζήτηση...",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Επόμενη", "pagination.next": "Επόμενη",
"pagination.first": "First",
"pagination.previous": "Προηγούμενη", "pagination.previous": "Προηγούμενη",
"entry.status.unread": "Μη αναγνωσμένο", "entry.status.unread": "Μη αναγνωσμένο",
"entry.status.read": "Αναγνωσμένο", "entry.status.read": "Αναγνωσμένο",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή", "page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα", "page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα", "page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
"page.keyboard_shortcuts.go_to_bottom_item": "Μετάβαση στο κάτω στοιχείο",
"page.keyboard_shortcuts.go_to_top_item": "Μετάβαση στο επάνω στοιχείο",
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου", "page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου", "page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα", "page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
@ -211,8 +206,8 @@
"page.settings.title": "Ρυθμίσεις", "page.settings.title": "Ρυθμίσεις",
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google", "page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google", "page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου %s", "page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου OpenID Connect",
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου %s", "page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -226,7 +221,7 @@
], ],
"page.login.title": "Είσοδος", "page.login.title": "Είσοδος",
"page.login.google_signin": "Συνδεθείτε με τo Google", "page.login.google_signin": "Συνδεθείτε με τo Google",
"page.login.oidc_signin": "Συνδεθείτε με το %s", "page.login.oidc_signin": "Συνδεθείτε με το OpenID Connect",
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης", "page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης", "page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
"page.integrations.title": "Ενσωμάτωση", "page.integrations.title": "Ενσωμάτωση",
@ -261,7 +256,6 @@
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.", "alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
"alert.no_category": "Δεν υπάρχει κατηγορία.", "alert.no_category": "Δεν υπάρχει κατηγορία.",
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.", "alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.", "alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
"alert.no_feed": "Δεν έχετε συνδρομές.", "alert.no_feed": "Δεν έχετε συνδρομές.",
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.", "alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
@ -303,14 +297,6 @@
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.", "error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.", "error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.", "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.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.", "error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.", "error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
@ -330,7 +316,6 @@
"form.feed.label.title": "Τίτλος", "form.feed.label.title": "Τίτλος",
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου", "form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
"form.feed.label.feed_url": "Διεύθυνση URL ροής", "form.feed.label.feed_url": "Διεύθυνση URL ροής",
"form.feed.label.description": "Περιγραφή",
"form.feed.label.category": "Κατηγορία", "form.feed.label.category": "Κατηγορία",
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου", "form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
"form.feed.label.feed_username": "Όνομα Χρήστη ροής", "form.feed.label.feed_username": "Όνομα Χρήστη ροής",
@ -352,13 +337,6 @@
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services", "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.label.title": "Τίτλος",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων", "form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.user.label.username": "Χρήστης", "form.user.label.username": "Χρήστης",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα", "form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών", "form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή", "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "Αρχείο OPML",
"form.import.label.url": "URL", "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_activate": "Ενεργοποιήστε το Fever API",
"form.integration.fever_username": "Όνομα Χρήστη Fever", "form.integration.fever_username": "Όνομα Χρήστη Fever",
"form.integration.fever_password": "Κωδικός Πρόσβασης Fever", "form.integration.fever_password": "Κωδικός Πρόσβασης Fever",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix", "form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
"form.integration.matrix_bot_url": "URL διακομιστή Matrix", "form.integration.matrix_bot_url": "URL διακομιστή Matrix",
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας 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_activate": "Αποθήκευση άρθρων στο Readeck",
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API", "form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
"form.integration.readeck_api_key": "Κλειδί API Readeck", "form.integration.readeck_api_key": "Κλειδί API Readeck",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Ετικέτα κλειδιού API",
"form.submit.loading": "Φόρτωση...", "form.submit.loading": "Φόρτωση...",
"form.submit.saving": "Αποθήκευση...", "form.submit.saving": "Αποθήκευση...",
@ -545,7 +505,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -40,7 +40,6 @@
"menu.mark_page_as_read": "Mark this page as read", "menu.mark_page_as_read": "Mark this page as read",
"menu.mark_all_as_read": "Mark all as read", "menu.mark_all_as_read": "Mark all as read",
"menu.show_all_entries": "Show all entries", "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.show_only_unread_entries": "Show only unread entries",
"menu.refresh_feed": "Refresh", "menu.refresh_feed": "Refresh",
"menu.refresh_all_feeds": "Refresh all feeds in the background", "menu.refresh_all_feeds": "Refresh all feeds in the background",
@ -56,9 +55,7 @@
"search.label": "Search", "search.label": "Search",
"search.placeholder": "Search…", "search.placeholder": "Search…",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Next", "pagination.next": "Next",
"pagination.first": "First",
"pagination.previous": "Previous", "pagination.previous": "Previous",
"entry.status.unread": "Unread", "entry.status.unread": "Unread",
"entry.status.read": "Read", "entry.status.read": "Read",
@ -179,8 +176,6 @@
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item", "page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
"page.keyboard_shortcuts.go_to_next_item": "Go to next item", "page.keyboard_shortcuts.go_to_next_item": "Go to next item",
"page.keyboard_shortcuts.go_to_feed": "Go to feed", "page.keyboard_shortcuts.go_to_feed": "Go to feed",
"page.keyboard_shortcuts.go_to_top_item": "Go to top item",
"page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item",
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page", "page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
"page.keyboard_shortcuts.go_to_next_page": "Go to next page", "page.keyboard_shortcuts.go_to_next_page": "Go to next page",
"page.keyboard_shortcuts.open_item": "Open selected item", "page.keyboard_shortcuts.open_item": "Open selected item",
@ -211,8 +206,8 @@
"page.settings.title": "Settings", "page.settings.title": "Settings",
"page.settings.link_google_account": "Link my Google account", "page.settings.link_google_account": "Link my Google account",
"page.settings.unlink_google_account": "Unlink my Google account", "page.settings.unlink_google_account": "Unlink my Google account",
"page.settings.link_oidc_account": "Link my %s account", "page.settings.link_oidc_account": "Link my OpenID Connect account",
"page.settings.unlink_oidc_account": "Unlink my %s account", "page.settings.unlink_oidc_account": "Unlink my OpenID Connect account",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -220,13 +215,13 @@
"page.settings.webauthn.last_seen_on": "Last Used", "page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Register passkey", "page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey", "page.settings.webauthn.register.error": "Unable to register passkey",
"page.settings.webauthn.delete": [ "page.settings.webauthn.delete" : [
"Remove %d passkey", "Remove %d passkey",
"Remove %d passkeys" "Remove %d passkeys"
], ],
"page.login.title": "Sign In", "page.login.title": "Sign In",
"page.login.google_signin": "Sign in with Google", "page.login.google_signin": "Sign in with Google",
"page.login.oidc_signin": "Sign in with %s", "page.login.oidc_signin": "Sign in with OpenID Connect",
"page.login.webauthn_login": "Login with passkey", "page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey", "page.login.webauthn_login.error": "Unable to login with passkey",
"page.integrations.title": "Integrations", "page.integrations.title": "Integrations",
@ -261,7 +256,6 @@
"alert.no_bookmark": "There are no starred entries.", "alert.no_bookmark": "There are no starred entries.",
"alert.no_category": "There is no category.", "alert.no_category": "There is no category.",
"alert.no_category_entry": "There are no entries in this 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_entry": "There are no entries for this feed.",
"alert.no_feed": "You dont have any feeds.", "alert.no_feed": "You dont have any feeds.",
"alert.no_feed_in_category": "There is no feed for this category.", "alert.no_feed_in_category": "There is no feed for this category.",
@ -303,14 +297,6 @@
"error.password_min_length": "The password must have at least 6 characters.", "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_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_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.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_mandatory_fields": "The URL and the category are mandatory.",
"error.feed_already_exists": "This feed already exists.", "error.feed_already_exists": "This feed already exists.",
@ -328,7 +314,6 @@
"form.feed.label.title": "Title", "form.feed.label.title": "Title",
"form.feed.label.site_url": "Site URL", "form.feed.label.site_url": "Site URL",
"form.feed.label.feed_url": "Feed URL", "form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Description",
"form.feed.label.category": "Category", "form.feed.label.category": "Category",
"form.feed.label.crawler": "Fetch original content", "form.feed.label.crawler": "Fetch original content",
"form.feed.label.feed_username": "Feed Username", "form.feed.label.feed_username": "Feed Username",
@ -348,13 +333,6 @@
"form.feed.label.disabled": "Do not refresh this feed", "form.feed.label.disabled": "Do not refresh this feed",
"form.feed.label.no_media_player": "No media player (audio/video)", "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.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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Default home page", "form.prefs.label.default_home_page": "Default home page",
"form.prefs.label.categories_sorting_order": "Categories sorting", "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": "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "OPML file",
"form.import.label.url": "URL", "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_activate": "Activate Fever API",
"form.integration.fever_username": "Fever Username", "form.integration.fever_username": "Fever Username",
"form.integration.fever_password": "Fever Password", "form.integration.fever_password": "Fever Password",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Password for Matrix user", "form.integration.matrix_bot_password": "Password for Matrix user",
"form.integration.matrix_bot_url": "Matrix server URL", "form.integration.matrix_bot_url": "Matrix server URL",
"form.integration.matrix_bot_chat_id": "ID of Matrix Room", "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_activate": "Save entries to readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint", "form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Readeck API key", "form.integration.readeck_api_key": "Readeck API key",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading…", "form.submit.loading": "Loading…",
"form.submit.saving": "Saving…", "form.submit.saving": "Saving…",
@ -545,7 +505,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %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",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -1,5 +1,5 @@
{ {
"skip_to_content": "Saltar al contenido", "skip_to_content": "Skip to content",
"confirm.question": "¿Estás seguro?", "confirm.question": "¿Estás seguro?",
"confirm.question.refresh": "¿Quieres forzar la actualización?", "confirm.question.refresh": "¿Quieres forzar la actualización?",
"confirm.yes": "sí", "confirm.yes": "sí",
@ -9,8 +9,8 @@
"action.save": "Guardar", "action.save": "Guardar",
"action.or": "o", "action.or": "o",
"action.cancel": "Cancelar", "action.cancel": "Cancelar",
"action.remove": "Eliminar", "action.remove": "Quitar",
"action.remove_feed": "Eliminar esta fuente", "action.remove_feed": "Quitar esta fuente",
"action.update": "Actualizar", "action.update": "Actualizar",
"action.edit": "Editar", "action.edit": "Editar",
"action.download": "Descargar", "action.download": "Descargar",
@ -19,8 +19,8 @@
"action.home_screen": "Añadir a la pantalla principal", "action.home_screen": "Añadir a la pantalla principal",
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s", "tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
"tooltip.logged_user": "Registrado como %s", "tooltip.logged_user": "Registrado como %s",
"menu.title": "Menú", "menu.title": "Menu",
"menu.home_page": "Página de inicio", "menu.home_page": "Home page",
"menu.unread": "No leídos", "menu.unread": "No leídos",
"menu.starred": "Marcadores", "menu.starred": "Marcadores",
"menu.history": "Historial", "menu.history": "Historial",
@ -41,9 +41,8 @@
"menu.mark_all_as_read": "Marcar todos como leídos", "menu.mark_all_as_read": "Marcar todos como leídos",
"menu.show_all_entries": "Mostrar todos los artículos", "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_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_feed": "Refrescar",
"menu.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano", "menu.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
"menu.edit_feed": "Editar", "menu.edit_feed": "Editar",
"menu.edit_category": "Editar", "menu.edit_category": "Editar",
"menu.add_feed": "Agregar fuente", "menu.add_feed": "Agregar fuente",
@ -55,10 +54,8 @@
"menu.shared_entries": "Artículos compartidos", "menu.shared_entries": "Artículos compartidos",
"search.label": "Buscar", "search.label": "Buscar",
"search.placeholder": "Búsqueda...", "search.placeholder": "Búsqueda...",
"search.submit": "Buscar", "search.submit": "Search",
"pagination.last": "Último",
"pagination.next": "Siguiente", "pagination.next": "Siguiente",
"pagination.first": "Primero",
"pagination.previous": "Anterior", "pagination.previous": "Anterior",
"entry.status.unread": "No leído", "entry.status.unread": "No leído",
"entry.status.read": "Leído", "entry.status.read": "Leído",
@ -93,22 +90,22 @@
"entry.tags.label": "Etiquetas:", "entry.tags.label": "Etiquetas:",
"page.shared_entries.title": "Artículos compartidos", "page.shared_entries.title": "Artículos compartidos",
"page.shared_entries_count": [ "page.shared_entries_count": [
"%d artículo compartido", "%d shared entry",
"%d artículos compartidos" "%d shared entries"
], ],
"page.unread.title": "No leídos", "page.unread.title": "No leídos",
"page.unread_entry_count": [ "page.unread_entry_count": [
"%d artículo no leído", "%d unread entry",
"%d artículos no leídos" "%d unread entries"
], ],
"page.total_entry_count": [ "page.total_entry_count": [
"%d artículo en total", "%d entry in total",
"%d artículos en total" "%d entries in total"
], ],
"page.starred.title": "Marcadores", "page.starred.title": "Marcadores",
"page.starred_entry_count": [ "page.starred_entry_count": [
"%d artículo marcado", "%d starred entry",
"%d artículos marcados" "%d starred entries"
], ],
"page.categories.title": "Categorías", "page.categories.title": "Categorías",
"page.categories.no_feed": "Sin fuente.", "page.categories.no_feed": "Sin fuente.",
@ -119,17 +116,17 @@
"Hay %d fuentes." "Hay %d fuentes."
], ],
"page.categories_count": [ "page.categories_count": [
"%d categoría", "%d category",
"%d categorías" "%d categories"
], ],
"page.new_category.title": "Nueva categoría", "page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usuario", "page.new_user.title": "Nuevo usuario",
"page.edit_category.title": "Editar categoría: %s", "page.edit_category.title": "Editar categoría: %s",
"page.edit_user.title": "Editar usuario: %s", "page.edit_user.title": "Editar usuario: %s",
"page.feeds.title": "Fuentes", "page.feeds.title": "Fuentes",
"page.category_label": "Categoría: %s", "page.category_label": "Category: %s",
"page.feeds.last_check": "Última verificación:", "page.feeds.last_check": "Última verificación:",
"page.feeds.next_check": "Próxima verificación:", "page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Número de artículos leídos", "page.feeds.read_counter": "Número de artículos leídos",
"page.feeds.error_count": [ "page.feeds.error_count": [
"%d error", "%d error",
@ -137,15 +134,15 @@
], ],
"page.history.title": "Historial", "page.history.title": "Historial",
"page.read_entry_count": [ "page.read_entry_count": [
"%d artículo leído", "%d read entry",
"%d artículos leídos" "%d read entries"
], ],
"page.import.title": "Importar", "page.import.title": "Importar",
"page.search.title": "Resultados de la búsqueda", "page.search.title": "Resultados de la búsqueda",
"page.about.title": "Acerca de", "page.about.title": "Acerca de",
"page.about.credits": "Créditos", "page.about.credits": "Créditos",
"page.about.version": "Versión:", "page.about.version": "Versión:",
"page.about.build_date": "Fecha de compilación:", "page.about.build_date": "Fecha de construcción:",
"page.about.author": "Autor:", "page.about.author": "Autor:",
"page.about.license": "Licencia:", "page.about.license": "Licencia:",
"page.about.global_config_options": "Opciones de configuración global", "page.about.global_config_options": "Opciones de configuración global",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente", "page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior", "page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente", "page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
"page.keyboard_shortcuts.go_to_top_item": "Ir al elemento superior",
"page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado", "page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado",
"page.keyboard_shortcuts.open_original": "Abrir el enlace original", "page.keyboard_shortcuts.open_original": "Abrir el enlace original",
"page.keyboard_shortcuts.open_original_same_window": "Abrir enlace original en la pestaña actual", "page.keyboard_shortcuts.open_original_same_window": "Abrir enlace original en la pestaña actual",
@ -190,7 +185,7 @@
"page.keyboard_shortcuts.open_comments_same_window": "Abrir enlace de comentarios en la pestaña actual", "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_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.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 segundo plano", "page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída", "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.download_content": "Descargar el contento original",
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador", "page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
@ -211,24 +206,24 @@
"page.settings.title": "Ajustes", "page.settings.title": "Ajustes",
"page.settings.link_google_account": "Vincular mi cuenta de Google", "page.settings.link_google_account": "Vincular mi cuenta de Google",
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google", "page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
"page.settings.link_oidc_account": "Vincular mi cuenta de %s", "page.settings.link_oidc_account": "Vincular mi cuenta de OpenID Connect",
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s", "page.settings.unlink_oidc_account": "Desvincular mi cuenta de OpenID Connect",
"page.settings.webauthn.passkeys": "Claves de acceso", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Accioness", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso", "page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Añadido", "page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Usado por última vez", "page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registrar clave de acceso", "page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso", "page.settings.webauthn.register.error": "No se puede registrar la clave de paso",
"page.settings.webauthn.delete": [ "page.settings.webauthn.delete": [
"Eliminar %d clave de acceso", "Eliminar %d clave de paso",
"Eliminar %d claves de acceso" "Eliminar %d claves de paso"
], ],
"page.login.title": "Iniciar sesión", "page.login.title": "Iniciar sesión",
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google", "page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de %s", "page.login.oidc_signin": "Iniciar sesión con tu cuenta de OpenID Connect",
"page.login.webauthn_login": "Iniciar sesión con clave de acceso", "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 acceso", "page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso",
"page.integrations.title": "Integraciones", "page.integrations.title": "Integraciones",
"page.integration.miniflux_api": "API de Miniflux", "page.integration.miniflux_api": "API de Miniflux",
"page.integration.miniflux_api_endpoint": "Extremo de API", "page.integration.miniflux_api_endpoint": "Extremo de API",
@ -256,12 +251,11 @@
"page.offline.title": "Modo offline", "page.offline.title": "Modo offline",
"page.offline.message": "Estas desconectado", "page.offline.message": "Estas desconectado",
"page.offline.refresh_page": "Intenta actualizar la página", "page.offline.refresh_page": "Intenta actualizar la página",
"page.webauthn_rename.title": "Renombrar clave de acceso", "page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "No hay artículos compartidos.", "alert.no_shared_entry": "No hay artículos compartidos.",
"alert.no_bookmark": "No hay marcador en este momento.", "alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.", "alert.no_category": "No hay categoría.",
"alert.no_category_entry": "No hay artículos en esta 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_entry": "No hay artículos para esta fuente.",
"alert.no_feed": "No tienes fuentes.", "alert.no_feed": "No tienes fuentes.",
"alert.no_feed_in_category": "No hay fuentes para esta categoría.", "alert.no_feed_in_category": "No hay fuentes para esta categoría.",
@ -296,14 +290,6 @@
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.", "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_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_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.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_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
"error.feed_already_exists": "Este feed ya existe.", "error.feed_already_exists": "Este feed ya existe.",
@ -328,7 +314,6 @@
"form.feed.label.title": "Título", "form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio", "form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente", "form.feed.label.feed_url": "URL de la fuente",
"form.feed.label.description": "Descripción",
"form.feed.label.category": "Categoría", "form.feed.label.category": "Categoría",
"form.feed.label.crawler": "Obtener rastreador original", "form.feed.label.crawler": "Obtener rastreador original",
"form.feed.label.feed_username": "Nombre de usuario de la fuente", "form.feed.label.feed_username": "Nombre de usuario de la fuente",
@ -337,28 +322,21 @@
"form.feed.label.cookie": "Configurar las cookies", "form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.scraper_rules": "Reglas de extracción de información", "form.feed.label.scraper_rules": "Reglas de extracción de información",
"form.feed.label.rewrite_rules": "Reglas de reescribir", "form.feed.label.rewrite_rules": "Reglas de reescribir",
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise", "form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)", "form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)", "form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)", "form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP", "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.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales", "form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy", "form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
"form.feed.label.disabled": "No actualice este feed", "form.feed.label.disabled": "No actualice este feed",
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)", "form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos", "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.general": "General",
"form.feed.fieldset.rules": "Reglas", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Ajustes de red", "form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Servicios de terceros", "form.feed.fieldset.integration": "Third-Party Services",
"form.category.label.title": "Título", "form.category.label.title": "Título",
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos", "form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
"form.user.label.username": "Nombre de usuario", "form.user.label.username": "Nombre de usuario",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Página de inicio por defecto", "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.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.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
"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.fieldset.application_settings": "Application Settings",
"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.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.label.mark_read_manually": "Marcar entradas como leídas manualmente", "form.prefs.fieldset.reader_settings": "Reader Settings",
"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.file": "Archivo OPML",
"form.import.label.url": "URL", "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_activate": "Activar API de Fever",
"form.integration.fever_username": "Nombre de usuario de Fever", "form.integration.fever_username": "Nombre de usuario de Fever",
"form.integration.fever_password": "Contraseña de Fever", "form.integration.fever_password": "Contraseña de Fever",
@ -433,12 +404,12 @@
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag", "form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
"form.integration.wallabag_username": "Nombre de usuario de Wallabag", "form.integration.wallabag_username": "Nombre de usuario de Wallabag",
"form.integration.wallabag_password": "Contraseña de Wallabag", "form.integration.wallabag_password": "Contraseña de Wallabag",
"form.integration.notion_activate": "Guardar entradas en Notion", "form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "ID de página de Notion", "form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Token secreto de Notion", "form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Enviar artículos a Apprise", "form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "URL de la API de Apprise", "form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise", "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper", "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_endpoint": "Acceso API de Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper", "form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
@ -449,26 +420,26 @@
"form.integration.espial_endpoint": "Acceso API de Espial", "form.integration.espial_endpoint": "Acceso API de Espial",
"form.integration.espial_api_key": "Clave de API de Espial", "form.integration.espial_api_key": "Clave de API de Espial",
"form.integration.espial_tags": "Etiquetas de Espial", "form.integration.espial_tags": "Etiquetas de Espial",
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader", "form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader", "form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise", "form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram", "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_bot_token": "Token de bot",
"form.integration.telegram_chat_id": "ID de chat", "form.integration.telegram_chat_id": "ID de chat",
"form.integration.telegram_topic_id": "Topic ID", "form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web", "form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Deshabilitar notificación", "form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Deshabilitar botones", "form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Guardar artículos en LinkAce", "form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint", "form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "Clave API de LinkAce", "form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "Etiquetas de LinkAce", "form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Marcar enlace como privado", "form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace", "form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Enviar artículos a Linkding", "form.integration.linkding_activate": "Enviar artículos a Linkding",
"form.integration.linkding_endpoint": "Acceso API de Linkding", "form.integration.linkding_endpoint": "Acceso API de Linkding",
"form.integration.linkding_api_key": "Clave de API de Linkding", "form.integration.linkding_api_key": "Clave de API de Linkding",
"form.integration.linkding_tags": "Etiquetas de Linkding", "form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Marcar marcador como no leído", "form.integration.linkding_bookmark": "Marcar marcador como no leído",
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden", "form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden", "form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
@ -478,34 +449,23 @@
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix", "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_url": "URL del servidor de Matrix",
"form.integration.matrix_bot_chat_id": "ID de la sala 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_activate": "Enviar artículos a Readeck",
"form.integration.readeck_endpoint": "Acceso API de Readeck", "form.integration.readeck_endpoint": "Acceso API de Readeck",
"form.integration.readeck_api_key": "Clave de API de Readeck", "form.integration.readeck_api_key": "Clave de API de Readeck",
"form.integration.readeck_labels": "Etiquetas de Readeck", "form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)", "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_activate": "Guardar artículos a Shiori",
"form.integration.shiori_endpoint": "Extremo de API de Shiori", "form.integration.shiori_endpoint": "Extremo de API de Shiori",
"form.integration.shiori_username": "Nombre de usuario de Shiori", "form.integration.shiori_username": "Nombre de usuario de Shiori",
"form.integration.shiori_password": "Contraseña de Shiori", "form.integration.shiori_password": "Contraseña de Shiori",
"form.integration.shaarli_activate": "Guardar artículos en Shaarli", "form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "URL de Shaarli", "form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Secreto API de Shaarli", "form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Habilitar Webhook", "form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "URL de Webhook", "form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Secreto de Webhook", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge", "form.integration.rssbridge_url": "RSS-Bridge server URL",
"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.api_key.label.description": "Etiqueta de clave API",
"form.submit.loading": "Cargando...", "form.submit.loading": "Cargando...",
"form.submit.saving": "Guardando...", "form.submit.saving": "Guardando...",
@ -537,43 +497,32 @@
"hace %d años" "hace %d años"
], ],
"alert.too_many_feeds_refresh": [ "alert.too_many_feeds_refresh": [
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.", "You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo." "You have triggered too many feed refreshes. Please wait %d minutes before trying again."
], ],
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.", "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": "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_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": "Imposible leer el cuerpo HTTP: %v.", "error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.", "error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?", "error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.", "error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.", "error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v", "error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "Error cliente HTTP: %v.", "error.http_client_error": "HTTP client error: %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_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"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_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"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_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "No se encuentra el recurso solicitado. Por favor, verifique la URL.", "error.http_resource_not_found": "The requested resource is not found. Please, verify the 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_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": "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_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": "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_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": "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_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": "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.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": "Error en la base de datos: %v.", "error.database_error": "Database error: %v.",
"error.category_not_found": "Esta categoría no existe o no pertenece a este usuario.", "error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "Este feed ya existe.", "error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "No se puede analizar este feed: %v.", "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "Este feed no existe o no pertenece a este usuario.", "error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "No se puede detectar la fuente usando RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "No se puede detectar el formato del feed: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
"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"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Merkitse kaikki luetuksi", "menu.mark_all_as_read": "Merkitse kaikki luetuksi",
"menu.show_all_entries": "Näytä kaikki artikkelit", "menu.show_all_entries": "Näytä kaikki artikkelit",
"menu.show_only_unread_entries": "Näytä vain lukemattomat 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_feed": "Päivitä",
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla", "menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
"menu.edit_feed": "Muokkaa", "menu.edit_feed": "Muokkaa",
@ -56,9 +55,7 @@
"search.label": "Haku", "search.label": "Haku",
"search.placeholder": "Hae...", "search.placeholder": "Hae...",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Seuraava", "pagination.next": "Seuraava",
"pagination.first": "First",
"pagination.previous": "Edellinen", "pagination.previous": "Edellinen",
"entry.status.unread": "Lukematon", "entry.status.unread": "Lukematon",
"entry.status.read": "Luettu", "entry.status.read": "Luettu",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen", "page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle", "page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle", "page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
"page.keyboard_shortcuts.go_to_bottom_item": "Siirry alimpaan kohtaan",
"page.keyboard_shortcuts.go_to_top_item": "Siirry alkuun",
"page.keyboard_shortcuts.open_item": "Avaa valittu kohde", "page.keyboard_shortcuts.open_item": "Avaa valittu kohde",
"page.keyboard_shortcuts.open_original": "Avaa alkuperäinen linkki", "page.keyboard_shortcuts.open_original": "Avaa alkuperäinen linkki",
"page.keyboard_shortcuts.open_original_same_window": "Avaa alkuperäinen linkki nykyisessä välilehdessä", "page.keyboard_shortcuts.open_original_same_window": "Avaa alkuperäinen linkki nykyisessä välilehdessä",
@ -211,8 +206,8 @@
"page.settings.title": "Asetukset", "page.settings.title": "Asetukset",
"page.settings.link_google_account": "Linkitä Google-tilini", "page.settings.link_google_account": "Linkitä Google-tilini",
"page.settings.unlink_google_account": "Poista Google-tilini linkitys", "page.settings.unlink_google_account": "Poista Google-tilini linkitys",
"page.settings.link_oidc_account": "Linkitä %s -tilini", "page.settings.link_oidc_account": "Linkitä OpenID Connect -tilini",
"page.settings.unlink_oidc_account": "Poista %s -tilini linkitys", "page.settings.unlink_oidc_account": "Poista OpenID Connect -tilini linkitys",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -226,7 +221,7 @@
], ],
"page.login.title": "Kirjaudu sisään", "page.login.title": "Kirjaudu sisään",
"page.login.google_signin": "Kirjaudu sisään Googlella", "page.login.google_signin": "Kirjaudu sisään Googlella",
"page.login.oidc_signin": "Kirjaudu sisään %silla", "page.login.oidc_signin": "Kirjaudu sisään OpenID Connectilla",
"page.login.webauthn_login": "Kirjaudu sisään salasanalla", "page.login.webauthn_login": "Kirjaudu sisään salasanalla",
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla", "page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
"page.integrations.title": "Integraatiot", "page.integrations.title": "Integraatiot",
@ -261,7 +256,6 @@
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.", "alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
"alert.no_category": "Ei ole kategoriaa.", "alert.no_category": "Ei ole kategoriaa.",
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.", "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_entry": "Tässä syötteessä ei ole artikkeleita.",
"alert.no_feed": "Sinulla ei ole tilauksia.", "alert.no_feed": "Sinulla ei ole tilauksia.",
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.", "alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
@ -303,14 +297,6 @@
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.", "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_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_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.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.", "error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
"error.feed_already_exists": "Tämä syöte on jo olemassa.", "error.feed_already_exists": "Tämä syöte on jo olemassa.",
@ -330,7 +316,6 @@
"form.feed.label.title": "Otsikko", "form.feed.label.title": "Otsikko",
"form.feed.label.site_url": "Sivuston URL-osoite", "form.feed.label.site_url": "Sivuston URL-osoite",
"form.feed.label.feed_url": "Syötteen URL-osoite", "form.feed.label.feed_url": "Syötteen URL-osoite",
"form.feed.label.description": "Kuvaus",
"form.feed.label.category": "Kategoria", "form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Nouda alkuperäinen sisältö", "form.feed.label.crawler": "Nouda alkuperäinen sisältö",
"form.feed.label.feed_username": "Syötteen käyttäjätunnus", "form.feed.label.feed_username": "Syötteen käyttäjätunnus",
@ -348,13 +333,6 @@
"form.feed.label.disabled": "Älä päivitä tätä syötettä", "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.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa", "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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu", "form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu", "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": "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "OPML-tiedosto",
"form.import.label.url": "URL", "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_activate": "Ota Fever API käyttöön",
"form.integration.fever_username": "Fever-käyttäjätunnus", "form.integration.fever_username": "Fever-käyttäjätunnus",
"form.integration.fever_password": "Fever-salasana", "form.integration.fever_password": "Fever-salasana",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana", "form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite", "form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus", "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_activate": "Tallenna artikkelit Readeckiin",
"form.integration.readeck_endpoint": "Readeck API-päätepiste", "form.integration.readeck_endpoint": "Readeck API-päätepiste",
"form.integration.readeck_api_key": "Readeck API-avain", "form.integration.readeck_api_key": "Readeck API-avain",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "API Key Label",
"form.submit.loading": "Ladataan...", "form.submit.loading": "Ladataan...",
"form.submit.saving": "Tallennetaan...", "form.submit.saving": "Tallennetaan...",
@ -545,7 +505,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %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",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Tout marquer comme lu", "menu.mark_all_as_read": "Tout marquer comme lu",
"menu.show_all_entries": "Afficher tous les articles", "menu.show_all_entries": "Afficher tous les articles",
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus", "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_feed": "Actualiser",
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan", "menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
"menu.edit_feed": "Modifier", "menu.edit_feed": "Modifier",
@ -56,9 +55,7 @@
"search.label": "Recherche", "search.label": "Recherche",
"search.placeholder": "Recherche...", "search.placeholder": "Recherche...",
"search.submit": "Rechercher", "search.submit": "Rechercher",
"pagination.last": "Dernière page",
"pagination.next": "Suivant", "pagination.next": "Suivant",
"pagination.first": "Première page",
"pagination.previous": "Précédent", "pagination.previous": "Précédent",
"entry.status.unread": "Non lu", "entry.status.unread": "Non lu",
"entry.status.read": "Lu", "entry.status.read": "Lu",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement", "page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente", "page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
"page.keyboard_shortcuts.go_to_next_page": "Page suivante", "page.keyboard_shortcuts.go_to_next_page": "Page suivante",
"page.keyboard_shortcuts.go_to_bottom_item": "Aller à l'élément du bas",
"page.keyboard_shortcuts.go_to_top_item": "Aller à l'élément supérieur",
"page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné", "page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné",
"page.keyboard_shortcuts.open_original": "Ouvrir le lien original", "page.keyboard_shortcuts.open_original": "Ouvrir le lien original",
"page.keyboard_shortcuts.open_original_same_window": "Ouvrir le lien original dans l'onglet en cours", "page.keyboard_shortcuts.open_original_same_window": "Ouvrir le lien original dans l'onglet en cours",
@ -211,8 +206,8 @@
"page.settings.title": "Réglages", "page.settings.title": "Réglages",
"page.settings.link_google_account": "Associer mon compte Google", "page.settings.link_google_account": "Associer mon compte Google",
"page.settings.unlink_google_account": "Dissocier mon compte Google", "page.settings.unlink_google_account": "Dissocier mon compte Google",
"page.settings.link_oidc_account": "Associer mon compte %s", "page.settings.link_oidc_account": "Associer mon compte OpenID Connect",
"page.settings.unlink_oidc_account": "Dissocier mon compte %s", "page.settings.unlink_oidc_account": "Dissocier mon compte OpenID Connect",
"page.settings.webauthn.passkeys": "Clés daccès", "page.settings.webauthn.passkeys": "Clés daccès",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Nom de la clé daccès", "page.settings.webauthn.passkey_name": "Nom de la clé daccès",
@ -220,13 +215,13 @@
"page.settings.webauthn.last_seen_on": "Dernière utilisation", "page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.register": "Enregister une nouvelle clé daccès", "page.settings.webauthn.register": "Enregister une nouvelle clé daccès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès", "page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès",
"page.settings.webauthn.delete": [ "page.settings.webauthn.delete" : [
"Supprimer %d clé daccès", "Supprimer %d clé daccès",
"Supprimer %d clés daccès" "Supprimer %d clés daccès"
], ],
"page.login.title": "Connexion", "page.login.title": "Connexion",
"page.login.google_signin": "Se connecter avec Google", "page.login.google_signin": "Se connecter avec Google",
"page.login.oidc_signin": "Se connecter avec %s", "page.login.oidc_signin": "Se connecter avec OpenID Connect",
"page.login.webauthn_login": "Se connecter avec une clé daccès", "page.login.webauthn_login": "Se connecter avec une clé daccès",
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé daccès", "page.login.webauthn_login.error": "Impossible de se connecter avec la clé daccès",
"page.integrations.title": "Intégrations", "page.integrations.title": "Intégrations",
@ -261,7 +256,6 @@
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.", "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": "Il n'y a aucune catégorie.",
"alert.no_category_entry": "Il n'y a aucun article dans cette 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_entry": "Il n'y a aucun article pour cet abonnement.",
"alert.no_feed": "Vous n'avez aucun 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.", "alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
@ -296,14 +290,6 @@
"error.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.", "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_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_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.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_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
"error.feed_already_exists": "Ce flux existe déjà.", "error.feed_already_exists": "Ce flux existe déjà.",
@ -328,7 +314,6 @@
"form.feed.label.title": "Titre", "form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web", "form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux", "form.feed.label.feed_url": "URL du flux",
"form.feed.label.description": "Description",
"form.feed.label.category": "Catégorie", "form.feed.label.category": "Catégorie",
"form.feed.label.crawler": "Récupérer le contenu original", "form.feed.label.crawler": "Récupérer le contenu original",
"form.feed.label.feed_username": "Nom d'utilisateur du flux", "form.feed.label.feed_username": "Nom d'utilisateur du flux",
@ -348,13 +333,6 @@
"form.feed.label.disabled": "Ne pas actualiser ce flux", "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.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.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.general": "Général",
"form.feed.fieldset.rules": "Règles", "form.feed.fieldset.rules": "Règles",
"form.feed.fieldset.network_settings": "Paramètres réseau", "form.feed.fieldset.network_settings": "Paramètres réseau",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Page d'accueil par défaut", "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.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": "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.application_settings": "Paramètres de l'application",
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification", "form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur", "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.file": "Fichier OPML",
"form.import.label.url": "URL", "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_activate": "Activer l'API de Fever",
"form.integration.fever_username": "Nom d'utilisateur pour 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", "form.integration.fever_password": "Mot de passe pour l'API de Fever",
@ -449,7 +420,7 @@
"form.integration.espial_endpoint": "URL de l'API de Espial", "form.integration.espial_endpoint": "URL de l'API de Espial",
"form.integration.espial_api_key": "Clé d'API de Espial", "form.integration.espial_api_key": "Clé d'API de Espial",
"form.integration.espial_tags": "Libellés de Espial", "form.integration.espial_tags": "Libellés de Espial",
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader", "form.integration.readwise_activate": "Enregistrer les entrées dans Readwise Reader",
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise", "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.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
"form.integration.telegram_bot_activate": "Envoyer les nouveaux articles vers Telegram", "form.integration.telegram_bot_activate": "Envoyer les nouveaux articles vers Telegram",
@ -458,17 +429,17 @@
"form.integration.telegram_topic_id": "Identifiant du sujet (Topic ID)", "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_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_notification": "Désactiver les notifications",
"form.integration.telegram_bot_disable_buttons": "Désactiver les boutons", "form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce", "form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "Point de terminaison de l'API LinkAce", "form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "Clé d'API LinkAce", "form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "Étiquettes LinkAce", "form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Marquer le lien comme privé", "form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens", "form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Sauvegarder les articles vers Linkding", "form.integration.linkding_activate": "Sauvegarder les articles vers Linkding",
"form.integration.linkding_endpoint": "URL de l'API de Linkding", "form.integration.linkding_endpoint": "URL de l'API de Linkding",
"form.integration.linkding_api_key": "Clé d'API de Linkding", "form.integration.linkding_api_key": "Clé d'API de Linkding",
"form.integration.linkding_tags": "Libellés", "form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Marquer le lien comme non lu", "form.integration.linkding_bookmark": "Marquer le lien comme non lu",
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden", "form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
"form.integration.linkwarden_endpoint": "URL de l'API de Linkwarden", "form.integration.linkwarden_endpoint": "URL de l'API de Linkwarden",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix", "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_url": "URL du serveur Matrix",
"form.integration.matrix_bot_chat_id": "Identifiant de la salle 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_activate": "Sauvegarder les articles vers Readeck",
"form.integration.readeck_endpoint": "URL de l'API de Readeck", "form.integration.readeck_endpoint": "URL de l'API de Readeck",
"form.integration.readeck_api_key": "Clé d'API de Readeck", "form.integration.readeck_api_key": "Clé d'API de Readeck",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Secret du webhook", "form.integration.webhook_secret": "Secret du webhook",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Libellé de la clé d'API",
"form.submit.loading": "Chargement...", "form.submit.loading": "Chargement...",
"form.submit.saving": "Sauvegarde en cours...", "form.submit.saving": "Sauvegarde en cours...",
@ -545,7 +505,7 @@
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.", "error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.", "error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?", "error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
"error.tls_error": "Erreur TLS : %q. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.", "error.tls_error": "Erreur TLS : %v. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
"error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.", "error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.",
"error.network_timeout": "Ce site web est trop lent à répondre : %v.", "error.network_timeout": "Ce site web est trop lent à répondre : %v.",
"error.http_client_error": "Erreur du client HTTP : %v.", "error.http_client_error": "Erreur du client HTTP : %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.", "error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
"error.feed_not_found": "Impossible de trouver ce flux.", "error.feed_not_found": "Impossible de trouver ce flux.",
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.", "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.", "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",
"enclosure_media_controls.seek" : "Avancer/Reculer :",
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
"enclosure_media_controls.speed" : "Vitesse :",
"enclosure_media_controls.speed.faster" : "Accélérer",
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
"enclosure_media_controls.speed.slower" : "Ralentir",
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
"enclosure_media_controls.speed.reset" : "Réinitialiser",
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें", "menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए", "menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए", "menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "ताज़ा करें", "menu.refresh_feed": "ताज़ा करें",
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें", "menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
"menu.edit_feed": "फ़ीड संपाद करे", "menu.edit_feed": "फ़ीड संपाद करे",
@ -56,9 +55,7 @@
"search.label": "खोजे", "search.label": "खोजे",
"search.placeholder": "खोजे...", "search.placeholder": "खोजे...",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "अगला", "pagination.next": "अगला",
"pagination.first": "First",
"pagination.previous": "पिछला", "pagination.previous": "पिछला",
"entry.status.unread": "अपठित", "entry.status.unread": "अपठित",
"entry.status.read": "पढ़े", "entry.status.read": "पढ़े",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं", "page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं", "page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं", "page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
"page.keyboard_shortcuts.go_to_bottom_item": "निचले आइटम पर जाएँ",
"page.keyboard_shortcuts.go_to_top_item": "शीर्ष आइटम पर जाएँ",
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें", "page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें", "page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें", "page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
@ -211,8 +206,8 @@
"page.settings.title": "समायोजन", "page.settings.title": "समायोजन",
"page.settings.link_google_account": "मेरा गूगल खाता जोरीय", "page.settings.link_google_account": "मेरा गूगल खाता जोरीय",
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय", "page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय (%s)", "page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय",
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय (%s)", "page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -226,7 +221,7 @@
], ],
"page.login.title": "साइन इन करें", "page.login.title": "साइन इन करें",
"page.login.google_signin": "गूगल के साथ साइन इन करें", "page.login.google_signin": "गूगल के साथ साइन इन करें",
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें (%s)", "page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें",
"page.login.webauthn_login": "पासकी से लॉगिन करें", "page.login.webauthn_login": "पासकी से लॉगिन करें",
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ", "page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
"page.integrations.title": "एकीकरण", "page.integrations.title": "एकीकरण",
@ -261,7 +256,6 @@
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है", "alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
"alert.no_category": "कोई श्रेणी नहीं है।", "alert.no_category": "कोई श्रेणी नहीं है।",
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।", "alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।", "alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।", "alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।", "alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
@ -303,14 +297,6 @@
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।", "error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।", "error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।", "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.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।", "error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.", "error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
@ -328,7 +314,6 @@
"form.feed.label.title": "शीर्षक", "form.feed.label.title": "शीर्षक",
"form.feed.label.site_url": "साइट यूआरएल", "form.feed.label.site_url": "साइट यूआरएल",
"form.feed.label.feed_url": "फ़ीड यूआरएल", "form.feed.label.feed_url": "फ़ीड यूआरएल",
"form.feed.label.description": "विवरण",
"form.feed.label.category": "श्रेणी", "form.feed.label.category": "श्रेणी",
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें", "form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम", "form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
@ -348,13 +333,6 @@
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें", "form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
"form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं", "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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़", "form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई", "form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें", "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings", "form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.import.label.file": "ओपीएमएल फ़ाइल", "form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल", "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_activate": "फीवर एपीआई सक्रिय करें",
"form.integration.fever_username": "फीवर उपयोगकर्ता नाम", "form.integration.fever_username": "फीवर उपयोगकर्ता नाम",
"form.integration.fever_password": "फीवर पासवर्ड", "form.integration.fever_password": "फीवर पासवर्ड",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड", "form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL", "form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी", "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_activate": "Readeck में विषयवस्तु सहेजें",
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु", "form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी", "form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "एपीआई कुंजी लेबल",
"form.submit.loading": "लोड हो रहा है...", "form.submit.loading": "लोड हो रहा है...",
"form.submit.saving": "सहेजा जा रहा है...", "form.submit.saving": "सहेजा जा रहा है...",
@ -545,7 +505,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca", "menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
"menu.show_all_entries": "Tampilkan semua entri", "menu.show_all_entries": "Tampilkan semua entri",
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca", "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_feed": "Muat ulang",
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang", "menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
"menu.edit_feed": "Sunting", "menu.edit_feed": "Sunting",
@ -57,8 +56,6 @@
"search.placeholder": "Cari...", "search.placeholder": "Cari...",
"search.submit": "Search", "search.submit": "Search",
"pagination.next": "Berikutnya", "pagination.next": "Berikutnya",
"pagination.last": "Last",
"pagination.first": "First",
"pagination.previous": "Sebelumnya", "pagination.previous": "Sebelumnya",
"entry.status.unread": "Belum dibaca", "entry.status.unread": "Belum dibaca",
"entry.status.read": "Telah dibaca", "entry.status.read": "Telah dibaca",
@ -172,8 +169,6 @@
"page.keyboard_shortcuts.go_to_feed": "Ke umpan", "page.keyboard_shortcuts.go_to_feed": "Ke umpan",
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya", "page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya", "page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
"page.keyboard_shortcuts.go_to_bottom_item": "Pergi ke item paling bawah",
"page.keyboard_shortcuts.go_to_top_item": "Pergi ke item teratas",
"page.keyboard_shortcuts.open_item": "Buka entri yang dipilih", "page.keyboard_shortcuts.open_item": "Buka entri yang dipilih",
"page.keyboard_shortcuts.open_original": "Buka tautan asli", "page.keyboard_shortcuts.open_original": "Buka tautan asli",
"page.keyboard_shortcuts.open_original_same_window": "Buka tautan asli di bilah saat ini", "page.keyboard_shortcuts.open_original_same_window": "Buka tautan asli di bilah saat ini",
@ -202,8 +197,8 @@
"page.settings.title": "Pengaturan", "page.settings.title": "Pengaturan",
"page.settings.link_google_account": "Tautkan akun Google saya", "page.settings.link_google_account": "Tautkan akun Google saya",
"page.settings.unlink_google_account": "Putuskan akun Google saya", "page.settings.unlink_google_account": "Putuskan akun Google saya",
"page.settings.link_oidc_account": "Tautkan akun %s saya", "page.settings.link_oidc_account": "Tautkan akun OpenID Connect saya",
"page.settings.unlink_oidc_account": "Putuskan akun %s saya", "page.settings.unlink_oidc_account": "Putuskan akun OpenID Connect saya",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -216,7 +211,7 @@
], ],
"page.login.title": "Masuk", "page.login.title": "Masuk",
"page.login.google_signin": "Masuk dengan Google", "page.login.google_signin": "Masuk dengan Google",
"page.login.oidc_signin": "Masuk dengan %s", "page.login.oidc_signin": "Masuk dengan OpenID Connect",
"page.login.webauthn_login": "Login with passkey", "page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey", "page.login.webauthn_login.error": "Unable to login with passkey",
"page.integrations.title": "Integrasi", "page.integrations.title": "Integrasi",
@ -251,7 +246,6 @@
"alert.no_bookmark": "Tidak ada markah.", "alert.no_bookmark": "Tidak ada markah.",
"alert.no_category": "Tidak ada kategori.", "alert.no_category": "Tidak ada kategori.",
"alert.no_category_entry": "Tidak ada artikel di kategori ini.", "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_entry": "Tidak ada artikel di umpan ini.",
"alert.no_feed": "Anda tidak memiliki langganan.", "alert.no_feed": "Anda tidak memiliki langganan.",
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.", "alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
@ -293,14 +287,6 @@
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.", "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_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.", "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.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.", "error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
"error.feed_already_exists": "Umpan ini sudah ada.", "error.feed_already_exists": "Umpan ini sudah ada.",
@ -318,7 +304,6 @@
"form.feed.label.title": "Judul", "form.feed.label.title": "Judul",
"form.feed.label.site_url": "URL Situs", "form.feed.label.site_url": "URL Situs",
"form.feed.label.feed_url": "URL Umpan", "form.feed.label.feed_url": "URL Umpan",
"form.feed.label.description": "Deskripsi",
"form.feed.label.category": "Kategori", "form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Ambil konten asli", "form.feed.label.crawler": "Ambil konten asli",
"form.feed.label.feed_username": "Nama Pengguna Umpan", "form.feed.label.feed_username": "Nama Pengguna Umpan",
@ -338,13 +323,6 @@
"form.feed.label.disabled": "Jangan perbarui umpan ini", "form.feed.label.disabled": "Jangan perbarui umpan ini",
"form.feed.label.no_media_player": "No media player (audio/video)", "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.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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -385,18 +363,11 @@
"form.prefs.label.default_home_page": "Beranda Baku", "form.prefs.label.default_home_page": "Beranda Baku",
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori", "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": "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "Berkas OPML",
"form.import.label.url": "URL", "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_activate": "Aktifkan API Fever",
"form.integration.fever_username": "Nama Pengguna Fever", "form.integration.fever_username": "Nama Pengguna Fever",
"form.integration.fever_password": "Kata Sandi Fever", "form.integration.fever_password": "Kata Sandi Fever",
@ -468,10 +439,6 @@
"form.integration.matrix_bot_password": "Kata Sandi Matrix", "form.integration.matrix_bot_password": "Kata Sandi Matrix",
"form.integration.matrix_bot_url": "URL Peladen Matrix", "form.integration.matrix_bot_url": "URL Peladen Matrix",
"form.integration.matrix_bot_chat_id": "ID Ruang 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_activate": "Simpan artikel ke Readeck",
"form.integration.readeck_endpoint": "Titik URL API Readeck", "form.integration.readeck_endpoint": "Titik URL API Readeck",
"form.integration.readeck_api_key": "Kunci API Readeck", "form.integration.readeck_api_key": "Kunci API Readeck",
@ -489,13 +456,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Label Kunci API",
"form.submit.loading": "Memuat...", "form.submit.loading": "Memuat...",
"form.submit.saving": "Menyimpan...", "form.submit.saving": "Menyimpan...",
@ -528,7 +488,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -547,16 +507,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %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",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Segna tutti gli articoli come letti", "menu.mark_all_as_read": "Segna tutti gli articoli come letti",
"menu.show_all_entries": "Mostra tutte le voci", "menu.show_all_entries": "Mostra tutte le voci",
"menu.show_only_unread_entries": "Mostra solo voci non lette", "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_feed": "Aggiorna",
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background", "menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
"menu.edit_feed": "Modifica", "menu.edit_feed": "Modifica",
@ -57,8 +56,6 @@
"search.placeholder": "Cerca...", "search.placeholder": "Cerca...",
"search.submit": "Search", "search.submit": "Search",
"pagination.next": "Successivo", "pagination.next": "Successivo",
"pagination.last": "Last",
"pagination.first": "First",
"pagination.previous": "Precedente", "pagination.previous": "Precedente",
"entry.status.unread": "Da leggere", "entry.status.unread": "Da leggere",
"entry.status.read": "Letto", "entry.status.read": "Letto",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed", "page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente", "page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva", "page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
"page.keyboard_shortcuts.go_to_bottom_item": "Vai all'elemento in fondo",
"page.keyboard_shortcuts.go_to_top_item": "Vai all'elemento principale",
"page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato", "page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato",
"page.keyboard_shortcuts.open_original": "Apri la pagina web originale", "page.keyboard_shortcuts.open_original": "Apri la pagina web originale",
"page.keyboard_shortcuts.open_original_same_window": "Apri il link originale nella scheda corrente", "page.keyboard_shortcuts.open_original_same_window": "Apri il link originale nella scheda corrente",
@ -211,8 +206,8 @@
"page.settings.title": "Impostazioni", "page.settings.title": "Impostazioni",
"page.settings.link_google_account": "Collega il mio account Google", "page.settings.link_google_account": "Collega il mio account Google",
"page.settings.unlink_google_account": "Scollega il mio account Google", "page.settings.unlink_google_account": "Scollega il mio account Google",
"page.settings.link_oidc_account": "Collega il mio account %s", "page.settings.link_oidc_account": "Collega il mio account OpenID Connect",
"page.settings.unlink_oidc_account": "Scollega il mio account %s", "page.settings.unlink_oidc_account": "Scollega il mio account OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -226,7 +221,7 @@
], ],
"page.login.title": "Accedi", "page.login.title": "Accedi",
"page.login.google_signin": "Accedi tramite Google", "page.login.google_signin": "Accedi tramite Google",
"page.login.oidc_signin": "Accedi tramite %s", "page.login.oidc_signin": "Accedi tramite OpenID Connect",
"page.login.webauthn_login": "Accedi con passkey", "page.login.webauthn_login": "Accedi con passkey",
"page.login.webauthn_login.error": "Impossibile accedere con passkey", "page.login.webauthn_login.error": "Impossibile accedere con passkey",
"page.integrations.title": "Integrazioni", "page.integrations.title": "Integrazioni",
@ -261,7 +256,6 @@
"alert.no_bookmark": "Nessun preferito disponibile.", "alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.", "alert.no_category": "Nessuna categoria disponibile.",
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.", "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_entry": "Questo feed non contiene alcun articolo.",
"alert.no_feed": "Nessun feed disponibile.", "alert.no_feed": "Nessun feed disponibile.",
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.", "alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
@ -296,14 +290,6 @@
"error.password_min_length": "La password deve contenere almeno 6 caratteri.", "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_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_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.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_mandatory_fields": "L'URL e la categoria sono obbligatori.",
"error.feed_already_exists": "Questo feed esiste già.", "error.feed_already_exists": "Questo feed esiste già.",
@ -328,7 +314,6 @@
"form.feed.label.title": "Titolo", "form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito", "form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed", "form.feed.label.feed_url": "URL del feed",
"form.feed.label.description": "Descrizione",
"form.feed.label.category": "Categoria", "form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Scarica il contenuto integrale", "form.feed.label.crawler": "Scarica il contenuto integrale",
"form.feed.label.feed_username": "Nome utente del feed", "form.feed.label.feed_username": "Nome utente del feed",
@ -348,13 +333,6 @@
"form.feed.label.disabled": "Non aggiornare questo feed", "form.feed.label.disabled": "Non aggiornare questo feed",
"form.feed.label.no_media_player": "No media player (audio/video)", "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.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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Pagina iniziale predefinita", "form.prefs.label.default_home_page": "Pagina iniziale predefinita",
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie", "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": "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "File OPML",
"form.import.label.url": "URL", "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_activate": "Abilita l'API di Fever",
"form.integration.fever_username": "Nome utente dell'account Fever", "form.integration.fever_username": "Nome utente dell'account Fever",
"form.integration.fever_password": "Password dell'account Fever", "form.integration.fever_password": "Password dell'account Fever",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Password per l'utente Matrix", "form.integration.matrix_bot_password": "Password per l'utente Matrix",
"form.integration.matrix_bot_url": "URL del server Matrix", "form.integration.matrix_bot_url": "URL del server Matrix",
"form.integration.matrix_bot_chat_id": "ID della stanza 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_activate": "Salva gli articoli su Readeck",
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck", "form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
"form.integration.readeck_api_key": "API key dell'account Readeck", "form.integration.readeck_api_key": "API key dell'account Readeck",
@ -500,13 +467,6 @@
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.api_key.label.description": "Etichetta chiave API", "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.loading": "Caricamento in corso...",
"form.submit.saving": "Salvataggio in corso...", "form.submit.saving": "Salvataggio in corso...",
"time_elapsed.not_yet": "non ancora", "time_elapsed.not_yet": "non ancora",
@ -545,7 +505,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %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",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "すべて既読にする", "menu.mark_all_as_read": "すべて既読にする",
"menu.show_all_entries": "すべての記事を表示", "menu.show_all_entries": "すべての記事を表示",
"menu.show_only_unread_entries": "未読の記事だけを表示", "menu.show_only_unread_entries": "未読の記事だけを表示",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "更新", "menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新", "menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
"menu.edit_feed": "編集", "menu.edit_feed": "編集",
@ -56,9 +55,7 @@
"search.label": "検索", "search.label": "検索",
"search.placeholder": "…を検索", "search.placeholder": "…を検索",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "次", "pagination.next": "次",
"pagination.first": "First",
"pagination.previous": "前", "pagination.previous": "前",
"entry.status.unread": "未読にする", "entry.status.unread": "未読にする",
"entry.status.read": "既読にする", "entry.status.read": "既読にする",
@ -172,8 +169,6 @@
"page.keyboard_shortcuts.go_to_feed": "フィード", "page.keyboard_shortcuts.go_to_feed": "フィード",
"page.keyboard_shortcuts.go_to_previous_page": "前のページ", "page.keyboard_shortcuts.go_to_previous_page": "前のページ",
"page.keyboard_shortcuts.go_to_next_page": "次のページ", "page.keyboard_shortcuts.go_to_next_page": "次のページ",
"page.keyboard_shortcuts.go_to_bottom_item": "一番下の項目に移動",
"page.keyboard_shortcuts.go_to_top_item": "先頭の項目に移動",
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く", "page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く", "page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く", "page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
@ -202,8 +197,8 @@
"page.settings.title": "設定", "page.settings.title": "設定",
"page.settings.link_google_account": "Google アカウントと接続する", "page.settings.link_google_account": "Google アカウントと接続する",
"page.settings.unlink_google_account": "Google アカウントと接続を解除する", "page.settings.unlink_google_account": "Google アカウントと接続を解除する",
"page.settings.link_oidc_account": "%s アカウントと接続する", "page.settings.link_oidc_account": "OpenID Connect アカウントと接続する",
"page.settings.unlink_oidc_account": "%s アカウントと接続を解除する", "page.settings.unlink_oidc_account": "OpenID Connect アカウントと接続を解除する",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -216,7 +211,7 @@
], ],
"page.login.title": "ログイン", "page.login.title": "ログイン",
"page.login.google_signin": "Google アカウントでログイン", "page.login.google_signin": "Google アカウントでログイン",
"page.login.oidc_signin": "%s アカウントでログイン", "page.login.oidc_signin": "OpenID Connect アカウントでログイン",
"page.login.webauthn_login": "パスキーでログイン", "page.login.webauthn_login": "パスキーでログイン",
"page.login.webauthn_login.error": "パスキーでログインできない", "page.login.webauthn_login.error": "パスキーでログインできない",
"page.integrations.title": "連携", "page.integrations.title": "連携",
@ -251,7 +246,6 @@
"alert.no_bookmark": "現在星付きはありません。", "alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。", "alert.no_category": "カテゴリが存在しません。",
"alert.no_category_entry": "このカテゴリには記事がありません。", "alert.no_category_entry": "このカテゴリには記事がありません。",
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
"alert.no_feed_entry": "このフィードには記事がありません。", "alert.no_feed_entry": "このフィードには記事がありません。",
"alert.no_feed": "何も購読していません。", "alert.no_feed": "何も購読していません。",
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。", "alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
@ -293,14 +287,6 @@
"error.password_min_length": "パスワードは6文字以上である必要があります。", "error.password_min_length": "パスワードは6文字以上である必要があります。",
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。", "error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。",
"error.settings_reading_speed_is_positive": "読書速度は正の整数である必要があります。", "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.entries_per_page_invalid": "ページあたりの記事数が無効です。",
"error.feed_mandatory_fields": "URL と カテゴリが必要です。", "error.feed_mandatory_fields": "URL と カテゴリが必要です。",
"error.feed_already_exists": "このフィードは既に存在します。", "error.feed_already_exists": "このフィードは既に存在します。",
@ -318,7 +304,6 @@
"form.feed.label.title": "タイトル", "form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL", "form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL", "form.feed.label.feed_url": "フィード URL",
"form.feed.label.description": "説明",
"form.feed.label.category": "カテゴリ", "form.feed.label.category": "カテゴリ",
"form.feed.label.crawler": "オリジナルの内容を取得", "form.feed.label.crawler": "オリジナルの内容を取得",
"form.feed.label.feed_username": "フィードのユーザー名", "form.feed.label.feed_username": "フィードのユーザー名",
@ -338,13 +323,6 @@
"form.feed.label.disabled": "このフィードを更新しない", "form.feed.label.disabled": "このフィードを更新しない",
"form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "未読一覧に記事を表示しない", "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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -385,18 +363,11 @@
"form.prefs.label.default_home_page": "デフォルトのトップページ", "form.prefs.label.default_home_page": "デフォルトのトップページ",
"form.prefs.label.categories_sorting_order": "カテゴリの表示順", "form.prefs.label.categories_sorting_order": "カテゴリの表示順",
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします", "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "OPML ファイル",
"form.import.label.url": "URL", "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_activate": "Fever API を有効にする",
"form.integration.fever_username": "Fever のユーザー名", "form.integration.fever_username": "Fever のユーザー名",
"form.integration.fever_password": "Fever のパスワード", "form.integration.fever_password": "Fever のパスワード",
@ -468,10 +439,6 @@
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード", "form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
"form.integration.matrix_bot_url": "MatrixサーバーのURL", "form.integration.matrix_bot_url": "MatrixサーバーのURL",
"form.integration.matrix_bot_chat_id": "MatrixルームのID", "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_activate": "Readeck に記事を保存する",
"form.integration.readeck_endpoint": "Readeck の API Endpoint", "form.integration.readeck_endpoint": "Readeck の API Endpoint",
"form.integration.readeck_api_key": "Readeck の API key", "form.integration.readeck_api_key": "Readeck の API key",
@ -489,13 +456,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "API キーラベル",
"form.submit.loading": "読み込み中…", "form.submit.loading": "読み込み中…",
"form.submit.saving": "保存中…", "form.submit.saving": "保存中…",
@ -528,7 +488,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -547,16 +507,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
"error.settings_media_playback_rate_range": "再生速度が範囲外",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -1,17 +1,17 @@
{ {
"skip_to_content": "Ga naar inhoud", "skip_to_content": "Skip to content",
"confirm.question": "Weet je het zeker?", "confirm.question": "Weet je het zeker?",
"confirm.question.refresh": "Wil je vernieuwen forceren?", "confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
"confirm.yes": "ja", "confirm.yes": "ja",
"confirm.no": "nee", "confirm.no": "nee",
"confirm.loading": "Bezig...", "confirm.loading": "Bezig...",
"action.subscribe": "Abonneren", "action.subscribe": "Abboneren",
"action.save": "Opslaan", "action.save": "Opslaan",
"action.or": "of", "action.or": "of",
"action.cancel": "annuleren", "action.cancel": "annuleren",
"action.remove": "Verwijderen", "action.remove": "Verwijderen",
"action.remove_feed": "Verwijder deze feed", "action.remove_feed": "Verwijder deze feed",
"action.update": "Bijwerken", "action.update": "Updaten",
"action.edit": "Bewerken", "action.edit": "Bewerken",
"action.download": "Download", "action.download": "Download",
"action.import": "Importeren", "action.import": "Importeren",
@ -20,7 +20,7 @@
"tooltip.keyboard_shortcuts": "Sneltoets: %s", "tooltip.keyboard_shortcuts": "Sneltoets: %s",
"tooltip.logged_user": "Ingelogd als %s", "tooltip.logged_user": "Ingelogd als %s",
"menu.title": "Menu", "menu.title": "Menu",
"menu.home_page": "Startpagina", "menu.home_page": "Home page",
"menu.unread": "Ongelezen", "menu.unread": "Ongelezen",
"menu.starred": "Favorieten", "menu.starred": "Favorieten",
"menu.history": "Geschiedenis", "menu.history": "Geschiedenis",
@ -31,17 +31,16 @@
"menu.preferences": "Voorkeuren", "menu.preferences": "Voorkeuren",
"menu.integrations": "Integraties", "menu.integrations": "Integraties",
"menu.sessions": "Sessies", "menu.sessions": "Sessies",
"menu.users": "Gebruikers", "menu.users": "Users",
"menu.about": "Over", "menu.about": "Over",
"menu.export": "Exporteren", "menu.export": "Exporteren",
"menu.import": "Importeren", "menu.import": "Importeren",
"menu.search": "Zoeken", "menu.search": "Zoeken",
"menu.create_category": "Categorie toevoegen", "menu.create_category": "Categorie toevoegen",
"menu.mark_page_as_read": "Markeer deze pagina als gelezen", "menu.mark_page_as_read": "Markeer deze pagina als gelezen",
"menu.mark_all_as_read": "Markeer alles als gelezen", "menu.mark_all_as_read": "Markeer alle items als gelezen",
"menu.show_all_entries": "Toon alle artikelen", "menu.show_all_entries": "Toon alle artikelen",
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen", "menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
"menu.show_only_starred_entries": "Toon alleen favorieten",
"menu.refresh_feed": "Vernieuwen", "menu.refresh_feed": "Vernieuwen",
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond", "menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"menu.edit_feed": "Bewerken", "menu.edit_feed": "Bewerken",
@ -49,39 +48,37 @@
"menu.add_feed": "Feed toevoegen", "menu.add_feed": "Feed toevoegen",
"menu.add_user": "Gebruiker toevoegen", "menu.add_user": "Gebruiker toevoegen",
"menu.flush_history": "Verwijder geschiedenis", "menu.flush_history": "Verwijder geschiedenis",
"menu.feed_entries": "Artikelen", "menu.feed_entries": "Lidwoord",
"menu.api_keys": "API-sleutels", "menu.api_keys": "API-sleutels",
"menu.create_api_key": "Maak een nieuwe API-sleutel", "menu.create_api_key": "Maak een nieuwe API-sleutel",
"menu.shared_entries": "Gedeelde artikelen", "menu.shared_entries": "Gedeelde vermeldingen",
"search.label": "Zoeken", "search.label": "Zoeken",
"search.placeholder": "Zoeken...", "search.placeholder": "Zoeken...",
"search.submit": "Zoeken", "search.submit": "Search",
"pagination.last": "Laatste",
"pagination.next": "Volgende", "pagination.next": "Volgende",
"pagination.first": "Eerste",
"pagination.previous": "Vorige", "pagination.previous": "Vorige",
"entry.status.unread": "Ongelezen", "entry.status.unread": "Ongelezen",
"entry.status.read": "Gelezen", "entry.status.read": "Gelezen",
"entry.status.toast.unread": "Gemarkeerd als ongelezen", "entry.status.toast.unread": "Gemarkeerd als ongelezen",
"entry.status.toast.read": "Gemarkeerd als gelezen", "entry.status.toast.read": "Gemarkeerd als gelezen",
"entry.status.title": "Verander artikelstatus", "entry.status.title": "Verander status van item",
"entry.bookmark.toggle.on": "Favoriet", "entry.bookmark.toggle.on": "Ster toevoegen",
"entry.bookmark.toggle.off": "Favoriet verwijderen", "entry.bookmark.toggle.off": "Ster weghalen",
"entry.bookmark.toast.on": "Favoriet toegevoegd", "entry.bookmark.toast.on": "Met ster",
"entry.bookmark.toast.off": "Favoriet verwijderd", "entry.bookmark.toast.off": "Ster verwijderd",
"entry.state.saving": "Opslaan...", "entry.state.saving": "Opslaag...",
"entry.state.loading": "Laden...", "entry.state.loading": "Laden...",
"entry.save.label": "Opslaan", "entry.save.label": "Opslaan",
"entry.save.title": "Artikel opslaan", "entry.save.title": "Artikel opslaan",
"entry.save.completed": "Klaar!", "entry.save.completed": "Done!",
"entry.save.toast.completed": "Artikel opgeslagen", "entry.save.toast.completed": "Artikel opgeslagen",
"entry.scraper.label": "Downloaden", "entry.scraper.label": "Downloaden",
"entry.scraper.title": "Originele inhoud ophalen", "entry.scraper.title": "Fetch original content",
"entry.scraper.completed": "Klaar!", "entry.scraper.completed": "Klaar!",
"entry.external_link.label": "Externe link", "entry.external_link.label": "Externe link",
"entry.comments.label": "Reacties", "entry.comments.label": "Comments",
"entry.comments.title": "Bekijk reacties", "entry.comments.title": "Bekijk de reacties",
"entry.share.label": "Delen", "entry.share.label": "Deel",
"entry.share.title": "Deel dit artikel", "entry.share.title": "Deel dit artikel",
"entry.unshare.label": "Delen ongedaan maken", "entry.unshare.label": "Delen ongedaan maken",
"entry.shared_entry.title": "Open de openbare link", "entry.shared_entry.title": "Open de openbare link",
@ -90,117 +87,115 @@
"%d minuut leestijd", "%d minuut leestijd",
"%d minuten leestijd" "%d minuten leestijd"
], ],
"entry.tags.label": "Tags:", "entry.tags.label": "Labels:",
"page.shared_entries.title": "Gedeelde artikelen", "page.shared_entries.title": "Gedeelde vermeldingen",
"page.shared_entries_count": [ "page.shared_entries_count": [
"%d gedeeld artikel", "%d shared entry",
"%d gedeelde artikelen" "%d shared entries"
], ],
"page.unread.title": "Ongelezen", "page.unread.title": "Ongelezen",
"page.unread_entry_count": [ "page.unread_entry_count": [
"%d ongelezen artikel", "%d unread entry",
"%d ongelezen artikelen" "%d unread entries"
], ],
"page.total_entry_count": [ "page.total_entry_count": [
"%d artikel totaal", "%d entry in total",
"%d artikelen totaal" "%d entries in total"
], ],
"page.starred.title": "Favorieten", "page.starred.title": "Favorieten",
"page.starred_entry_count": [ "page.starred_entry_count": [
"%d favoriet artikel", "%d starred entry",
"%d favoriete artikelen" "%d starred entries"
], ],
"page.categories.title": "Categorieën", "page.categories.title": "Categorieën",
"page.categories.no_feed": "Geen feed.", "page.categories.no_feed": "Geen feeds.",
"page.categories.entries": "Artikelen", "page.categories.entries": "Lidwoord",
"page.categories.feeds": "Feeds", "page.categories.feeds": "Abonnementen",
"page.categories.feed_count": [ "page.categories.feed_count": [
"Er is %d feed.", "Er is %d feed.",
"Er zijn %d feeds." "Er zijn %d feeds."
], ],
"page.categories_count": [ "page.categories_count": [
"%d categorie", "%d category",
"%d categorieën" "%d categories"
], ],
"page.new_category.title": "Nieuwe categorie", "page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker", "page.new_user.title": "Nieuwe gebruiker",
"page.edit_category.title": "Bewerk categorie: %s", "page.edit_category.title": "Bewerken van categorie: %s",
"page.edit_user.title": "Bewerk gebruiker: %s", "page.edit_user.title": "Bewerk gebruiker: %s",
"page.feeds.title": "Feeds", "page.feeds.title": "Feeds",
"page.category_label": "Categorie: %s", "page.category_label": "Category: %s",
"page.feeds.last_check": "Laatste controle:", "page.feeds.last_check": "Laatste update:",
"page.feeds.next_check": "Volgende controle:", "page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Aantal gelezen artikelen", "page.feeds.read_counter": "Aantal gelezen vermeldingen",
"page.feeds.error_count": [ "page.feeds.error_count": [
"%d fout", "%d error",
"%d fouten" "%d errors"
], ],
"page.history.title": "Geschiedenis", "page.history.title": "Geschiedenis",
"page.read_entry_count": [ "page.read_entry_count": [
"%d gelezen artikel", "%d read entry",
"%d gelezen artikelen" "%d read entries"
], ],
"page.import.title": "Importeren", "page.import.title": "Importeren",
"page.login.title": "Inloggen", "page.login.title": "Inloggen",
"page.search.title": "Zoekresultaten", "page.search.title": "Zoekresultaten",
"page.about.title": "Over", "page.about.title": "Over",
"page.about.credits": "Credits", "page.about.credits": "Copyrights",
"page.about.version": "Versie:", "page.about.version": "Versie:",
"page.about.build_date": "Compilatiedatum:", "page.about.build_date": "Datum build:",
"page.about.author": "Auteur:", "page.about.author": "Auteur:",
"page.about.license": "Licentie:", "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.postgres_version": "Postgres versie:",
"page.about.go_version": "Go versie:", "page.about.go_version": "Go versie:",
"page.add_feed.title": "Nieuwe feed", "page.add_feed.title": "Nieuwe feed",
"page.add_feed.no_category": "Er is geen categorie. Je moet minstens één categorie hebben.", "page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"page.add_feed.label.url": "URL", "page.add_feed.label.url": "URL",
"page.add_feed.submit": "Feed zoeken", "page.add_feed.submit": "Feed zoeken",
"page.add_feed.legend.advanced_options": "Geavanceerde opties", "page.add_feed.legend.advanced_options": "Geavanceerde mogelijkheden",
"page.add_feed.choose_feed": "Feed kiezen", "page.add_feed.choose_feed": "Feed kiezen",
"page.edit_feed.title": "Bewerk feed: %s", "page.edit_feed.title": "Bewerken van feed: %s",
"page.edit_feed.last_check": "Laatste controle:", "page.edit_feed.last_check": "Laatste update:",
"page.edit_feed.last_modified_header": "LastModified header:", "page.edit_feed.last_modified_header": "LastModified-header:",
"page.edit_feed.etag_header": "ETAG header:", "page.edit_feed.etag_header": "ETAG-header:",
"page.edit_feed.no_header": "Geen", "page.edit_feed.no_header": "Geen",
"page.edit_feed.last_parsing_error": "Laatste analysefout", "page.edit_feed.last_parsing_error": "Laatste parse error",
"page.entry.attachments": "Bijlagen", "page.entry.attachments": "Bijlagen",
"page.keyboard_shortcuts.title": "Sneltoetsen", "page.keyboard_shortcuts.title": "Sneltoetsen",
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's", "page.keyboard_shortcuts.subtitle.sections": "Naviguatie tussen menu's",
"page.keyboard_shortcuts.subtitle.items": "Navigeren door artikelen", "page.keyboard_shortcuts.subtitle.items": "Navigatie tussen items",
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's", "page.keyboard_shortcuts.subtitle.pages": "Naviguatie tussen pagina's",
"page.keyboard_shortcuts.subtitle.actions": "Acties", "page.keyboard_shortcuts.subtitle.actions": "Actions",
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen", "page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten", "page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis", "page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds", "page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën", "page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen", "page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Sneltoetsen tonen", "page.keyboard_shortcuts.show_keyboard_shortcuts": "Laat sneltoetsen zien",
"page.keyboard_shortcuts.go_to_previous_item": "Vorig artikel", "page.keyboard_shortcuts.go_to_previous_item": "Vorige item",
"page.keyboard_shortcuts.go_to_next_item": "Volgend artikel", "page.keyboard_shortcuts.go_to_next_item": "Volgende item",
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed", "page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina", "page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina", "page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste artikel", "page.keyboard_shortcuts.open_item": "Open geselecteerde link",
"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": "Open originele link",
"page.keyboard_shortcuts.open_original_same_window": "Open originele link in huidig tabblad", "page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
"page.keyboard_shortcuts.open_comments": "Open reacties", "page.keyboard_shortcuts.open_comments": "Open opmerkingen link",
"page.keyboard_shortcuts.open_comments_same_window": "Open reacties in huidig tabblad", "page.keyboard_shortcuts.open_comments_same_window": "Open de reactiekoppeling op het huidige tabblad",
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende", "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.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond", "page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
"page.keyboard_shortcuts.mark_page_as_read": "Markeer huidige pagina als gelezen", "page.keyboard_shortcuts.mark_page_as_read": "Markeer deze pagina als gelezen",
"page.keyboard_shortcuts.download_content": "Download originele inhoud", "page.keyboard_shortcuts.download_content": "Download originele content",
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen", "page.keyboard_shortcuts.toggle_bookmark_status": "Ster toevoegen/weghalen",
"page.keyboard_shortcuts.save_article": "Artikel opslaan", "page.keyboard_shortcuts.save_article": "Artikel opslaan",
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven", "page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed", "page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier", "page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten", "page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.close_modal": "Dialoogvenster sluiten", "page.keyboard_shortcuts.close_modal": "Sluit dialoogscherm",
"page.users.title": "Gebruikers", "page.users.title": "Gebruikers",
"page.users.username": "Gebruikersnaam", "page.users.username": "Gebruikersnaam",
"page.users.never_logged": "Nooit", "page.users.never_logged": "Nooit",
@ -208,27 +203,27 @@
"page.users.admin.no": "Nee", "page.users.admin.no": "Nee",
"page.users.actions": "Acties", "page.users.actions": "Acties",
"page.users.last_login": "Laatste login", "page.users.last_login": "Laatste login",
"page.users.is_admin": "Beheerder", "page.users.is_admin": "Administrator",
"page.settings.title": "Instellingen", "page.settings.title": "Instellingen",
"page.settings.link_google_account": "Koppel mijn Google-account", "page.settings.link_google_account": "Koppel mijn Google-account",
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account", "page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
"page.settings.link_oidc_account": "Koppel mijn %s account", "page.settings.link_oidc_account": "Koppel mijn OpenID Connect-account",
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s account", "page.settings.unlink_oidc_account": "Ontkoppel mijn OpenID Connect-account",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Acties", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Naam", "page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Toegevoegd op", "page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Laatst gebruikt", "page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Passkey registreren", "page.settings.webauthn.register": "Wachtwoord registreren",
"page.settings.webauthn.register.error": "Kan passkey niet registreren", "page.settings.webauthn.register.error": "Kan wachtwoord niet registreren",
"page.settings.webauthn.delete": [ "page.settings.webauthn.delete": [
"Verwijder %d passkey", "Verwijder %d wachtwoord",
"Verwijder %d passkeys" "Verwijder %d wachtwoordsleutels"
], ],
"page.login.oidc_signin": "Inloggen met %s", "page.login.oidc_signin": "Inloggen via OpenID Connect",
"page.login.webauthn_login": "Inloggen met passkey", "page.login.webauthn_login": "Inloggen met wachtwoord",
"page.login.webauthn_login.error": "Kan niet inloggen met passkey", "page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord",
"page.login.google_signin": "Inloggen met Google", "page.login.google_signin": "Inloggen via Google",
"page.integrations.title": "Integraties", "page.integrations.title": "Integraties",
"page.integration.miniflux_api": "Miniflux API", "page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API-URL", "page.integration.miniflux_api_endpoint": "API-URL",
@ -238,7 +233,7 @@
"page.integration.bookmarklet": "Bookmarklet", "page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux", "page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.", "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 abonneren op een website.", "page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"page.sessions.title": "Sessies", "page.sessions.title": "Sessies",
"page.sessions.table.date": "Datum", "page.sessions.table.date": "Datum",
"page.sessions.table.ip": "IP-adres", "page.sessions.table.ip": "IP-adres",
@ -246,8 +241,8 @@
"page.sessions.table.actions": "Acties", "page.sessions.table.actions": "Acties",
"page.sessions.table.current_session": "Huidige sessie", "page.sessions.table.current_session": "Huidige sessie",
"page.api_keys.title": "API-sleutels", "page.api_keys.title": "API-sleutels",
"page.api_keys.table.description": "Omschrijving", "page.api_keys.table.description": "Beschrijving",
"page.api_keys.table.token": "Token", "page.api_keys.table.token": "Blijk",
"page.api_keys.table.last_used_at": "Laatst gebruikt", "page.api_keys.table.last_used_at": "Laatst gebruikt",
"page.api_keys.table.created_at": "Aanmaakdatum", "page.api_keys.table.created_at": "Aanmaakdatum",
"page.api_keys.table.actions": "Acties", "page.api_keys.table.actions": "Acties",
@ -256,157 +251,133 @@
"page.offline.title": "Offline modus", "page.offline.title": "Offline modus",
"page.offline.message": "Je bent offline", "page.offline.message": "Je bent offline",
"page.offline.refresh_page": "Probeer de pagina te vernieuwen", "page.offline.refresh_page": "Probeer de pagina te vernieuwen",
"page.webauthn_rename.title": "Hernoem Passkey", "page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Er is geen gedeeld artikel.", "alert.no_shared_entry": "Er is geen gedeelde toegang.",
"alert.no_bookmark": "Er zijn geen favorieten.", "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.", "alert.no_category": "Er zijn geen categorieën.",
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.", "alert.no_category_entry": "Deze categorie bevat geen feeds.",
"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_entry": "Er zijn geen artikelen in deze feed.",
"alert.no_feed": "Je hebt nog geen feed geabonneerd.", "alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
"alert.no_feed_in_category": "Er is geen feed voor deze categorie.", "alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
"alert.no_history": "Geschiedenis is op dit moment leeg.", "alert.no_history": "Geschiedenis is op dit moment leeg.",
"alert.feed_error": "Er is een probleem met deze feed", "alert.feed_error": "Er is een probleem met deze feed",
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.", "alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.", "alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
"alert.no_user": "Je bent de enige gebruiker.", "alert.no_user": "Je bent de enige gebruiker.",
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!", "alert.account_unlinked": "Uw externe account is nu gedissocieerd!",
"alert.account_linked": "Jouw externe account is nu gekoppeld!", "alert.account_linked": "Uw externe account is nu gekoppeld!",
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!", "alert.pocket_linked": "Uw Pocket-account is nu gekoppeld!",
"alert.prefs_saved": "Instellingen opgeslagen!", "alert.prefs_saved": "Instellingen opgeslagen!",
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.", "error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!", "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_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader 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_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!", "error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
"error.category_already_exists": "Deze categorie bestaat al.", "error.category_already_exists": "Deze categorie bestaat al.",
"error.unable_to_create_category": "Kan deze categorie niet aanmaken.", "error.unable_to_create_category": "Kan deze categorie niet maken.",
"error.unable_to_update_category": "Kan categorie niet bijwerken.", "error.unable_to_update_category": "Kon categorie niet updaten.",
"error.user_already_exists": "Deze gebruiker bestaat al.", "error.user_already_exists": "Deze gebruiker bestaat al.",
"error.unable_to_create_user": "Kan deze gebruiker niet aanmaken.", "error.unable_to_create_user": "Kan deze gebruiker niet maken.",
"error.unable_to_update_user": "Kan deze gebruiker niet bijwerken.", "error.unable_to_update_user": "Kan deze gebruiker niet updaten.",
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.", "error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
"error.subscription_not_found": "Kan geen feeds vinden.", "error.subscription_not_found": "Kon geen feeds vinden.",
"error.empty_file": "Dit bestand is leeg.", "error.empty_file": "Dit bestand is leeg.",
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.", "error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.", "error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
"error.title_required": "De titel is verplicht.", "error.title_required": "Naam van categorie is verplicht.",
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.", "error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
"error.password_min_length": "Minimaal 6 tekens gebruiken.", "error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
"error.settings_mandatory_fields": "Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.", "error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.", "error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)", "error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='", "error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
"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.feed_already_exists": "Deze feed bestaat al.",
"error.invalid_feed_url": "Ongeldige feed URL.", "error.invalid_feed_url": "Ongeldige feed-URL.",
"error.invalid_site_url": "Ongeldige site URL.", "error.invalid_site_url": "Ongeldige site-URL.",
"error.feed_url_not_empty": "De feed URL mag niet leeg zijn.", "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.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_title_not_empty": "De feedtitel mag niet leeg zijn.",
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.", "error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
"error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.", "error.feed_invalid_blocklist_rule": "De regel voor de blokkeerlijst is ongeldig.",
"error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.", "error.feed_invalid_keeplist_rule": "De regel voor het bewaren van een lijst is ongeldig.",
"error.user_mandatory_fields": "Gebruikersnaam is verplicht", "error.user_mandatory_fields": "Gebruikersnaam is verplicht",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.", "error.api_key_already_exists": "This API Key already exists.",
"error.unable_to_create_api_key": "Kan deze API-sleutel niet aanmaken.", "error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
"error.invalid_theme": "Ongeldig thema.", "error.invalid_theme": "Ongeldig thema.",
"error.invalid_language": "Ongeldige taal.", "error.invalid_language": "Ongeldige taal.",
"error.invalid_timezone": "Ongeldige tijdzone.", "error.invalid_timezone": "Ongeldige tijdzone.",
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.", "error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.", "error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.", "error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
"error.invalid_default_home_page": "Ongeldige startpagina!", "error.invalid_default_home_page": "Ongeldige standaard homepage!",
"form.feed.label.title": "Titel", "form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL", "form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL", "form.feed.label.feed_url": "Feed URL",
"form.feed.label.description": "Omschrijving",
"form.feed.label.category": "Categorie", "form.feed.label.category": "Categorie",
"form.feed.label.crawler": "Download originele inhoud", "form.feed.label.crawler": "Download originele content",
"form.feed.label.feed_username": "Feed gebruikersnaam", "form.feed.label.feed_username": "Feed-gebruikersnaam",
"form.feed.label.feed_password": "Feed wachtwoord", "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.cookie": "Cookies instellen",
"form.feed.label.scraper_rules": "Extractieregels", "form.feed.label.scraper_rules": "Scraper regels",
"form.feed.label.rewrite_rules": "Herschrijfregels", "form.feed.label.rewrite_rules": "Rewrite regels",
"form.feed.label.blocklist_rules": "Blokkeerregels", "form.feed.label.blocklist_rules": "Blokkeer regels",
"form.feed.label.keeplist_rules": "Bewaarregels", "form.feed.label.keeplist_rules": "toestemmingsregels",
"form.feed.label.urlrewrite_rules": "Herschrijfregels voor URL's", "form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's", "form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache", "form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan", "form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen", "form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy", "form.feed.label.fetch_via_proxy": "Ophalen via proxy",
"form.feed.label.disabled": "Deze feed niet vernieuwen", "form.feed.label.disabled": "Vernieuw deze feed niet",
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)", "form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst", "form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen", "form.feed.fieldset.general": "General",
"form.feed.label.ntfy_priority": "Ntfy prioriteit", "form.feed.fieldset.rules": "Rules",
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit", "form.feed.fieldset.network_settings": "Network Settings",
"form.feed.label.ntfy_high_priority": "Ntfy hoge prioriteit", "form.feed.fieldset.integration": "Third-Party Services",
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit", "form.category.label.title": "Naam",
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit", "form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
"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.username": "Gebruikersnaam",
"form.user.label.password": "Wachtwoord", "form.user.label.password": "Wachtwoord",
"form.user.label.confirmation": "Bevestig wachtwoord", "form.user.label.confirmation": "Bevestig wachtwoord",
"form.user.label.admin": "Beheerder", "form.user.label.admin": "Administrator",
"form.prefs.label.language": "Taal", "form.prefs.label.language": "Taal",
"form.prefs.label.timezone": "Tijdzone", "form.prefs.label.timezone": "Tijdzone",
"form.prefs.label.theme": "Thema", "form.prefs.label.theme": "Skin",
"form.prefs.label.entry_sorting": "Volgorde van artikelen", "form.prefs.label.entry_sorting": "Volgorde van items",
"form.prefs.label.entries_per_page": "Artikelen per pagina", "form.prefs.label.entries_per_page": "Inzendingen per pagina",
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)", "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.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).", "form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
"form.prefs.select.older_first": "Oudere artikelen eerst", "form.prefs.select.older_first": "Oudere items eerst",
"form.prefs.select.recent_first": "Recente artikelen eerst", "form.prefs.select.recent_first": "Recente items eerst",
"form.prefs.select.fullscreen": "Volledig scherm", "form.prefs.select.fullscreen": "Volledig scherm",
"form.prefs.select.standalone": "Standalone", "form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimaal", "form.prefs.select.minimal_ui": "Minimaal",
"form.prefs.select.browser": "Browser", "form.prefs.select.browser": "Browser",
"form.prefs.select.publish_time": "Tijdstip van publiceren artikel", "form.prefs.select.publish_time": "Tijd van binnenkomst",
"form.prefs.select.created_time": "Tijdstip van aanmaken artikel", "form.prefs.select.created_time": "Tijdstip van binnenkomst",
"form.prefs.select.alphabetical": "Alfabetisch", "form.prefs.select.alphabetical": "Alfabetisch",
"form.prefs.select.unread_count": "Aantal ongelezen artikelen", "form.prefs.select.unread_count": "Ongelezen tellen",
"form.prefs.select.none": "Geen", "form.prefs.select.none": "Geen",
"form.prefs.select.tap": "Dubbeltik", "form.prefs.select.tap": "Dubbeltik",
"form.prefs.select.swipe": "Vegen", "form.prefs.select.swipe": "Vegen",
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen", "form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen", "form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren", "form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen", "form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
"form.prefs.label.custom_css": "Aangepaste CSS", "form.prefs.label.custom_css": "Aangepaste CSS",
"form.prefs.label.entry_order": "Artikelen sorteren", "form.prefs.label.entry_order": "Ingang Sorteerkolom",
"form.prefs.label.default_home_page": "Startpagina", "form.prefs.label.default_home_page": "Standaard startpagina",
"form.prefs.label.categories_sorting_order": "Volgorde categorieën", "form.prefs.label.categories_sorting_order": "Categorieën sorteren",
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken", "form.prefs.label.mark_read_on_view": "Items automatisch markeren 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.fieldset.application_settings": "Application Settings",
"form.prefs.label.mark_read_on_media_completion": "Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.label.mark_read_manually": "Markeer artikelen handmatig als gelezen", "form.prefs.fieldset.reader_settings": "Reader Settings",
"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.file": "OPML-bestand",
"form.import.label.url": "URL", "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_activate": "Activeer Fever API",
"form.integration.fever_username": "Fever gebruikersnaam", "form.integration.fever_username": "Fever gebruikersnaam",
"form.integration.fever_password": "Fever wachtwoord", "form.integration.fever_password": "Fever wachtwoord",
@ -414,102 +385,91 @@
"form.integration.googlereader_activate": "Activeer Google Reader API", "form.integration.googlereader_activate": "Activeer Google Reader API",
"form.integration.googlereader_username": "Google Reader gebruikersnaam", "form.integration.googlereader_username": "Google Reader gebruikersnaam",
"form.integration.googlereader_password": "Google Reader wachtwoord", "form.integration.googlereader_password": "Google Reader wachtwoord",
"form.integration.googlereader_endpoint": "Google Reader API-endpoint:", "form.integration.googlereader_endpoint": "Google Reader URL:",
"form.integration.pinboard_activate": "Artikelen opslaan in Pinboard", "form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
"form.integration.pinboard_token": "Pinboard API token", "form.integration.pinboard_token": "Pinboard API token",
"form.integration.pinboard_tags": "Pinboard tags", "form.integration.pinboard_tags": "Pinboard tags",
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen", "form.integration.pinboard_bookmark": "Markeer bookmark als gelezen",
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper", "form.integration.instapaper_activate": "Artikelen opstaan naar Instapaper",
"form.integration.instapaper_username": "Instapaper gebruikersnaam", "form.integration.instapaper_username": "Instapaper gebruikersnaam",
"form.integration.instapaper_password": "Instapaper wachtwoord", "form.integration.instapaper_password": "Instapaper wachtwoord",
"form.integration.pocket_activate": "Artikelen opslaan in Pocket", "form.integration.pocket_activate": "Bewaar artikelen in Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key", "form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Pocket Access Token", "form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Verbind je Pocket-account", "form.integration.pocket_connect_link": "Verbind je Pocket-account",
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag", "form.integration.wallabag_activate": "Opslaan naar Wallabag",
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)", "form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.wallabag_endpoint": "Wallabag URL", "form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID", "form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client-Secret", "form.integration.wallabag_client_secret": "Wallabag Client-Secret",
"form.integration.wallabag_username": "Wallabag gebruikersnaam", "form.integration.wallabag_username": "Wallabag gebruikersnaam",
"form.integration.wallabag_password": "Wallabag wachtwoord", "form.integration.wallabag_password": "Wallabag wachtwoord",
"form.integration.notion_activate": "Artikelen opslaan in Notion", "form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID", "form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token", "form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Artikelen opslaan in Apprise", "form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL", "form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's", "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Artikelen opslaan in Nunux Keeper", "form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL", "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel", "form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
"form.integration.omnivore_activate": "Artikelen opslaan in Omnivore", "form.integration.omnivore_activate": "Opslaan naar Omnivore",
"form.integration.omnivore_url": "Omnivore URL", "form.integration.omnivore_url": "Omnivore URL",
"form.integration.omnivore_api_key": "Omnivore API-sleutel", "form.integration.omnivore_api_key": "Omnivore API-sleutel",
"form.integration.espial_activate": "Artikelen opslaan in Espial", "form.integration.espial_activate": "Opslaan naar Espial",
"form.integration.espial_endpoint": "Espial URL", "form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_api_key": "Espial API-sleutel", "form.integration.espial_api_key": "Espial API-sleutel",
"form.integration.espial_tags": "Espial tags", "form.integration.espial_tags": "Espial tags",
"form.integration.readwise_activate": "Artikelen opslaan in Readwise Reader", "form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token", "form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen", "form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Stuur nieuwe artikelen naar Telegram", "form.integration.telegram_bot_activate": "Push nieuwe artikelen naar Telegram-chat",
"form.integration.telegram_bot_token": "Bot token", "form.integration.telegram_bot_token": "Bot token",
"form.integration.telegram_chat_id": "Chat ID", "form.integration.telegram_chat_id": "Chat ID",
"form.integration.telegram_topic_id": "Topic ID", "form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Webpaginavoorbeeld uitschakelen", "form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Notificatie uitschakelen", "form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Knoppen uitschakelen", "form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce", "form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint", "form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API-sleutel", "form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce tags", "form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Koppeling als privé markeren", "form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen", "form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Artikelen opslaan in Linkding", "form.integration.linkding_activate": "Opslaan naar Linkding",
"form.integration.linkding_endpoint": "Linkding URL", "form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API-sleutel", "form.integration.linkding_api_key": "Linkding API-sleutel",
"form.integration.linkding_tags": "Linkding tags", "form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen", "form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden", "form.integration.linkwarden_activate": "Opslaan naar Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden URL", "form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel", "form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix", "form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
"form.integration.matrix_bot_user": "Matrix gebruikersnaam", "form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker", "form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
"form.integration.matrix_bot_url": "URL van de Matrix-server", "form.integration.matrix_bot_url": "URL van de Matrix-server",
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer", "form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
"form.integration.raindrop_activate": "Artikelen opslaan in Raindrop", "form.integration.readeck_activate": "Opslaan naar Readeck",
"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_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API-sleutel", "form.integration.readeck_api_key": "Readeck API-sleutel",
"form.integration.readeck_labels": "Readeck Labels", "form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)", "form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
"form.integration.shiori_activate": "Artikelen opslaan in Shiori", "form.integration.shiori_activate": "Opslaan naar Shiori",
"form.integration.shiori_endpoint": "Shiori URL", "form.integration.shiori_endpoint": "Shiori URL",
"form.integration.shiori_username": "Shiori gebruikersnaam", "form.integration.shiori_username": "Shiori gebruikersnaam",
"form.integration.shiori_password": "Shiori wachtwoord", "form.integration.shiori_password": "Shiori wachtwoord",
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli", "form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL", "form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret", "form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Webhook activeren", "form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL", "form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy", "form.api_key.label.description": "API-sleutellabel",
"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.loading": "Laden...",
"form.submit.saving": "Opslaan...", "form.submit.saving": "Opslaag...",
"time_elapsed.not_yet": "nog niet", "time_elapsed.not_yet": "in de toekomst",
"time_elapsed.yesterday": "gisteren", "time_elapsed.yesterday": "gisteren",
"time_elapsed.now": "minder dan een minuut geleden", "time_elapsed.now": "minder dan een minuut geleden",
"time_elapsed.minutes": [ "time_elapsed.minutes": [
@ -537,43 +497,32 @@
"%d jaar geleden" "%d jaar geleden"
], ],
"alert.too_many_feeds_refresh": [ "alert.too_many_feeds_refresh": [
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.", "You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen." "You have triggered too many feed refreshes. Please wait %d minutes before trying again."
], ],
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.", "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": "De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)", "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": "Kan de HTTP-body niet lezen: %v.", "error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "De HTTP-respons body is leeg.", "error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?", "error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.", "error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.", "error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v", "error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP-client-fout: %v.", "error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.", "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 heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.", "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": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?", "error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "De gevraagde bron is niet gevonden. Controleer de URL.", "error.http_resource_not_found": "The requested resource is not found. Please, verify the 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_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": "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_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": "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_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": "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_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": "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.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 fout: %v.", "error.database_error": "Database error: %v.",
"error.category_not_found": "Deze categorie bestaat niet of hoort niet bij deze gebruiker.", "error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "Deze feed bestaat al.", "error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Kan deze feed niet verwerken: %v.", "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.", "error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Kan feed niet detecteren met RSS-Bridge: %v.", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
"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"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane", "menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
"menu.show_all_entries": "Pokaż wszystkie artykuły", "menu.show_all_entries": "Pokaż wszystkie artykuły",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane 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_feed": "Odśwież",
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle", "menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"menu.edit_feed": "Edytuj", "menu.edit_feed": "Edytuj",
@ -56,9 +55,7 @@
"search.label": "Szukaj", "search.label": "Szukaj",
"search.placeholder": "Szukaj...", "search.placeholder": "Szukaj...",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Ostatni",
"pagination.next": "Następny", "pagination.next": "Następny",
"pagination.first": "Pierwszy",
"pagination.previous": "Poprzedni", "pagination.previous": "Poprzedni",
"entry.status.unread": "Nieprzeczytane", "entry.status.unread": "Nieprzeczytane",
"entry.status.read": "Przeczytane", "entry.status.read": "Przeczytane",
@ -190,8 +187,6 @@
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji", "page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony", "page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony", "page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do najwyższego elementu",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł", "page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł", "page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie", "page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
@ -220,8 +215,8 @@
"page.settings.title": "Ustawienia", "page.settings.title": "Ustawienia",
"page.settings.link_google_account": "Połącz z moim kontem Google", "page.settings.link_google_account": "Połącz z moim kontem Google",
"page.settings.unlink_google_account": "Odłącz moje konto Google", "page.settings.unlink_google_account": "Odłącz moje konto Google",
"page.settings.link_oidc_account": "Połącz z moim kontem %s", "page.settings.link_oidc_account": "Połącz z moim kontem OpenID Connect",
"page.settings.unlink_oidc_account": "Odłącz moje konto %s", "page.settings.unlink_oidc_account": "Odłącz moje konto OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -236,7 +231,7 @@
], ],
"page.login.title": "Zaloguj się", "page.login.title": "Zaloguj się",
"page.login.google_signin": "Zaloguj przez Google", "page.login.google_signin": "Zaloguj przez Google",
"page.login.oidc_signin": "Zaloguj przez %s", "page.login.oidc_signin": "Zaloguj przez OpenID Connect",
"page.login.webauthn_login": "Zaloguj się za pomocą hasła", "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.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
"page.integrations.title": "Usługi", "page.integrations.title": "Usługi",
@ -271,7 +266,6 @@
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.", "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!", "alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów", "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_entry": "Nie ma artykułu dla tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.", "alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.", "alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
@ -306,14 +300,6 @@
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.", "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_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_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.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.", "error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.feed_already_exists": "Ten kanał już istnieje.", "error.feed_already_exists": "Ten kanał już istnieje.",
@ -338,7 +324,6 @@
"form.feed.label.title": "Tytuł", "form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony", "form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału", "form.feed.label.feed_url": "URL kanału",
"form.feed.label.description": "Opis",
"form.feed.label.category": "Kategoria", "form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść", "form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika", "form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
@ -358,13 +343,6 @@
"form.feed.label.disabled": "Nie odświeżaj tego kanału", "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.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych", "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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -405,18 +383,11 @@
"form.prefs.label.default_home_page": "Domyślna strona główna", "form.prefs.label.default_home_page": "Domyślna strona główna",
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii", "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": "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "Plik OPML",
"form.import.label.url": "URL", "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_activate": "Aktywuj Fever API",
"form.integration.fever_username": "Login do Fever", "form.integration.fever_username": "Login do Fever",
"form.integration.fever_password": "Hasło do Fever", "form.integration.fever_password": "Hasło do Fever",
@ -488,10 +459,6 @@
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix", "form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix", "form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju 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_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL", "form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key", "form.integration.readeck_api_key": "Readeck API key",
@ -509,13 +476,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie...", "form.submit.loading": "Ładowanie...",
"form.submit.saving": "Zapisywanie...", "form.submit.saving": "Zapisywanie...",
@ -562,7 +522,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -581,16 +541,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %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",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Marcar todos como lido", "menu.mark_all_as_read": "Marcar todos como lido",
"menu.show_all_entries": "Mostrar todas os itens", "menu.show_all_entries": "Mostrar todas os itens",
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos", "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_feed": "Atualizar",
"menu.refresh_all_feeds": "Atualizar todas as fontes", "menu.refresh_all_feeds": "Atualizar todas as fontes",
"menu.edit_feed": "Editar", "menu.edit_feed": "Editar",
@ -56,9 +55,7 @@
"search.label": "Buscar", "search.label": "Buscar",
"search.placeholder": "Buscar por...", "search.placeholder": "Buscar por...",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Próximo", "pagination.next": "Próximo",
"pagination.first": "First",
"pagination.previous": "Anterior", "pagination.previous": "Anterior",
"entry.status.unread": "Não lido", "entry.status.unread": "Não lido",
"entry.status.read": "Lido", "entry.status.read": "Lido",
@ -181,8 +178,6 @@
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte", "page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior", "page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte", "page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
"page.keyboard_shortcuts.go_to_bottom_item": "Ir para o item inferior",
"page.keyboard_shortcuts.go_to_top_item": "Ir para o item superior",
"page.keyboard_shortcuts.open_item": "Abrir o item selecionado", "page.keyboard_shortcuts.open_item": "Abrir o item selecionado",
"page.keyboard_shortcuts.open_original": "Abrir o conteúdo original", "page.keyboard_shortcuts.open_original": "Abrir o conteúdo original",
"page.keyboard_shortcuts.open_original_same_window": "Abrir o conteúdo original na janela atual", "page.keyboard_shortcuts.open_original_same_window": "Abrir o conteúdo original na janela atual",
@ -211,8 +206,8 @@
"page.settings.title": "Ajustes", "page.settings.title": "Ajustes",
"page.settings.link_google_account": "Vincular minha conta do Google", "page.settings.link_google_account": "Vincular minha conta do Google",
"page.settings.unlink_google_account": "Desvincular minha conta do Google", "page.settings.unlink_google_account": "Desvincular minha conta do Google",
"page.settings.link_oidc_account": "Vincular minha conta do %s", "page.settings.link_oidc_account": "Vincular minha conta do OpenID Connect",
"page.settings.unlink_oidc_account": "Desvincular minha conta do %s", "page.settings.unlink_oidc_account": "Desvincular minha conta do OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -226,7 +221,7 @@
], ],
"page.login.title": "Iniciar Sessão", "page.login.title": "Iniciar Sessão",
"page.login.google_signin": "Iniciar Sessão com sua conta do Google", "page.login.google_signin": "Iniciar Sessão com sua conta do Google",
"page.login.oidc_signin": "Iniciar Sessão com sua conta do %s", "page.login.oidc_signin": "Iniciar Sessão com sua conta do OpenID Connect",
"page.login.webauthn_login": "Entrar com senha", "page.login.webauthn_login": "Entrar com senha",
"page.login.webauthn_login.error": "Não é possível fazer login com senha", "page.login.webauthn_login.error": "Não é possível fazer login com senha",
"page.integrations.title": "Integrações", "page.integrations.title": "Integrações",
@ -261,7 +256,6 @@
"alert.no_bookmark": "Não há favorito neste momento.", "alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.", "alert.no_category": "Não há categoria.",
"alert.no_category_entry": "Não há itens nesta 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_entry": "Não há itens nessa fonte.",
"alert.no_feed": "Não há inscrições.", "alert.no_feed": "Não há inscrições.",
"alert.no_feed_in_category": "Não há inscrições nessa categoria.", "alert.no_feed_in_category": "Não há inscrições nessa categoria.",
@ -296,14 +290,6 @@
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.", "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_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_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.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_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
"error.feed_already_exists": "Este feed já existe.", "error.feed_already_exists": "Este feed já existe.",
@ -328,7 +314,6 @@
"form.feed.label.title": "Título", "form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site", "form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte", "form.feed.label.feed_url": "URL da fonte",
"form.feed.label.description": "Descrição",
"form.feed.label.category": "Categoria", "form.feed.label.category": "Categoria",
"form.feed.label.crawler": "Obter conteúdo original", "form.feed.label.crawler": "Obter conteúdo original",
"form.feed.label.feed_username": "Nome de usuário da fonte", "form.feed.label.feed_username": "Nome de usuário da fonte",
@ -348,13 +333,6 @@
"form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.fetch_via_proxy": "Buscar via proxy", "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.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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -395,18 +373,11 @@
"form.prefs.label.default_home_page": "Página inicial predefinida", "form.prefs.label.default_home_page": "Página inicial predefinida",
"form.prefs.label.categories_sorting_order": "Classificação das categorias", "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": "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "Arquivo OPML",
"form.import.label.url": "URL", "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_activate": "Ativar API do Fever",
"form.integration.fever_username": "Nome de usuário do Fever", "form.integration.fever_username": "Nome de usuário do Fever",
"form.integration.fever_password": "Senha do Fever", "form.integration.fever_password": "Senha do Fever",
@ -478,10 +449,6 @@
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix", "form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
"form.integration.matrix_bot_url": "URL do servidor Matrix", "form.integration.matrix_bot_url": "URL do servidor Matrix",
"form.integration.matrix_bot_chat_id": "Identificação da sala 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_activate": "Salvar itens no Readeck",
"form.integration.readeck_endpoint": "Endpoint de API do Readeck", "form.integration.readeck_endpoint": "Endpoint de API do Readeck",
"form.integration.readeck_api_key": "Chave de API do Readeck", "form.integration.readeck_api_key": "Chave de API do Readeck",
@ -499,13 +466,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Etiqueta da chave de API",
"form.submit.loading": "Carregando...", "form.submit.loading": "Carregando...",
"form.submit.saving": "Salvando...", "form.submit.saving": "Salvando...",
@ -545,7 +505,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -564,16 +524,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %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",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Отметить всё как прочитанное", "menu.mark_all_as_read": "Отметить всё как прочитанное",
"menu.show_all_entries": "Показать все статьи", "menu.show_all_entries": "Показать все статьи",
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи", "menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
"menu.show_only_starred_entries": "Показывать только избранные статьи",
"menu.refresh_feed": "Обновить", "menu.refresh_feed": "Обновить",
"menu.refresh_all_feeds": "Обновить все подписки в фоне", "menu.refresh_all_feeds": "Обновить все подписки в фоне",
"menu.edit_feed": "Изменить", "menu.edit_feed": "Изменить",
@ -56,9 +55,7 @@
"search.label": "Поиск", "search.label": "Поиск",
"search.placeholder": "Поиск…", "search.placeholder": "Поиск…",
"search.submit": "Search", "search.submit": "Search",
"pagination.last": "Last",
"pagination.next": "Следующая", "pagination.next": "Следующая",
"pagination.first": "First",
"pagination.previous": "Предыдущая", "pagination.previous": "Предыдущая",
"entry.status.unread": "Не прочитано", "entry.status.unread": "Не прочитано",
"entry.status.read": "Прочитано", "entry.status.read": "Прочитано",
@ -190,8 +187,6 @@
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке", "page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице", "page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице", "page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти к нижнему элементу",
"page.keyboard_shortcuts.go_to_top_item": "Перейти к верхнему элементу",
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент", "page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке", "page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку", "page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
@ -220,8 +215,8 @@
"page.settings.title": "Настройки", "page.settings.title": "Настройки",
"page.settings.link_google_account": "Привязать мой Google аккаунт", "page.settings.link_google_account": "Привязать мой Google аккаунт",
"page.settings.unlink_google_account": "Отвязать мой Google аккаунт", "page.settings.unlink_google_account": "Отвязать мой Google аккаунт",
"page.settings.link_oidc_account": "Привязать мой %s аккаунт", "page.settings.link_oidc_account": "Привязать мой OpenID Connect аккаунт",
"page.settings.unlink_oidc_account": "Отвязать мой %s аккаунт", "page.settings.unlink_oidc_account": "Отвязать мой OpenID Connect аккаунт",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -236,7 +231,7 @@
], ],
"page.login.title": "Войти", "page.login.title": "Войти",
"page.login.google_signin": "Войти с помощью Google", "page.login.google_signin": "Войти с помощью Google",
"page.login.oidc_signin": "Войти с помощью %s", "page.login.oidc_signin": "Войти с помощью OpenID Connect",
"page.login.webauthn_login": "Войти с паролем", "page.login.webauthn_login": "Войти с паролем",
"page.login.webauthn_login.error": "Невозможно войти с паролем", "page.login.webauthn_login.error": "Невозможно войти с паролем",
"page.integrations.title": "Интеграции", "page.integrations.title": "Интеграции",
@ -271,7 +266,6 @@
"alert.no_bookmark": "Избранное отсутствует.", "alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.", "alert.no_category": "Категории отсутствуют.",
"alert.no_category_entry": "В этой категории нет статей.", "alert.no_category_entry": "В этой категории нет статей.",
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.", "alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
"alert.no_feed": "У вас нет ни одной подписки.", "alert.no_feed": "У вас нет ни одной подписки.",
"alert.no_feed_in_category": "Для этой категории нет подписки.", "alert.no_feed_in_category": "Для этой категории нет подписки.",
@ -306,14 +300,6 @@
"error.password_min_length": "Вы должны использовать минимум 6 символов.", "error.password_min_length": "Вы должны использовать минимум 6 символов.",
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.", "error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
"error.settings_reading_speed_is_positive": "Скорость чтения должна быть целым положительным числом.", "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.entries_per_page_invalid": "Недопустимое значение количества записей на странице.",
"error.feed_mandatory_fields": "Ссылка и категория обязательны.", "error.feed_mandatory_fields": "Ссылка и категория обязательны.",
"error.feed_already_exists": "Эта подписка уже существует.", "error.feed_already_exists": "Эта подписка уже существует.",
@ -338,7 +324,6 @@
"form.feed.label.title": "Название", "form.feed.label.title": "Название",
"form.feed.label.site_url": "Адрес сайта", "form.feed.label.site_url": "Адрес сайта",
"form.feed.label.feed_url": "Адрес подписки", "form.feed.label.feed_url": "Адрес подписки",
"form.feed.label.description": "Описание",
"form.feed.label.category": "Категория", "form.feed.label.category": "Категория",
"form.feed.label.crawler": "Извлечь оригинальное содержимое", "form.feed.label.crawler": "Извлечь оригинальное содержимое",
"form.feed.label.feed_username": "Имя пользователя подписки", "form.feed.label.feed_username": "Имя пользователя подписки",
@ -358,13 +343,6 @@
"form.feed.label.disabled": "Не обновлять эту подписку", "form.feed.label.disabled": "Не обновлять эту подписку",
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)", "form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
"form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных", "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.general": "General",
"form.feed.fieldset.rules": "Rules", "form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings", "form.feed.fieldset.network_settings": "Network Settings",
@ -405,18 +383,11 @@
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию", "form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
"form.prefs.label.categories_sorting_order": "Сортировка категорий", "form.prefs.label.categories_sorting_order": "Сортировка категорий",
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре", "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "OPML файл",
"form.import.label.url": "Ссылка", "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_activate": "Активировать Fever API",
"form.integration.fever_username": "Имя пользователя Fever", "form.integration.fever_username": "Имя пользователя Fever",
"form.integration.fever_password": "Пароль Fever", "form.integration.fever_password": "Пароль Fever",
@ -488,10 +459,6 @@
"form.integration.matrix_bot_password": "Пароль пользователя Matrix", "form.integration.matrix_bot_password": "Пароль пользователя Matrix",
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix", "form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
"form.integration.matrix_bot_chat_id": "ID комнаты 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_activate": "Сохранять статьи в Readeck",
"form.integration.readeck_endpoint": "Конечная точка Readeck API", "form.integration.readeck_endpoint": "Конечная точка Readeck API",
"form.integration.readeck_api_key": "API-ключ Readeck", "form.integration.readeck_api_key": "API-ключ Readeck",
@ -509,13 +476,6 @@
"form.integration.webhook_secret": "Секретный ключ для вебхуков", "form.integration.webhook_secret": "Секретный ключ для вебхуков",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Описание API-ключа",
"form.submit.loading": "Загрузка…", "form.submit.loading": "Загрузка…",
"form.submit.saving": "Сохранение…", "form.submit.saving": "Сохранение…",
@ -562,7 +522,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -581,16 +541,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
"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"
} }

File diff suppressed because it is too large Load Diff

View File

@ -19,8 +19,8 @@
"action.home_screen": "Додати до головного екрану", "action.home_screen": "Додати до головного екрану",
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s", "tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
"tooltip.logged_user": "Здійснено вхід як %s", "tooltip.logged_user": "Здійснено вхід як %s",
"menu.title": "Меню", "menu.title": "Menu",
"menu.home_page": "Головна сторінка", "menu.home_page": "Home page",
"menu.unread": "Непрочитане", "menu.unread": "Непрочитане",
"menu.starred": "З зірочкою", "menu.starred": "З зірочкою",
"menu.history": "Історія", "menu.history": "Історія",
@ -41,7 +41,6 @@
"menu.mark_all_as_read": "Відмітити все як прочитане", "menu.mark_all_as_read": "Відмітити все як прочитане",
"menu.show_all_entries": "Показати всі записи", "menu.show_all_entries": "Показати всі записи",
"menu.show_only_unread_entries": "Показати тільки непрочитані записи", "menu.show_only_unread_entries": "Показати тільки непрочитані записи",
"menu.show_only_starred_entries": "Показати тільки записи з зірочкою",
"menu.refresh_feed": "Оновити", "menu.refresh_feed": "Оновити",
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі", "menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
"menu.edit_feed": "Редагувати", "menu.edit_feed": "Редагувати",
@ -55,11 +54,9 @@
"menu.shared_entries": "Спільні записи", "menu.shared_entries": "Спільні записи",
"search.label": "Пошук", "search.label": "Пошук",
"search.placeholder": "Шукати...", "search.placeholder": "Шукати...",
"search.submit": "Знайти", "search.submit": "Search",
"pagination.last": "Остання", "pagination.next": "Вперед",
"pagination.next": "Наступна", "pagination.previous": "Назад",
"pagination.first": "Перша",
"pagination.previous": "Попередня",
"entry.status.unread": "Непрочитане", "entry.status.unread": "Непрочитане",
"entry.status.read": "Прочитане", "entry.status.read": "Прочитане",
"entry.status.toast.unread": "Відмічено непрочитаним", "entry.status.toast.unread": "Відмічено непрочитаним",
@ -92,7 +89,7 @@
"читати %d хвилин" "читати %d хвилин"
], ],
"entry.tags.label": "Теги:", "entry.tags.label": "Теги:",
"page.shared_entries.title": "Спільні записи", "page.shared_entries.title": "Спильні записи",
"page.shared_entries_count": [ "page.shared_entries_count": [
"%d shared entry", "%d shared entry",
"%d shared entries", "%d shared entries",
@ -134,9 +131,9 @@
"page.edit_category.title": "Редагування категорії: %s", "page.edit_category.title": "Редагування категорії: %s",
"page.edit_user.title": "Редагування користувача: %s", "page.edit_user.title": "Редагування користувача: %s",
"page.feeds.title": "Стрічки", "page.feeds.title": "Стрічки",
"page.category_label": "Категорія: %s", "page.category_label": "Category: %s",
"page.feeds.last_check": "Остання перевірка:", "page.feeds.last_check": "Остання перевірка:",
"page.feeds.next_check": "Наступна перевірка:", "page.feeds.next_check": "Next check:",
"page.feeds.read_counter": "Кількість прочитаних записів", "page.feeds.read_counter": "Кількість прочитаних записів",
"page.feeds.error_count": [ "page.feeds.error_count": [
"%d помилка", "%d помилка",
@ -190,8 +187,6 @@
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки", "page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки", "page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки", "page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти до нижнього пункту",
"page.keyboard_shortcuts.go_to_top_item": "Перейти до верхнього пункту",
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис", "page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання", "page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці", "page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
@ -220,8 +215,8 @@
"page.settings.title": "Налаштування ", "page.settings.title": "Налаштування ",
"page.settings.link_google_account": "Підключити мій обліковий запис Google", "page.settings.link_google_account": "Підключити мій обліковий запис Google",
"page.settings.unlink_google_account": "Відключити мій обліковий запис Google", "page.settings.unlink_google_account": "Відключити мій обліковий запис Google",
"page.settings.link_oidc_account": "Підключити мій обліковий запис %s", "page.settings.link_oidc_account": "Підключити мій обліковий запис OpenID Connect",
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис %s", "page.settings.unlink_oidc_account": "Відключити мій обліковий запис OpenID Connect",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions", "page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name", "page.settings.webauthn.passkey_name": "Passkey Name",
@ -236,7 +231,7 @@
], ],
"page.login.title": "Вхід", "page.login.title": "Вхід",
"page.login.google_signin": "Увійти через Google", "page.login.google_signin": "Увійти через Google",
"page.login.oidc_signin": "Увійти через %s", "page.login.oidc_signin": "Увійти через OpenID Connect",
"page.login.webauthn_login": "Увійти за допомогою пароля", "page.login.webauthn_login": "Увійти за допомогою пароля",
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу", "page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
"page.integrations.title": "Інтеграції", "page.integrations.title": "Інтеграції",
@ -271,7 +266,6 @@
"alert.no_bookmark": "Наразі закладки відсутні.", "alert.no_bookmark": "Наразі закладки відсутні.",
"alert.no_category": "Немає категорії.", "alert.no_category": "Немає категорії.",
"alert.no_category_entry": "У цій категорії немає записів.", "alert.no_category_entry": "У цій категорії немає записів.",
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
"alert.no_feed_entry": "У цій стрічці немає записів.", "alert.no_feed_entry": "У цій стрічці немає записів.",
"alert.no_feed": "У вас немає підписок.", "alert.no_feed": "У вас немає підписок.",
"alert.no_feed_in_category": "У цій категорії немає підписок.", "alert.no_feed_in_category": "У цій категорії немає підписок.",
@ -313,14 +307,6 @@
"error.password_min_length": "Пароль має складати щонайменше 6 символів.", "error.password_min_length": "Пароль має складати щонайменше 6 символів.",
"error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.", "error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.",
"error.settings_reading_speed_is_positive": "Швидкість читання має бути додатнім цілим числом.", "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.entries_per_page_invalid": "Число записів на сторінку недійсне.",
"error.feed_mandatory_fields": "URL та категорія є обов’язковими.", "error.feed_mandatory_fields": "URL та категорія є обов’язковими.",
"error.feed_already_exists": "Така стрічка вже існує.", "error.feed_already_exists": "Така стрічка вже існує.",
@ -338,7 +324,6 @@
"form.feed.label.title": "Назва", "form.feed.label.title": "Назва",
"form.feed.label.site_url": "URL-адреса сайту", "form.feed.label.site_url": "URL-адреса сайту",
"form.feed.label.feed_url": "URL-адреса стрічки", "form.feed.label.feed_url": "URL-адреса стрічки",
"form.feed.label.description": "Опис",
"form.feed.label.category": "Категорія", "form.feed.label.category": "Категорія",
"form.feed.label.crawler": "Завантажувати оригінальний вміст", "form.feed.label.crawler": "Завантажувати оригінальний вміст",
"form.feed.label.feed_username": "Ім’я користувача для завантаження", "form.feed.label.feed_username": "Ім’я користувача для завантаження",
@ -358,13 +343,6 @@
"form.feed.label.disabled": "Не оновлювати цю стрічку", "form.feed.label.disabled": "Не оновлювати цю стрічку",
"form.feed.label.no_media_player": "No media player (audio/video)", "form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного", "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.label.title": "Назва",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного", "form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.feed.fieldset.general": "General", "form.feed.fieldset.general": "General",
@ -405,18 +383,11 @@
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням", "form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
"form.prefs.label.categories_sorting_order": "Сортування за категоріями", "form.prefs.label.categories_sorting_order": "Сортування за категоріями",
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду", "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.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings", "form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader 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.file": "Файл OPML",
"form.import.label.url": "URL-адреса", "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_activate": "Увімкнути API Fever",
"form.integration.fever_username": "Ім’я користувача Fever", "form.integration.fever_username": "Ім’я користувача Fever",
"form.integration.fever_password": "Пароль Fever", "form.integration.fever_password": "Пароль Fever",
@ -488,10 +459,6 @@
"form.integration.matrix_bot_password": "Пароль для користувача Matrix", "form.integration.matrix_bot_password": "Пароль для користувача Matrix",
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці", "form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці", "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_activate": "Зберігати статті до Readeck",
"form.integration.readeck_endpoint": "Readeck API Endpoint", "form.integration.readeck_endpoint": "Readeck API Endpoint",
"form.integration.readeck_api_key": "Ключ API Readeck", "form.integration.readeck_api_key": "Ключ API Readeck",
@ -509,13 +476,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions", "form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL", "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.api_key.label.description": "Назва ключа API",
"form.submit.loading": "Завантаження...", "form.submit.loading": "Завантаження...",
"form.submit.saving": "Зберігаю...", "form.submit.saving": "Зберігаю...",
@ -562,7 +522,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -581,16 +541,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -1,5 +1,5 @@
{ {
"skip_to_content": "跳转至内容", "skip_to_content": "Skip to content",
"confirm.question": "您确认吗?", "confirm.question": "您确认吗?",
"confirm.question.refresh": "您是否要强制刷新?", "confirm.question.refresh": "您是否要强制刷新?",
"confirm.yes": "是", "confirm.yes": "是",
@ -19,8 +19,8 @@
"action.home_screen": "添加到主屏幕", "action.home_screen": "添加到主屏幕",
"tooltip.keyboard_shortcuts": "快捷键: %s", "tooltip.keyboard_shortcuts": "快捷键: %s",
"tooltip.logged_user": "当前登录 %s", "tooltip.logged_user": "当前登录 %s",
"menu.title": "菜单", "menu.title": "Menu",
"menu.home_page": "首页", "menu.home_page": "Home page",
"menu.unread": "未读", "menu.unread": "未读",
"menu.starred": "收藏", "menu.starred": "收藏",
"menu.history": "历史", "menu.history": "历史",
@ -41,7 +41,6 @@
"menu.mark_all_as_read": "全部标为已读", "menu.mark_all_as_read": "全部标为已读",
"menu.show_all_entries": "显示所有文章", "menu.show_all_entries": "显示所有文章",
"menu.show_only_unread_entries": "仅显示未读文章", "menu.show_only_unread_entries": "仅显示未读文章",
"menu.show_only_starred_entries": "仅显示已收藏文章",
"menu.refresh_feed": "更新", "menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "在后台更新全部源", "menu.refresh_all_feeds": "在后台更新全部源",
"menu.edit_feed": "编辑", "menu.edit_feed": "编辑",
@ -55,10 +54,8 @@
"menu.shared_entries": "已分享的文章", "menu.shared_entries": "已分享的文章",
"search.label": "搜索", "search.label": "搜索",
"search.placeholder": "搜索…", "search.placeholder": "搜索…",
"search.submit": "查找", "search.submit": "Search",
"pagination.last": "最后一页",
"pagination.next": "下一页", "pagination.next": "下一页",
"pagination.first": "第一页",
"pagination.previous": "上一页", "pagination.previous": "上一页",
"entry.status.unread": "标为未读", "entry.status.unread": "标为未读",
"entry.status.read": "标为已读", "entry.status.read": "标为已读",
@ -92,18 +89,18 @@
"entry.tags.label": "标签:", "entry.tags.label": "标签:",
"page.shared_entries.title": "已分享的文章", "page.shared_entries.title": "已分享的文章",
"page.shared_entries_count": [ "page.shared_entries_count": [
"%d 已分享的文章" "%d shared entry"
], ],
"page.unread.title": "未读", "page.unread.title": "未读",
"page.unread_entry_count": [ "page.unread_entry_count": [
"%d 未读的文章" "%d unread entry"
], ],
"page.total_entry_count": [ "page.total_entry_count": [
"%d 文章总数" "%d entry in total"
], ],
"page.starred.title": "收藏", "page.starred.title": "收藏",
"page.starred_entry_count": [ "page.starred_entry_count": [
"%d 收藏的文章" "%d starred entry"
], ],
"page.categories.title": "分类", "page.categories.title": "分类",
"page.categories.no_feed": "没有源", "page.categories.no_feed": "没有源",
@ -113,14 +110,14 @@
"有 %d 个源" "有 %d 个源"
], ],
"page.categories_count": [ "page.categories_count": [
"%d 分类" "%d category"
], ],
"page.new_category.title": "新分类", "page.new_category.title": "新分类",
"page.new_user.title": "新用户", "page.new_user.title": "新用户",
"page.edit_category.title": "编辑分类 : %s", "page.edit_category.title": "编辑分类 : %s",
"page.edit_user.title": "编辑用户 : %s", "page.edit_user.title": "编辑用户 : %s",
"page.feeds.title": "源", "page.feeds.title": "源",
"page.category_label": "分类: %s", "page.category_label": "Category: %s",
"page.feeds.last_check": "最后检查时间:", "page.feeds.last_check": "最后检查时间:",
"page.feeds.next_check": "下次检查时间:", "page.feeds.next_check": "下次检查时间:",
"page.feeds.read_counter": "已读文章数", "page.feeds.read_counter": "已读文章数",
@ -129,7 +126,7 @@
], ],
"page.history.title": "历史", "page.history.title": "历史",
"page.read_entry_count": [ "page.read_entry_count": [
"%d 阅读文章" "%d read entry"
], ],
"page.import.title": "导入", "page.import.title": "导入",
"page.search.title": "搜索结果", "page.search.title": "搜索结果",
@ -172,8 +169,6 @@
"page.keyboard_shortcuts.go_to_feed": "转到源页面", "page.keyboard_shortcuts.go_to_feed": "转到源页面",
"page.keyboard_shortcuts.go_to_previous_page": "上一页", "page.keyboard_shortcuts.go_to_previous_page": "上一页",
"page.keyboard_shortcuts.go_to_next_page": "下一页", "page.keyboard_shortcuts.go_to_next_page": "下一页",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.open_item": "打开选定的文章", "page.keyboard_shortcuts.open_item": "打开选定的文章",
"page.keyboard_shortcuts.open_original": "打开原始链接", "page.keyboard_shortcuts.open_original": "打开原始链接",
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接", "page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
@ -202,8 +197,8 @@
"page.settings.title": "设置", "page.settings.title": "设置",
"page.settings.link_google_account": "关联我的 Google 账户", "page.settings.link_google_account": "关联我的 Google 账户",
"page.settings.unlink_google_account": "解除 Google 账号关联", "page.settings.unlink_google_account": "解除 Google 账号关联",
"page.settings.link_oidc_account": "关联我的 %s 账户", "page.settings.link_oidc_account": "关联我的 OpenID Connect 账户",
"page.settings.unlink_oidc_account": "解除 %s 账号关联", "page.settings.unlink_oidc_account": "解除 OpenID Connect 账号关联",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "操作", "page.settings.webauthn.actions": "操作",
"page.settings.webauthn.passkey_name": "Passkey 名称", "page.settings.webauthn.passkey_name": "Passkey 名称",
@ -216,7 +211,7 @@
], ],
"page.login.title": "登录", "page.login.title": "登录",
"page.login.google_signin": "使用 Google 登录", "page.login.google_signin": "使用 Google 登录",
"page.login.oidc_signin": "使用 %s 登录", "page.login.oidc_signin": "使用 OpenID Connect 登录",
"page.login.webauthn_login": "使用密码登录", "page.login.webauthn_login": "使用密码登录",
"page.login.webauthn_login.error": "无法使用密码登录", "page.login.webauthn_login.error": "无法使用密码登录",
"page.integrations.title": "集成", "page.integrations.title": "集成",
@ -251,7 +246,6 @@
"alert.no_bookmark": "目前没有收藏", "alert.no_bookmark": "目前没有收藏",
"alert.no_category": "目前没有分类", "alert.no_category": "目前没有分类",
"alert.no_category_entry": "该分类下没有文章", "alert.no_category_entry": "该分类下没有文章",
"alert.no_tag_entry": "没有与此标签匹配的条目。",
"alert.no_feed_entry": "该源中没有文章", "alert.no_feed_entry": "该源中没有文章",
"alert.no_feed": "目前没有源", "alert.no_feed": "目前没有源",
"alert.no_history": "目前没有历史", "alert.no_history": "目前没有历史",
@ -294,14 +288,6 @@
"error.site_url_not_empty": "源网站的网址不能为空。", "error.site_url_not_empty": "源网站的网址不能为空。",
"error.feed_title_not_empty": "订阅源的标题不能为空。", "error.feed_title_not_empty": "订阅源的标题不能为空。",
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。", "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_category_not_found": "此类别不存在或不属于该用户。",
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。", "error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
"error.feed_invalid_keeplist_rule": "保留列表规则无效。", "error.feed_invalid_keeplist_rule": "保留列表规则无效。",
@ -318,7 +304,6 @@
"form.feed.label.title": "标题", "form.feed.label.title": "标题",
"form.feed.label.site_url": "源网站 URL", "form.feed.label.site_url": "源网站 URL",
"form.feed.label.feed_url": "订阅源 URL", "form.feed.label.feed_url": "订阅源 URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "类别", "form.feed.label.category": "类别",
"form.feed.label.crawler": "抓取全文内容", "form.feed.label.crawler": "抓取全文内容",
"form.feed.label.feed_username": "源用户名", "form.feed.label.feed_username": "源用户名",
@ -333,18 +318,11 @@
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表", "form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存", "form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书", "form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
"form.feed.label.disable_http2": "关闭 HTTP/2 避免记录指纹", "form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.fetch_via_proxy": "通过代理获取", "form.feed.label.fetch_via_proxy": "通过代理获取",
"form.feed.label.disabled": "请勿刷新此源", "form.feed.label.disabled": "请勿刷新此源",
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)", "form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
"form.feed.label.hide_globally": "隐藏全局未读列表中的文章", "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.general": "通用",
"form.feed.fieldset.rules": "规则", "form.feed.fieldset.rules": "规则",
"form.feed.fieldset.network_settings": "网络设置", "form.feed.fieldset.network_settings": "网络设置",
@ -385,18 +363,11 @@
"form.prefs.label.default_home_page": "默认主页", "form.prefs.label.default_home_page": "默认主页",
"form.prefs.label.categories_sorting_order": "分类排序", "form.prefs.label.categories_sorting_order": "分类排序",
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读", "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.application_settings": "应用设置",
"form.prefs.fieldset.authentication_settings": "用户认证设置", "form.prefs.fieldset.authentication_settings": "用户认证设置",
"form.prefs.fieldset.reader_settings": "阅读器设置", "form.prefs.fieldset.reader_settings": "阅读器设置",
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
"form.import.label.file": "OPML 文件", "form.import.label.file": "OPML 文件",
"form.import.label.url": "URL", "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_activate": "启用 Fever API",
"form.integration.fever_username": "Fever 用户名", "form.integration.fever_username": "Fever 用户名",
"form.integration.fever_password": "Fever 密码", "form.integration.fever_password": "Fever 密码",
@ -406,7 +377,7 @@
"form.integration.googlereader_password": "Google Reader 密码", "form.integration.googlereader_password": "Google Reader 密码",
"form.integration.googlereader_endpoint": "Google Reader API 端点:", "form.integration.googlereader_endpoint": "Google Reader API 端点:",
"form.integration.pinboard_activate": "保存文章到 Pinboard", "form.integration.pinboard_activate": "保存文章到 Pinboard",
"form.integration.pinboard_token": "Pinboard API 密钥", "form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard 标签", "form.integration.pinboard_tags": "Pinboard 标签",
"form.integration.pinboard_bookmark": "标记为未读", "form.integration.pinboard_bookmark": "标记为未读",
"form.integration.instapaper_activate": "保存文章到 Instapaper", "form.integration.instapaper_activate": "保存文章到 Instapaper",
@ -420,12 +391,12 @@
"form.integration.wallabag_only_url": "仅发送 URL而不是完整内容", "form.integration.wallabag_only_url": "仅发送 URL而不是完整内容",
"form.integration.wallabag_endpoint": "Wallabag URL", "form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag 客户端 ID", "form.integration.wallabag_client_id": "Wallabag 客户端 ID",
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥", "form.integration.wallabag_client_secret": "Wallabag 客户端 Secret",
"form.integration.wallabag_username": "Wallabag 用户名", "form.integration.wallabag_username": "Wallabag 用户名",
"form.integration.wallabag_password": "Wallabag 密码", "form.integration.wallabag_password": "Wallabag 密码",
"form.integration.notion_activate": "保存文章到 Notion", "form.integration.notion_activate": "保存文章到 Notion",
"form.integration.notion_page_id": "Notion 页面ID", "form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion 密钥", "form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "将新文章推送到 Apprise", "form.integration.apprise_activate": "将新文章推送到 Apprise",
"form.integration.apprise_url": "Apprise API URL", "form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表", "form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
@ -435,13 +406,13 @@
"form.integration.omnivore_activate": "保存文章到 Omnivore", "form.integration.omnivore_activate": "保存文章到 Omnivore",
"form.integration.omnivore_url": "Omnivore API 端点", "form.integration.omnivore_url": "Omnivore API 端点",
"form.integration.omnivore_api_key": "Omnivore API 密钥", "form.integration.omnivore_api_key": "Omnivore API 密钥",
"form.integration.espial_activate": "保存文章到 Espial", "form.integration.espial_activate": "保存文章到 Espial",
"form.integration.espial_endpoint": "Espial API 端点", "form.integration.espial_endpoint": "Espial API 端点",
"form.integration.espial_api_key": "Espial API 密钥", "form.integration.espial_api_key": "Espial API 密钥",
"form.integration.espial_tags": "Espial 标签", "form.integration.espial_tags": "Espial 标签",
"form.integration.readwise_activate": "保存文章到 Readwise Reader", "form.integration.readwise_activate": "保存文章到 Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader 密钥", "form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "获取你的 Readwise 密钥", "form.integration.readwise_api_key_link": "获取你的 Readwise Access Token",
"form.integration.telegram_bot_activate": "将新文章推送到 Telegram", "form.integration.telegram_bot_activate": "将新文章推送到 Telegram",
"form.integration.telegram_bot_token": "机器人令牌", "form.integration.telegram_bot_token": "机器人令牌",
"form.integration.telegram_topic_id": "Topic ID", "form.integration.telegram_topic_id": "Topic ID",
@ -449,12 +420,12 @@
"form.integration.telegram_bot_disable_notification": "禁用通知", "form.integration.telegram_bot_disable_notification": "禁用通知",
"form.integration.telegram_bot_disable_buttons": "不展示按钮", "form.integration.telegram_bot_disable_buttons": "不展示按钮",
"form.integration.telegram_chat_id": "聊天ID", "form.integration.telegram_chat_id": "聊天ID",
"form.integration.linkace_activate": "保存文章到 LinkAce", "form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API URL", "form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API 密钥", "form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce 标签", "form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "将链接标记为私有", "form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "关闭链接检查", "form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "保存文章到 Linkding", "form.integration.linkding_activate": "保存文章到 Linkding",
"form.integration.linkding_endpoint": "Linkding API 端点", "form.integration.linkding_endpoint": "Linkding API 端点",
"form.integration.linkding_api_key": "Linkding API 密钥", "form.integration.linkding_api_key": "Linkding API 密钥",
@ -468,10 +439,6 @@
"form.integration.matrix_bot_password": "Matrix Bot 密码", "form.integration.matrix_bot_password": "Matrix Bot 密码",
"form.integration.matrix_bot_url": "Matrix 服务器 URL", "form.integration.matrix_bot_url": "Matrix 服务器 URL",
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID", "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_activate": "保存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端点", "form.integration.readeck_endpoint": "Readeck API 端点",
"form.integration.readeck_api_key": "Readeck API 密钥", "form.integration.readeck_api_key": "Readeck API 密钥",
@ -483,19 +450,12 @@
"form.integration.shiori_password": "Shiori 密码", "form.integration.shiori_password": "Shiori 密码",
"form.integration.shaarli_activate": "保存文章到 Shaarli", "form.integration.shaarli_activate": "保存文章到 Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL", "form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API 密钥", "form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "启用 Webhook", "form.integration.webhook_activate": "启用 Webhook",
"form.integration.webhook_url": "Webhook URL", "form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook 密钥", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge", "form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge",
"form.integration.rssbridge_url": "RSS-Bridge 服务器 URL", "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.api_key.label.description": "API密钥标签",
"form.submit.loading": "载入中…", "form.submit.loading": "载入中…",
"form.submit.saving": "保存中…", "form.submit.saving": "保存中…",
@ -521,42 +481,31 @@
"%d 年前" "%d 年前"
], ],
"alert.too_many_feeds_refresh": [ "alert.too_many_feeds_refresh": [
"多次触发订阅源更新,请等待 %d 分钟后重试。" "You have triggered too many feed refreshes. Please wait %d minute before trying again."
], ],
"alert.background_feed_refresh": "所有的订阅源都在后台刷新中。您可以继续使用Miniflux同时此过程正在运行。", "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": "HTTP响应内容过大您可以在全局设置中增加HTTP响应大小限制需要服务器重新启动。", "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": "无法读取HTTP主体: %v。", "error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "HTTP响应主体为空。", "error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "HTTP响应内容为空该网站可能正在使用机器人保护机制。", "error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭TLS验证。", "error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux无法访问该网站由于网络错误: %v。", "error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "该网站响应过慢,请求超时: %v", "error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP 客户端错误r: %v。", "error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "该网站访问未授权,可能用户名和密码错误。", "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 对该网站请求过多次数,请稍后重试或修改应用配置项。", "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": "该网站被禁止访问,网站可能有机器人保护机制?", "error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "请求资源无法找到请检查URL。", "error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "当前由于服务器错误导致该网站无法访问问题不在Miniflux请稍后重试。", "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": "当前由于错误的网关导致该网站无法访问问题不在Miniflux请稍后重试。", "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": "当前由于服务器内部错误导致该网站无法访问问题不在Miniflux请稍后重试。", "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": "当前由于网关超时导致该网站无法访问问题不在Miniflux请稍后重试。", "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": "当前由于意外的HTTP状态码%d 导致该网站无法访问问题不在Miniflux请稍后重试。", "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": "数据库错误: %v。", "error.database_error": "Database error: %v.",
"error.category_not_found": "该分类不存在或不属于该用户。", "error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "该订阅源已经存在。", "error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "无法解析该订阅源: %v。", "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "该订阅源不存在或不属于该用户。", "error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "无法使用RSS-Bridge去检测订阅源: %v。", "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "无法解析订阅源格式: %v。", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
"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"
} }

View File

@ -41,7 +41,6 @@
"menu.mark_all_as_read": "全部標為已讀", "menu.mark_all_as_read": "全部標為已讀",
"menu.show_all_entries": "顯示所有文章", "menu.show_all_entries": "顯示所有文章",
"menu.show_only_unread_entries": "僅顯示未讀文章", "menu.show_only_unread_entries": "僅顯示未讀文章",
"menu.show_only_starred_entries": "Show only starred entries",
"menu.refresh_feed": "更新", "menu.refresh_feed": "更新",
"menu.refresh_all_feeds": "背景更新全部Feeds", "menu.refresh_all_feeds": "背景更新全部Feeds",
"menu.edit_feed": "編輯", "menu.edit_feed": "編輯",
@ -56,9 +55,7 @@
"search.label": "搜尋", "search.label": "搜尋",
"search.placeholder": "搜尋…", "search.placeholder": "搜尋…",
"search.submit": "送出", "search.submit": "送出",
"pagination.last": "Last",
"pagination.next": "下一頁", "pagination.next": "下一頁",
"pagination.first": "First",
"pagination.previous": "上一頁", "pagination.previous": "上一頁",
"entry.status.unread": "標為未讀", "entry.status.unread": "標為未讀",
"entry.status.read": "標為已讀", "entry.status.read": "標為已讀",
@ -172,8 +169,6 @@
"page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面", "page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面",
"page.keyboard_shortcuts.go_to_previous_page": "上一頁", "page.keyboard_shortcuts.go_to_previous_page": "上一頁",
"page.keyboard_shortcuts.go_to_next_page": "下一頁", "page.keyboard_shortcuts.go_to_next_page": "下一頁",
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
"page.keyboard_shortcuts.open_item": "開啟選定的文章", "page.keyboard_shortcuts.open_item": "開啟選定的文章",
"page.keyboard_shortcuts.open_original": "開啟原始連結", "page.keyboard_shortcuts.open_original": "開啟原始連結",
"page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結", "page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結",
@ -202,8 +197,8 @@
"page.settings.title": "設定", "page.settings.title": "設定",
"page.settings.link_google_account": "關聯我的 Google 賬戶", "page.settings.link_google_account": "關聯我的 Google 賬戶",
"page.settings.unlink_google_account": "解除 Google 帳號關聯", "page.settings.unlink_google_account": "解除 Google 帳號關聯",
"page.settings.link_oidc_account": "關聯我的 %s 賬戶", "page.settings.link_oidc_account": "關聯我的 OpenID Connect 賬戶",
"page.settings.unlink_oidc_account": "解除 %s 帳號關聯", "page.settings.unlink_oidc_account": "解除 OpenID Connect 帳號關聯",
"page.settings.webauthn.passkeys": "Passkeys", "page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "操作", "page.settings.webauthn.actions": "操作",
"page.settings.webauthn.passkey_name": "Passkey 名稱", "page.settings.webauthn.passkey_name": "Passkey 名稱",
@ -216,7 +211,7 @@
], ],
"page.login.title": "登入", "page.login.title": "登入",
"page.login.google_signin": "使用 Google 登入", "page.login.google_signin": "使用 Google 登入",
"page.login.oidc_signin": "使用 %s 登入", "page.login.oidc_signin": "使用 OpenID Connect 登入",
"page.login.webauthn_login": "使用密碼登錄", "page.login.webauthn_login": "使用密碼登錄",
"page.login.webauthn_login.error": "無法使用密碼登錄", "page.login.webauthn_login.error": "無法使用密碼登錄",
"page.integrations.title": "整合", "page.integrations.title": "整合",
@ -251,7 +246,6 @@
"alert.no_bookmark": "目前沒有收藏", "alert.no_bookmark": "目前沒有收藏",
"alert.no_category": "目前沒有分類", "alert.no_category": "目前沒有分類",
"alert.no_category_entry": "該分類下沒有文章", "alert.no_category_entry": "該分類下沒有文章",
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
"alert.no_feed_entry": "該Feed中沒有文章", "alert.no_feed_entry": "該Feed中沒有文章",
"alert.no_feed": "目前沒有Feed", "alert.no_feed": "目前沒有Feed",
"alert.no_history": "目前沒有歷史", "alert.no_history": "目前沒有歷史",
@ -286,14 +280,6 @@
"error.password_min_length": "請至少輸入 6 個字元", "error.password_min_length": "請至少輸入 6 個字元",
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區", "error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
"error.settings_reading_speed_is_positive": "閱讀速度必須是正整數。", "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.entries_per_page_invalid": "每頁的文章數無效。",
"error.feed_mandatory_fields": "必須填寫網址和分類", "error.feed_mandatory_fields": "必須填寫網址和分類",
"error.feed_already_exists": "此Feed已存在。", "error.feed_already_exists": "此Feed已存在。",
@ -318,7 +304,6 @@
"form.feed.label.title": "標題", "form.feed.label.title": "標題",
"form.feed.label.site_url": "網站 URL", "form.feed.label.site_url": "網站 URL",
"form.feed.label.feed_url": "訂閱 Feed URL", "form.feed.label.feed_url": "訂閱 Feed URL",
"form.feed.label.description": "描述",
"form.feed.label.category": "類別", "form.feed.label.category": "類別",
"form.feed.label.crawler": "下載原文內容", "form.feed.label.crawler": "下載原文內容",
"form.feed.label.feed_username": "Feed 使用者名稱", "form.feed.label.feed_username": "Feed 使用者名稱",
@ -338,13 +323,6 @@
"form.feed.label.disabled": "請勿更新此 Feed", "form.feed.label.disabled": "請勿更新此 Feed",
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)", "form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
"form.feed.label.hide_globally": "隱藏全域性未讀列表中的文章", "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.general": "通用",
"form.feed.fieldset.rules": "規則", "form.feed.fieldset.rules": "規則",
"form.feed.fieldset.network_settings": "網路設定", "form.feed.fieldset.network_settings": "網路設定",
@ -385,18 +363,11 @@
"form.prefs.label.default_home_page": "預設主頁", "form.prefs.label.default_home_page": "預設主頁",
"form.prefs.label.categories_sorting_order": "分類排序", "form.prefs.label.categories_sorting_order": "分類排序",
"form.prefs.label.mark_read_on_view": "查看時自動將條目標記為已讀", "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.application_settings": "應用程式設定",
"form.prefs.fieldset.authentication_settings": "使用者認證設定", "form.prefs.fieldset.authentication_settings": "使用者認證設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定", "form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.import.label.file": "OPML 檔案", "form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL", "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_activate": "啟用 Fever API",
"form.integration.fever_username": "Fever 使用者名稱", "form.integration.fever_username": "Fever 使用者名稱",
"form.integration.fever_password": "Fever 密碼", "form.integration.fever_password": "Fever 密碼",
@ -468,10 +439,6 @@
"form.integration.matrix_bot_password": "Matrix 的密碼", "form.integration.matrix_bot_password": "Matrix 的密碼",
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL", "form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID", "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_activate": "儲存文章到 Readeck",
"form.integration.readeck_endpoint": "Readeck API 端點", "form.integration.readeck_endpoint": "Readeck API 端點",
"form.integration.readeck_api_key": "Readeck API 金鑰", "form.integration.readeck_api_key": "Readeck API 金鑰",
@ -489,13 +456,6 @@
"form.integration.webhook_secret": "Webhook Secret", "form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge", "form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge",
"form.integration.rssbridge_url": "RSS-Bridge 伺服器的 URL", "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.api_key.label.description": "API金鑰標籤",
"form.submit.loading": "載入中…", "form.submit.loading": "載入中…",
"form.submit.saving": "儲存中…", "form.submit.saving": "儲存中…",
@ -528,7 +488,7 @@
"error.http_body_read": "Unable to read the HTTP body: %v.", "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_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.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.tls_error": "TLS error: %v. 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_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.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.", "error.http_client_error": "HTTP client error: %v.",
@ -547,16 +507,5 @@
"error.unable_to_parse_feed": "Unable to parse this feed: %v.", "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.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.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.", "error.feed_format_not_detected": "Unable to detect feed format: %v."
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
"error.settings_media_playback_rate_range": "播放速度超出範圍",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
} }

View File

@ -1,68 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/url"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/route"
)
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
mac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())
mac.Write([]byte(mediaURL))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
}
func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
if mediaURL == "" {
return ""
}
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
}
// Note that the proxyified URL is relative to the root URL.
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
if err != nil {
return mediaURL
}
return absoluteURL
}
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
if customProxyURL == "" {
return mediaURL
}
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),
slog.Any("error", err),
)
return mediaURL
}
return absoluteURL
}

View File

@ -2,14 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model" package model // import "miniflux.app/v2/internal/model"
import ( import "strings"
"strings"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/urllib"
)
// Enclosure represents an attachment. // Enclosure represents an attachment.
type Enclosure struct { type Enclosure struct {
@ -22,56 +15,20 @@ type Enclosure struct {
MediaProgression int64 `json:"media_progression"` 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 // Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
func (e Enclosure) Html5MimeType() string { func (e Enclosure) Html5MimeType() string {
if e.MimeType == "video/m4v" { if strings.HasPrefix(e.MimeType, "video") {
return "video/x-m4v" switch e.MimeType {
// Solution from this stackoverflow discussion:
// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
// https://www.florenceporcel.com/podcast/lfhdu.xml
case "video/m4v":
return "video/x-m4v"
}
} }
return e.MimeType return e.MimeType
} }
// EnclosureList represents a list of attachments. // EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure 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
}
}
}
}

View File

@ -50,22 +50,6 @@ 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. // Entries represents a list of entries.
type Entries []*Entry type Entries []*Entry

View File

@ -28,7 +28,6 @@ type Feed struct {
FeedURL string `json:"feed_url"` FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"` SiteURL string `json:"site_url"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"`
CheckedAt time.Time `json:"checked_at"` CheckedAt time.Time `json:"checked_at"`
NextCheckAt time.Time `json:"next_check_at"` NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"` EtagHeader string `json:"etag_header"`
@ -51,10 +50,8 @@ type Feed struct {
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"` FetchViaProxy bool `json:"fetch_via_proxy"`
HideGlobally bool `json:"hide_globally"` HideGlobally bool `json:"hide_globally"`
DisableHTTP2 bool `json:"disable_http2"`
AppriseServiceURLs string `json:"apprise_service_urls"` AppriseServiceURLs string `json:"apprise_service_urls"`
NtfyEnabled bool `json:"ntfy_enabled"` DisableHTTP2 bool `json:"disable_http2"`
NtfyPriority int `json:"ntfy_priority"`
// Non persisted attributes // Non persisted attributes
Category *Category `json:"category,omitempty"` Category *Category `json:"category,omitempty"`
@ -162,7 +159,25 @@ type FeedCreationRequestFromSubscriptionDiscovery struct {
ETag string ETag string
LastModified string LastModified string
FeedCreationRequest FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
NoMediaPlayer bool `json:"no_media_player"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
HideGlobally bool `json:"hide_globally"`
UrlRewriteRules string `json:"urlrewrite_rules"`
DisableHTTP2 bool `json:"disable_http2"`
} }
// FeedModificationRequest represents the request to update a feed. // FeedModificationRequest represents the request to update a feed.
@ -170,7 +185,6 @@ type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"` FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"` SiteURL *string `json:"site_url"`
Title *string `json:"title"` Title *string `json:"title"`
Description *string `json:"description"`
ScraperRules *string `json:"scraper_rules"` ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"` RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"` BlocklistRules *string `json:"blocklist_rules"`
@ -205,10 +219,6 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.Title = *f.Title feed.Title = *f.Title
} }
if f.Description != nil && *f.Description != "" {
feed.Description = *f.Description
}
if f.ScraperRules != nil { if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules feed.ScraperRules = *f.ScraperRules
} }

View File

@ -6,9 +6,6 @@ package model // import "miniflux.app/v2/internal/model"
// Integration represents user integration settings. // Integration represents user integration settings.
type Integration struct { type Integration struct {
UserID int64 UserID int64
BetulaEnabled bool
BetulaURL string
BetulaToken string
PinboardEnabled bool PinboardEnabled bool
PinboardToken string PinboardToken string
PinboardTags string PinboardTags string
@ -93,15 +90,4 @@ type Integration struct {
OmnivoreEnabled bool OmnivoreEnabled bool
OmnivoreAPIKey string OmnivoreAPIKey string
OmnivoreURL 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
} }

View File

@ -3,20 +3,26 @@
package model // import "miniflux.app/v2/internal/model" package model // import "miniflux.app/v2/internal/model"
type Number interface { // OptionalString populates an optional string field.
int | int64 | float64
}
func OptionalNumber[T Number](value T) *T {
if value > 0 {
return &value
}
return nil
}
func OptionalString(value string) *string { func OptionalString(value string) *string {
if value != "" { if value != "" {
return &value return &value
} }
return nil return nil
} }
// OptionalInt populates an optional int field.
func OptionalInt(value int) *int {
if value > 0 {
return &value
}
return nil
}
// OptionalInt64 populates an optional int64 field.
func OptionalInt64(value int64) *int64 {
if value > 0 {
return &value
}
return nil
}

View File

@ -11,34 +11,30 @@ import (
// User represents a user in the system. // User represents a user in the system.
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"-"` Password string `json:"-"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Theme string `json:"theme"` Theme string `json:"theme"`
Language string `json:"language"` Language string `json:"language"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
EntryDirection string `json:"entry_sorting_direction"` EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"` EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"` Stylesheet string `json:"stylesheet"`
GoogleID string `json:"google_id"` GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"` OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"` EntriesPerPage int `json:"entries_per_page"`
KeyboardShortcuts bool `json:"keyboard_shortcuts"` KeyboardShortcuts bool `json:"keyboard_shortcuts"`
ShowReadingTime bool `json:"show_reading_time"` ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"` EntrySwipe bool `json:"entry_swipe"`
GestureNav string `json:"gesture_nav"` GestureNav string `json:"gesture_nav"`
LastLoginAt *time.Time `json:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"` DisplayMode string `json:"display_mode"`
DefaultReadingSpeed int `json:"default_reading_speed"` DefaultReadingSpeed int `json:"default_reading_speed"`
CJKReadingSpeed int `json:"cjk_reading_speed"` CJKReadingSpeed int `json:"cjk_reading_speed"`
DefaultHomePage string `json:"default_home_page"` DefaultHomePage string `json:"default_home_page"`
CategoriesSortingOrder string `json:"categories_sorting_order"` CategoriesSortingOrder string `json:"categories_sorting_order"`
MarkReadOnView bool `json:"mark_read_on_view"` 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. // UserCreationRequest represents the request to create a user.
@ -52,32 +48,28 @@ type UserCreationRequest struct {
// UserModificationRequest represents the request to update a user. // UserModificationRequest represents the request to update a user.
type UserModificationRequest struct { type UserModificationRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
Password *string `json:"password"` Password *string `json:"password"`
Theme *string `json:"theme"` Theme *string `json:"theme"`
Language *string `json:"language"` Language *string `json:"language"`
Timezone *string `json:"timezone"` Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"` EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"` EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"` Stylesheet *string `json:"stylesheet"`
GoogleID *string `json:"google_id"` GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"` OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"` EntriesPerPage *int `json:"entries_per_page"`
IsAdmin *bool `json:"is_admin"` IsAdmin *bool `json:"is_admin"`
KeyboardShortcuts *bool `json:"keyboard_shortcuts"` KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"` ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"` EntrySwipe *bool `json:"entry_swipe"`
GestureNav *string `json:"gesture_nav"` GestureNav *string `json:"gesture_nav"`
DisplayMode *string `json:"display_mode"` DisplayMode *string `json:"display_mode"`
DefaultReadingSpeed *int `json:"default_reading_speed"` DefaultReadingSpeed *int `json:"default_reading_speed"`
CJKReadingSpeed *int `json:"cjk_reading_speed"` CJKReadingSpeed *int `json:"cjk_reading_speed"`
DefaultHomePage *string `json:"default_home_page"` DefaultHomePage *string `json:"default_home_page"`
CategoriesSortingOrder *string `json:"categories_sorting_order"` CategoriesSortingOrder *string `json:"categories_sorting_order"`
MarkReadOnView *bool `json:"mark_read_on_view"` 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. // Patch updates the User object with the modification request.
@ -169,22 +161,6 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.MarkReadOnView != nil { if u.MarkReadOnView != nil {
user.MarkReadOnView = *u.MarkReadOnView 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. // UseTimezone converts last login date to the given timezone.

View File

@ -1,10 +1,9 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy" package proxy // import "miniflux.app/v2/internal/proxy"
import ( import (
"slices"
"strings" "strings"
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
@ -17,26 +16,31 @@ import (
type urlProxyRewriter func(router *mux.Router, url string) string type urlProxyRewriter func(router *mux.Router, url string) string
func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string) string { // ProxyRewriter replaces media URLs with internal proxy URLs.
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument) func ProxyRewriter(router *mux.Router, data string) string {
return genericProxyRewriter(router, ProxifyURL, data)
} }
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, htmlDocument string) string { // AbsoluteProxyRewriter do the same as ProxyRewriter except it uses absolute URLs.
return genericProxyRewriter(router, ProxifyAbsoluteURL, htmlDocument) func AbsoluteProxyRewriter(router *mux.Router, host, data string) string {
proxifyFunction := func(router *mux.Router, url string) string {
return AbsoluteProxifyURL(router, host, url)
}
return genericProxyRewriter(router, proxifyFunction, data)
} }
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string { func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
proxyOption := config.Opts.MediaProxyMode() proxyOption := config.Opts.ProxyOption()
if proxyOption == "none" { if proxyOption == "none" {
return htmlDocument return data
} }
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument)) doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
if err != nil { if err != nil {
return htmlDocument return data
} }
for _, mediaType := range config.Opts.MediaProxyResourceTypes() { for _, mediaType := range config.Opts.ProxyMediaTypes() {
switch mediaType { switch mediaType {
case "image": case "image":
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) { doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
@ -51,15 +55,13 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
} }
}) })
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") { doc.Find("video").Each(func(i int, video *goquery.Selection) {
doc.Find("video").Each(func(i int, video *goquery.Selection) { if posterAttrValue, ok := video.Attr("poster"); ok {
if posterAttrValue, ok := video.Attr("poster"); ok { if shouldProxy(posterAttrValue, proxyOption) {
if shouldProxy(posterAttrValue, proxyOption) { video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
}
} }
}) }
} })
case "audio": case "audio":
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) { doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
@ -89,7 +91,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
output, err := doc.Find("body").First().Html() output, err := doc.Find("body").First().Html()
if err != nil { if err != nil {
return htmlDocument return data
} }
return output return output

View File

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy" package proxy // import "miniflux.app/v2/internal/proxy"
import ( import (
"net/http" "net/http"
@ -29,11 +29,11 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") r.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>` input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>` expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -53,11 +53,11 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") 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>` input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>` expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -76,11 +76,11 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") r.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>` input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := input expected := input
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -99,11 +99,11 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") 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>` input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := input expected := input
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -124,11 +124,11 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") r.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>` input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>` expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -149,98 +149,11 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") 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>` input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>` expected := `<p><img src="/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
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 := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
expected := `<p><img src="http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org:88/folder/")
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
if config.Opts.BaseURL() != "http://example.org:88/folder" {
t.Fatalf(`Unexpected base URL, got "%s"`, config.Opts.BaseURL())
}
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)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "audio")
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 := `<audio src="https://website/folder/audio.mp3"></audio>`
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
expected := `<audio src="http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM="></audio>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
} }
} }
@ -261,61 +174,11 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") 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>` input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>` expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
}
}
func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("PROXY_OPTION", "all")
os.Setenv("PROXY_MEDIA_TYPES", "image")
os.Setenv("PROXY_URL", "http://:8080example.com")
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 := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
}
}
func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
os.Clearenv()
os.Setenv("MEDIA_PROXY_MODE", "all")
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
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 := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
} }
} }
@ -335,11 +198,11 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") r.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>` input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>` expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -359,11 +222,11 @@ func TestProxyFilterWithHttpsInvalid(t *testing.T) {
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") 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>` input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>` expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %q instead of %q`, output, expected) t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
} }
} }
@ -385,7 +248,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
input := `<p><img src="http://website/folder/image.png" srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w" alt="test"></p>` input := `<p><img src="http://website/folder/image.png" srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w" alt="test"></p>`
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w" alt="test"/></p>` expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w" alt="test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -410,7 +273,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>` input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>`
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>` expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -435,7 +298,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>` input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>` expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -460,7 +323,7 @@ func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>` input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>`
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>` expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -484,7 +347,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
input := `<img src="data:image/gif;base64,test">` input := `<img src="data:image/gif;base64,test">`
expected := `<img src="data:image/gif;base64,test"/>` expected := `<img src="data:image/gif;base64,test"/>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -508,7 +371,7 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
input := `<picture><source srcset="data:image/gif;base64,test"/></picture>` input := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
expected := `<picture><source srcset="data:image/gif;base64,test"/></picture>` expected := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -533,7 +396,7 @@ func TestProxyFilterWithVideo(t *testing.T) {
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>` 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>` expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)
@ -558,32 +421,7 @@ func TestProxyFilterVideoPoster(t *testing.T) {
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>` input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>` expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
output := RewriteDocumentWithRelativeProxyURL(r, input) output := ProxyRewriter(r, input)
if expected != output {
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 { if expected != output {
t.Errorf(`Not expected output: got %s`, output) t.Errorf(`Not expected output: got %s`, output)

69
internal/proxy/proxy.go Normal file
View File

@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package proxy // import "miniflux.app/v2/internal/proxy"
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/url"
"path"
"miniflux.app/v2/internal/http/route"
"github.com/gorilla/mux"
"miniflux.app/v2/internal/config"
)
// ProxifyURL generates a relative URL for a proxified resource.
func ProxifyURL(router *mux.Router, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}
// AbsoluteProxifyURL generates an absolute URL for a proxified resource.
func AbsoluteProxifyURL(router *mux.Router, host, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
path := route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
if config.Opts.HTTPS {
return "https://" + host + path
} else {
return "http://" + host + path
}
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}

View File

@ -6,114 +6,158 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import ( import (
"encoding/base64" "encoding/base64"
"html" "html"
"log/slog"
"strings" "strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
) )
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html // Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
type Atom03Feed struct { type atom03Feed struct {
Version string `xml:"version,attr"` ID string `xml:"id"`
Title atom03Text `xml:"title"`
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed. Author atomPerson `xml:"author"`
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element, Links atomLinks `xml:"link"`
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI. Entries []atom03Entry `xml:"entry"`
ID string `xml:"http://purl.org/atom/ns# id"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
// atom:feed elements MUST contain exactly one atom:title element.
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"http://purl.org/atom/ns# title"`
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
// The nature of the relationship as well as the link itself is determined by the element's content.
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
Links AtomLinks `xml:"http://purl.org/atom/ns# link"`
// The "atom:author" element is a Person construct that indicates the default author of the feed.
// atom:feed elements MUST contain exactly one atom:author element,
// UNLESS all of the atom:feed element's child atom:entry elements contain an atom:author element.
// atom:feed elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"http://purl.org/atom/ns# author"`
// The "atom:entry" element's represents an individual entry that is contained by the feed.
// atom:feed elements MAY contain one or more atom:entry elements.
Entries []Atom03Entry `xml:"http://purl.org/atom/ns# entry"`
} }
type Atom03Entry struct { func (a *atom03Feed) Transform(baseURL string) *model.Feed {
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry. var err error
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
ID string `xml:"id"`
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry. feed := new(model.Feed)
// atom:entry elements MUST have exactly one "atom:title" element.
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
Title Atom03Content `xml:"title"`
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified. feedURL := a.Links.firstLinkWithRelation("self")
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one. feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
// The content of an atom:modified element MUST have a time zone whose value SHOULD be "UTC". if err != nil {
Modified string `xml:"modified"` feed.FeedURL = feedURL
}
// The "atom:issued" element is a Date construct that indicates the time that the entry was issued. siteURL := a.Links.originalLink()
// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one. feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
// The content of an atom:issued element MAY omit a time zone. if err != nil {
Issued string `xml:"issued"` feed.SiteURL = siteURL
}
// The "atom:created" element is a Date construct that indicates the time that the entry was created. feed.Title = a.Title.String()
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one. if feed.Title == "" {
// The content of an atom:created element MUST have a time zone whose value SHOULD be "UTC". feed.Title = feed.SiteURL
// If atom:created is not present, its content MUST considered to be the same as that of atom:modified. }
Created string `xml:"created"`
// The "atom:link" element is a Link construct that conveys a URI associated with the entry. for _, entry := range a.Entries {
// The nature of the relationship as well as the link itself is determined by the element's content. item := entry.Transform()
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate". entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value. if err == nil {
// atom:entry elements MAY contain additional atom:link elements beyond those described above. item.URL = entryURL
Links AtomLinks `xml:"link"` }
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry. if item.Author == "" {
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one. item.Author = a.Author.String()
Summary Atom03Content `xml:"summary"` }
// The "atom:content" element is a Content construct that conveys the content of the entry. if item.Title == "" {
// atom:entry elements MAY contain one or more atom:content elements. item.Title = sanitizer.TruncateHTML(item.Content, 100)
Content Atom03Content `xml:"content"` }
// The "atom:author" element is a Person construct that indicates the default author of the entry. if item.Title == "" {
// atom:entry elements MUST contain exactly one atom:author element, item.Title = item.URL
// UNLESS the atom:feed element containing them contains an atom:author element itself. }
// atom:entry elements MUST NOT contain more than one atom:author element.
Author AtomPerson `xml:"author"` feed.Entries = append(feed.Entries, item)
}
return feed
} }
type Atom03Content struct { type atom03Entry struct {
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content. ID string `xml:"id"`
// When present, this attribute's value MUST be a registered media type [RFC2045]. Title atom03Text `xml:"title"`
// If not present, its value MUST be considered to be "text/plain". Modified string `xml:"modified"`
Type string `xml:"type,attr"` Issued string `xml:"issued"`
Created string `xml:"created"`
Links atomLinks `xml:"link"`
Summary atom03Text `xml:"summary"`
Content atom03Text `xml:"content"`
Author atomPerson `xml:"author"`
}
// Content constructs MAY have a "mode" attribute, whose value indicates the method used to encode the content. func (a *atom03Entry) Transform() *model.Entry {
// When present, this attribute's value MUST be listed below. entry := model.NewEntry()
// If not present, its value MUST be considered to be "xml". entry.URL = a.Links.originalLink()
// entry.Date = a.entryDate()
// "xml": A mode attribute with the value "xml" indicates that the element's content is inline xml (for example, namespace-qualified XHTML). entry.Author = a.Author.String()
// entry.Hash = a.entryHash()
// "escaped": A mode attribute with the value "escaped" indicates that the element's content is an escaped string. entry.Content = a.entryContent()
// Processors MUST unescape the element's content before considering it as content of the indicated media type. entry.Title = a.entryTitle()
// return entry
// "base64": A mode attribute with the value "base64" indicates that the element's content is base64-encoded [RFC2045]. }
// Processors MUST decode the element's content before considering it as content of the the indicated media type.
Mode string `xml:"mode,attr"`
func (a *atom03Entry) entryTitle() string {
return sanitizer.StripTags(a.Title.String())
}
func (a *atom03Entry) entryContent() string {
content := a.Content.String()
if content != "" {
return content
}
summary := a.Summary.String()
if summary != "" {
return summary
}
return ""
}
func (a *atom03Entry) entryDate() time.Time {
dateText := ""
for _, value := range []string{a.Issued, a.Modified, a.Created} {
if value != "" {
dateText = value
break
}
}
if dateText != "" {
result, err := date.Parse(dateText)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now()
}
return result
}
return time.Now()
}
func (a *atom03Entry) entryHash() string {
for _, value := range []string{a.ID, a.Links.originalLink()} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
type atom03Text struct {
Type string `xml:"type,attr"`
Mode string `xml:"mode,attr"`
CharData string `xml:",chardata"` CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"` InnerXML string `xml:",innerxml"`
} }
func (a *Atom03Content) Content() string { func (a *atom03Text) String() string {
content := "" content := ""
switch { switch {

View File

@ -1,115 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type Atom03Adapter struct {
atomFeed *Atom03Feed
}
func NewAtom03Adapter(atomFeed *Atom03Feed) *Atom03Adapter {
return &Atom03Adapter{atomFeed}
}
func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Content()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.Content()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Content()
}
// Populate the entry title.
entry.Title = atomEntry.Title.Content()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
}
if entry.Title == "" {
entry.Title = entry.URL
}
// Populate the entry author.
entry.Author = atomEntry.Author.PersonName()
if entry.Author == "" {
entry.Author = a.atomFeed.Author.PersonName()
}
// Populate the entry date.
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
if parsedDate, err := date.Parse(value); err == nil {
entry.Date = parsedDate
break
} else {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", value),
slog.String("id", atomEntry.ID),
slog.Any("error", err),
)
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}

View File

@ -27,7 +27,7 @@ func TestParseAtom03(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -36,7 +36,7 @@ func TestParseAtom03(t *testing.T) {
t.Errorf("Incorrect title, got: %s", feed.Title) t.Errorf("Incorrect title, got: %s", feed.Title)
} }
if feed.FeedURL != "http://diveintomark.org/atom.xml" { if feed.FeedURL != "http://diveintomark.org/" {
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL) t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
} }
@ -74,28 +74,6 @@ func TestParseAtom03(t *testing.T) {
} }
} }
func TestParseAtom03WithoutSiteURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
</entry>
</feed>`
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
if err != nil {
t.Fatal(err)
}
if feed.SiteURL != "http://diveintomark.org/atom.xml" {
t.Errorf("Incorrect title, got: %s", feed.Title)
}
}
func TestParseAtom03WithoutFeedTitle(t *testing.T) { func TestParseAtom03WithoutFeedTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?> data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#"> <feed version="0.3" xmlns="http://purl.org/atom/ns#">
@ -109,7 +87,7 @@ func TestParseAtom03WithoutFeedTitle(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -132,7 +110,7 @@ func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -160,7 +138,7 @@ func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -188,7 +166,7 @@ func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -219,7 +197,7 @@ func TestParseAtom03WithSummaryOnly(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -250,7 +228,7 @@ func TestParseAtom03WithXMLContent(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -281,7 +259,7 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
</entry> </entry>
</feed>` </feed>`
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3") feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -6,200 +6,286 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
import ( import (
"encoding/xml" "encoding/xml"
"html" "html"
"log/slog"
"strconv"
"strings" "strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/media" "miniflux.app/v2/internal/reader/media"
"miniflux.app/v2/internal/reader/sanitizer" "miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
) )
// The "atom:feed" element is the document (i.e., top-level) element of
// an Atom Feed Document, acting as a container for metadata and data
// associated with the feed. Its element children consist of metadata
// elements followed by zero or more atom:entry child elements.
//
// Specs: // Specs:
// https://tools.ietf.org/html/rfc4287 // https://tools.ietf.org/html/rfc4287
// https://validator.w3.org/feed/docs/atom.html // https://validator.w3.org/feed/docs/atom.html
type Atom10Feed struct { type atom10Feed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
ID string `xml:"id"`
// The "atom:id" element conveys a permanent, universally unique Title atom10Text `xml:"title"`
// identifier for an entry or feed. Authors atomAuthors `xml:"author"`
// Icon string `xml:"icon"`
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the Links atomLinks `xml:"link"`
// definition of "IRI" excludes relative references. Though the IRI Entries []atom10Entry `xml:"entry"`
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:feed elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// The "atom:title" element is a Text construct that conveys a human-
// readable title for an entry or feed.
//
// atom:feed elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:author" element is a Person construct that indicates the
// author of the entry or feed.
//
// atom:feed elements MUST contain one or more atom:author elements,
// unless all of the atom:feed element's child atom:entry elements
// contain at least one atom:author element.
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:icon" element's content is an IRI reference [RFC3987] that
// identifies an image that provides iconic visual identification for a
// feed.
//
// atom:feed elements MUST NOT contain more than one atom:icon element.
Icon string `xml:"http://www.w3.org/2005/Atom icon"`
// The "atom:logo" element's content is an IRI reference [RFC3987] that
// identifies an image that provides visual identification for a feed.
//
// atom:feed elements MUST NOT contain more than one atom:logo element.
Logo string `xml:"http://www.w3.org/2005/Atom logo"`
// atom:feed elements SHOULD contain one atom:link element with a rel
// attribute value of "self". This is the preferred URI for
// retrieving Atom Feed Documents representing this Atom feed.
//
// atom:feed elements MUST NOT contain more than one atom:link
// element with a rel attribute value of "alternate" that has the
// same combination of type and hreflang attribute values.
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// atom:feed elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
Entries []Atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
} }
type Atom10Entry struct { func (a *atom10Feed) Transform(baseURL string) *model.Feed {
// The "atom:id" element conveys a permanent, universally unique var err error
// identifier for an entry or feed.
//
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
// definition of "IRI" excludes relative references. Though the IRI
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
// can be dereferenced.
//
// atom:entry elements MUST contain exactly one atom:id element.
ID string `xml:"http://www.w3.org/2005/Atom id"`
// The "atom:title" element is a Text construct that conveys a human- feed := new(model.Feed)
// readable title for an entry or feed.
//
// atom:entry elements MUST contain exactly one atom:title element.
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
// The "atom:published" element is a Date construct indicating an feedURL := a.Links.firstLinkWithRelation("self")
// instant in time associated with an event early in the life cycle of feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
// the entry. if err != nil {
Published string `xml:"http://www.w3.org/2005/Atom published"` feed.FeedURL = feedURL
}
// The "atom:updated" element is a Date construct indicating the most siteURL := a.Links.originalLink()
// recent instant in time when an entry or feed was modified in a way feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
// the publisher considers significant. Therefore, not all if err != nil {
// modifications necessarily result in a changed atom:updated value. feed.SiteURL = siteURL
// }
// atom:entry elements MUST contain exactly one atom:updated element.
Updated string `xml:"http://www.w3.org/2005/Atom updated"`
// atom:entry elements MUST NOT contain more than one atom:link feed.Title = html.UnescapeString(a.Title.String())
// element with a rel attribute value of "alternate" that has the if feed.Title == "" {
// same combination of type and hreflang attribute values. feed.Title = feed.SiteURL
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"` }
// atom:entry elements MUST contain an atom:summary element in either feed.IconURL = strings.TrimSpace(a.Icon)
// of the following cases:
// * the atom:entry contains an atom:content that has a "src"
// attribute (and is thus empty).
// * the atom:entry contains content that is encoded in Base64;
// i.e., the "type" attribute of atom:content is a MIME media type
// [MIMEREG], but is not an XML media type [RFC3023], does not
// begin with "text/", and does not end with "/xml" or "+xml".
//
// atom:entry elements MUST NOT contain more than one atom:summary
// element.
Summary Atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
// atom:entry elements MUST NOT contain more than one atom:content for _, entry := range a.Entries {
// element. item := entry.Transform()
Content Atom10Text `xml:"http://www.w3.org/2005/Atom content"` entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
if err == nil {
item.URL = entryURL
}
// The "atom:author" element is a Person construct that indicates the if item.Author == "" {
// author of the entry or feed. item.Author = a.Authors.String()
// }
// atom:entry elements MUST contain one or more atom:author elements
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
// The "atom:category" element conveys information about a category if item.Title == "" {
// associated with an entry or feed. This specification assigns no item.Title = sanitizer.TruncateHTML(item.Content, 100)
// meaning to the content (if any) of this element. }
//
// atom:entry elements MAY contain any number of atom:category
// elements.
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
media.MediaItemElement if item.Title == "" {
item.Title = item.URL
}
feed.Entries = append(feed.Entries, item)
}
return feed
} }
// A Text construct contains human-readable text, usually in small type atom10Entry struct {
// quantities. The content of Text constructs is Language-Sensitive. ID string `xml:"id"`
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1 Title atom10Text `xml:"title"`
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1 Published string `xml:"published"`
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2 Updated string `xml:"updated"`
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3 Links atomLinks `xml:"link"`
type Atom10Text struct { Summary atom10Text `xml:"summary"`
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
Authors atomAuthors `xml:"author"`
Categories []atom10Category `xml:"category"`
media.Element
}
func (a *atom10Entry) Transform() *model.Entry {
entry := model.NewEntry()
entry.URL = a.Links.originalLink()
entry.Date = a.entryDate()
entry.Author = a.Authors.String()
entry.Hash = a.entryHash()
entry.Content = a.entryContent()
entry.Title = a.entryTitle()
entry.Enclosures = a.entryEnclosures()
entry.CommentsURL = a.entryCommentsURL()
entry.Tags = a.entryCategories()
return entry
}
func (a *atom10Entry) entryTitle() string {
return html.UnescapeString(a.Title.String())
}
func (a *atom10Entry) entryContent() string {
content := a.Content.String()
if content != "" {
return content
}
summary := a.Summary.String()
if summary != "" {
return summary
}
mediaDescription := a.FirstMediaDescription()
if mediaDescription != "" {
return mediaDescription
}
return ""
}
// Note: The published date represents the original creation date for YouTube feeds.
// Example:
// <published>2019-01-26T08:02:28+00:00</published>
// <updated>2019-01-29T07:27:27+00:00</updated>
func (a *atom10Entry) entryDate() time.Time {
dateText := a.Published
if dateText == "" {
dateText = a.Updated
}
if dateText != "" {
result, err := date.Parse(dateText)
if err != nil {
slog.Debug("Unable to parse date from Atom 0.3 feed",
slog.String("date", dateText),
slog.String("id", a.ID),
slog.Any("error", err),
)
return time.Now()
}
return result
}
return time.Now()
}
func (a *atom10Entry) entryHash() string {
for _, value := range []string{a.ID, a.Links.originalLink()} {
if value != "" {
return crypto.Hash(value)
}
}
return ""
}
func (a *atom10Entry) entryEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
duplicates := make(map[string]bool)
for _, mediaThumbnail := range a.AllMediaThumbnails() {
if _, found := duplicates[mediaThumbnail.URL]; !found {
duplicates[mediaThumbnail.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaThumbnail.URL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
}
for _, link := range a.Links {
if strings.EqualFold(link.Rel, "enclosure") {
if link.URL == "" {
continue
}
if _, found := duplicates[link.URL]; !found {
duplicates[link.URL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
}
}
}
for _, mediaContent := range a.AllMediaContents() {
if _, found := duplicates[mediaContent.URL]; !found {
duplicates[mediaContent.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaContent.URL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
}
for _, mediaPeerLink := range a.AllMediaPeerLinks() {
if _, found := duplicates[mediaPeerLink.URL]; !found {
duplicates[mediaPeerLink.URL] = true
enclosures = append(enclosures, &model.Enclosure{
URL: mediaPeerLink.URL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
}
return enclosures
}
func (r *atom10Entry) entryCategories() []string {
categoryList := make([]string, 0)
for _, atomCategory := range r.Categories {
if strings.TrimSpace(atomCategory.Label) != "" {
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Label))
} else {
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Term))
}
}
return categoryList
}
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
func (a *atom10Entry) entryCommentsURL() string {
commentsURL := a.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
if urllib.IsAbsoluteURL(commentsURL) {
return commentsURL
}
return ""
}
type atom10Text struct {
Type string `xml:"type,attr"` Type string `xml:"type,attr"`
CharData string `xml:",chardata"` CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"` InnerXML string `xml:",innerxml"`
XHTMLRootElement AtomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"` XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
} }
func (a *Atom10Text) Body() string { type atom10Category struct {
var content string Term string `xml:"term,attr"`
Label string `xml:"label,attr"`
if strings.EqualFold(a.Type, "xhtml") {
content = a.xhtmlContent()
} else {
content = a.CharData
}
return strings.TrimSpace(content)
} }
func (a *Atom10Text) Title() string { // Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
func (a *atom10Text) String() string {
var content string var content string
switch { switch {
case strings.EqualFold(a.Type, "xhtml"): case a.Type == "", a.Type == "text", a.Type == "text/plain":
content = a.xhtmlContent() if strings.HasPrefix(strings.TrimSpace(a.InnerXML), `<![CDATA[`) {
case strings.Contains(a.InnerXML, "<![CDATA["): content = html.EscapeString(a.CharData)
content = html.UnescapeString(a.CharData) } else {
content = a.InnerXML
}
case a.Type == "xhtml":
var root = a.XHTMLRootElement
if root.XMLName.Local == "div" {
content = root.InnerXML
} else {
content = a.InnerXML
}
default: default:
content = a.CharData content = a.CharData
} }
content = sanitizer.StripTags(content)
return strings.TrimSpace(content) return strings.TrimSpace(content)
} }
func (a *Atom10Text) xhtmlContent() string { type atomXHTMLRootElement struct {
if a.XHTMLRootElement.XMLName.Local == "div" {
return a.XHTMLRootElement.InnerXML
}
return a.InnerXML
}
type AtomXHTMLRootElement struct {
XMLName xml.Name `xml:"div"` XMLName xml.Name `xml:"div"`
InnerXML string `xml:",innerxml"` InnerXML string `xml:",innerxml"`
} }

View File

@ -1,254 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"log/slog"
"slices"
"sort"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type Atom10Adapter struct {
atomFeed *Atom10Feed
}
func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
return &Atom10Adapter{atomFeed}
}
func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
feed := new(model.Feed)
// Populate the feed URL.
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
if feedURL != "" {
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
feed.FeedURL = absoluteFeedURL
}
} else {
feed.FeedURL = baseURL
}
// Populate the site URL.
siteURL := a.atomFeed.Links.OriginalLink()
if siteURL != "" {
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
feed.SiteURL = absoluteSiteURL
}
} else {
feed.SiteURL = baseURL
}
// Populate the feed title.
feed.Title = a.atomFeed.Title.Body()
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// Populate the feed icon.
if a.atomFeed.Icon != "" {
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
feed.IconURL = absoluteIconURL
}
} else if a.atomFeed.Logo != "" {
if absoluteLogoURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
feed.IconURL = absoluteLogoURL
}
}
feed.Entries = a.populateEntries(feed.SiteURL)
return feed
}
func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
entries := make(model.Entries, 0, len(a.atomFeed.Entries))
for _, atomEntry := range a.atomFeed.Entries {
entry := model.NewEntry()
// Populate the entry URL.
entry.URL = atomEntry.Links.OriginalLink()
if entry.URL != "" {
if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
entry.URL = absoluteEntryURL
}
}
// Populate the entry content.
entry.Content = atomEntry.Content.Body()
if entry.Content == "" {
entry.Content = atomEntry.Summary.Body()
if entry.Content == "" {
entry.Content = atomEntry.FirstMediaDescription()
}
}
// Populate the entry title.
entry.Title = atomEntry.Title.Title()
if entry.Title == "" {
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
if entry.Title == "" {
entry.Title = entry.URL
}
}
// Populate the entry author.
authors := atomEntry.Authors.PersonNames()
if len(authors) == 0 {
authors = a.atomFeed.Authors.PersonNames()
}
sort.Strings(authors)
authors = slices.Compact(authors)
entry.Author = strings.Join(authors, ", ")
// Populate the entry date.
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
if value != "" {
if parsedDate, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from Atom 1.0 feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = parsedDate
break
}
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate categories.
categories := atomEntry.Categories.CategoryNames()
if len(categories) == 0 {
categories = a.atomFeed.Categories.CategoryNames()
}
sort.Strings(categories)
entry.Tags = slices.Compact(categories)
// Populate the commentsURL if defined.
// See https://tools.ietf.org/html/rfc4685#section-4
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
commentsURL := atomEntry.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
if urllib.IsAbsoluteURL(commentsURL) {
entry.CommentsURL = commentsURL
}
// Generate the entry hash.
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
// Populate the entry enclosures.
uniqueEnclosuresMap := make(map[string]bool)
for _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {
mediaURL := strings.TrimSpace(mediaThumbnail.URL)
if mediaURL == "" {
continue
}
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media thumbnail",
slog.String("url", mediaThumbnail.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaThumbnail.MimeType(),
Size: mediaThumbnail.Size(),
})
}
}
}
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
absoluteEnclosureURL, err := urllib.AbsoluteURL(siteURL, link.Href)
if err != nil {
slog.Debug("Unable to resolve absolute URL for enclosure",
slog.String("enclosure_url", link.Href),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
uniqueEnclosuresMap[absoluteEnclosureURL] = true
length, _ := strconv.ParseInt(link.Length, 10, 0)
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteEnclosureURL,
MimeType: link.Type,
Size: length,
})
}
}
}
for _, mediaContent := range atomEntry.AllMediaContents() {
mediaURL := strings.TrimSpace(mediaContent.URL)
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media content",
slog.String("url", mediaContent.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaContent.MimeType(),
Size: mediaContent.Size(),
})
}
}
}
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
if mediaURL == "" {
continue
}
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
slog.Debug("Unable to build absolute URL for media peer link",
slog.String("url", mediaPeerLink.URL),
slog.String("site_url", siteURL),
slog.Any("error", err),
)
} else {
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
uniqueEnclosuresMap[mediaAbsoluteURL] = true
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: mediaAbsoluteURL,
MimeType: mediaPeerLink.MimeType(),
Size: mediaPeerLink.Size(),
})
}
}
}
entries = append(entries, entry)
}
return entries
}

File diff suppressed because it is too large Load Diff

View File

@ -3,91 +3,77 @@
package atom // import "miniflux.app/v2/internal/reader/atom" package atom // import "miniflux.app/v2/internal/reader/atom"
import ( import "strings"
"strings"
)
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.2 type atomPerson struct {
type AtomPerson struct { Name string `xml:"name"`
// The "atom:name" element's content conveys a human-readable name for the author.
// It MAY be the name of a corporation or other entity no individual authors can be named.
// Person constructs MUST contain exactly one "atom:name" element, whose content MUST be a string.
Name string `xml:"name"`
// The "atom:email" element's content conveys an e-mail address associated with the Person construct.
// Person constructs MAY contain an atom:email element, but MUST NOT contain more than one.
// Its content MUST be an e-mail address [RFC2822].
// Ordering of the element children of Person constructs MUST NOT be considered significant.
Email string `xml:"email"` Email string `xml:"email"`
} }
func (a *AtomPerson) PersonName() string { func (a *atomPerson) String() string {
name := strings.TrimSpace(a.Name) name := ""
if name != "" {
return name switch {
case a.Name != "":
name = a.Name
case a.Email != "":
name = a.Email
} }
return strings.TrimSpace(a.Email) return strings.TrimSpace(name)
} }
type AtomPersons []*AtomPerson type atomAuthors []*atomPerson
func (a AtomPersons) PersonNames() []string { func (a atomAuthors) String() string {
var names []string var authors []string
authorNamesMap := make(map[string]bool)
for _, person := range a { for _, person := range a {
personName := person.PersonName() authors = append(authors, person.String())
if _, ok := authorNamesMap[personName]; !ok {
names = append(names, personName)
authorNamesMap[personName] = true
}
} }
return names return strings.Join(authors, ", ")
} }
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7 type atomLink struct {
type AtomLink struct { URL string `xml:"href,attr"`
Href string `xml:"href,attr"`
Type string `xml:"type,attr"` Type string `xml:"type,attr"`
Rel string `xml:"rel,attr"` Rel string `xml:"rel,attr"`
Length string `xml:"length,attr"` Length string `xml:"length,attr"`
Title string `xml:"title,attr"`
} }
type AtomLinks []*AtomLink type atomLinks []*atomLink
func (a AtomLinks) OriginalLink() string { func (a atomLinks) originalLink() string {
for _, link := range a { for _, link := range a {
if strings.EqualFold(link.Rel, "alternate") { if strings.EqualFold(link.Rel, "alternate") {
return strings.TrimSpace(link.Href) return strings.TrimSpace(link.URL)
} }
if link.Rel == "" && (link.Type == "" || link.Type == "text/html") { if link.Rel == "" && (link.Type == "" || link.Type == "text/html") {
return strings.TrimSpace(link.Href) return strings.TrimSpace(link.URL)
} }
} }
return "" return ""
} }
func (a AtomLinks) firstLinkWithRelation(relation string) string { func (a atomLinks) firstLinkWithRelation(relation string) string {
for _, link := range a { for _, link := range a {
if strings.EqualFold(link.Rel, relation) { if strings.EqualFold(link.Rel, relation) {
return strings.TrimSpace(link.Href) return strings.TrimSpace(link.URL)
} }
} }
return "" return ""
} }
func (a AtomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string { func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
for _, link := range a { for _, link := range a {
if strings.EqualFold(link.Rel, relation) { if strings.EqualFold(link.Rel, relation) {
for _, contentType := range contentTypes { for _, contentType := range contentTypes {
if strings.EqualFold(link.Type, contentType) { if strings.EqualFold(link.Type, contentType) {
return strings.TrimSpace(link.Href) return strings.TrimSpace(link.URL)
} }
} }
} }
@ -95,61 +81,3 @@ func (a AtomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
return "" return ""
} }
func (a AtomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
var links []*AtomLink
for _, link := range a {
if strings.EqualFold(link.Rel, relation) {
link.Href = strings.TrimSpace(link.Href)
if link.Href != "" {
links = append(links, link)
}
}
}
return links
}
// The "atom:category" element conveys information about a category
// associated with an entry or feed. This specification assigns no
// meaning to the content (if any) of this element.
//
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.2
type AtomCategory struct {
// The "term" attribute is a string that identifies the category to
// which the entry or feed belongs. Category elements MUST have a
// "term" attribute.
Term string `xml:"term,attr"`
// The "scheme" attribute is an IRI that identifies a categorization
// scheme. Category elements MAY have a "scheme" attribute.
Scheme string `xml:"scheme,attr"`
// The "label" attribute provides a human-readable label for display in
// end-user applications. The content of the "label" attribute is
// Language-Sensitive. Entities such as "&amp;" and "&lt;" represent
// their corresponding characters ("&" and "<", respectively), not
// markup. Category elements MAY have a "label" attribute.
Label string `xml:"label,attr"`
}
type AtomCategories []AtomCategory
func (ac AtomCategories) CategoryNames() []string {
var categories []string
for _, category := range ac {
label := strings.TrimSpace(category.Label)
if label != "" {
categories = append(categories, label)
} else {
term := strings.TrimSpace(category.Term)
if term != "" {
categories = append(categories, term)
}
}
}
return categories
}

View File

@ -4,6 +4,7 @@
package atom // import "miniflux.app/v2/internal/reader/atom" package atom // import "miniflux.app/v2/internal/reader/atom"
import ( import (
"encoding/xml"
"fmt" "fmt"
"io" "io"
@ -11,20 +12,45 @@ import (
xml_decoder "miniflux.app/v2/internal/reader/xml" xml_decoder "miniflux.app/v2/internal/reader/xml"
) )
// Parse returns a normalized feed struct from a Atom feed. type atomFeed interface {
func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error) { Transform(baseURL string) *model.Feed
switch version { }
case "0.3":
atomFeed := new(Atom03Feed) // Parse returns a normalized feed struct from a Atom feed.
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil { func Parse(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
return nil, fmt.Errorf("atom: unable to parse Atom 0.3 feed: %w", err) var rawFeed atomFeed
} if getAtomFeedVersion(r) == "0.3" {
return NewAtom03Adapter(atomFeed).BuildFeed(baseURL), nil rawFeed = new(atom03Feed)
default: } else {
atomFeed := new(Atom10Feed) rawFeed = new(atom10Feed)
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil { }
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err) r.Seek(0, io.SeekStart)
}
return NewAtom10Adapter(atomFeed).BuildFeed(baseURL), nil if err := xml_decoder.NewXMLDecoder(r).Decode(rawFeed); err != nil {
} return nil, fmt.Errorf("atom: unable to parse feed: %w", err)
}
return rawFeed.Transform(baseURL), nil
}
func getAtomFeedVersion(data io.ReadSeeker) string {
decoder := xml_decoder.NewXMLDecoder(data)
for {
token, _ := decoder.Token()
if token == nil {
break
}
if element, ok := token.(xml.StartElement); ok {
if element.Name.Local == "feed" {
for _, attr := range element.Attr {
if attr.Name.Local == "version" && attr.Value == "0.3" {
return "0.3"
}
}
return "1.0"
}
}
}
return "1.0"
} }

View File

@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package atom // import "miniflux.app/v2/internal/reader/atom"
import (
"bytes"
"testing"
)
func TestDetectAtom10(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2003-12-13T18:30:02Z</updated>
<author>
<name>John Doe</name>
</author>
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<entry>
<title>Atom-Powered Robots Run Amok</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
if version != "1.0" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
}
func TestDetectAtom03(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
<title>dive into mark</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
<modified>2003-12-13T18:30:02Z</modified>
<author><name>Mark Pilgrim</name></author>
<entry>
<title>Atom 0.3 snapshot</title>
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
<id>tag:diveintomark.org,2003:3.2397</id>
<issued>2003-12-13T08:29:29-04:00</issued>
<modified>2003-12-13T18:30:02Z</modified>
<summary type="text/plain">This is a test</summary>
<content type="text/html" mode="escaped"><![CDATA[<p>HTML content</p>]]></content>
</entry>
</feed>`
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
if version != "0.3" {
t.Errorf(`Invalid Atom version detected: %s`, version)
}
}

View File

@ -3,13 +3,29 @@
package dublincore // import "miniflux.app/v2/internal/reader/dublincore" package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
type DublinCoreChannelElement struct { import (
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"` "strings"
"miniflux.app/v2/internal/reader/sanitizer"
)
// DublinCoreFeedElement represents Dublin Core feed XML elements.
type DublinCoreFeedElement struct {
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ channel>creator"`
} }
func (feed *DublinCoreFeedElement) GetSanitizedCreator() string {
return strings.TrimSpace(sanitizer.StripTags(feed.DublinCoreCreator))
}
// DublinCoreItemElement represents Dublin Core entry XML elements.
type DublinCoreItemElement struct { type DublinCoreItemElement struct {
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"` DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"` DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"` DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"` DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
} }
func (item *DublinCoreItemElement) GetSanitizedCreator() string {
return strings.TrimSpace(sanitizer.StripTags(item.DublinCoreCreator))
}

View File

@ -35,3 +35,8 @@ func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
// Transform document to UTF-8 from the specified encoding in XML prolog. // Transform document to UTF-8 from the specified encoding in XML prolog.
return charset.NewReaderLabel(charsetLabel, r) return charset.NewReaderLabel(charsetLabel, r)
} }
// CharsetReaderFromContentType is used when the encoding is not specified for the input document.
func CharsetReaderFromContentType(contentType string, input io.Reader) (io.Reader, error) {
return charset.NewReader(input, contentType)
}

View File

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

View File

@ -109,16 +109,6 @@ func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
} }
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) { 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{ transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless. // Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
@ -138,7 +128,6 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
IdleConnTimeout: 10 * time.Second, IdleConnTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
CipherSuites: cipherSuites,
InsecureSkipVerify: r.ignoreTLSErrors, InsecureSkipVerify: r.ignoreTLSErrors,
}, },
} }
@ -180,7 +169,6 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
} }
req.Header = r.headers req.Header = r.headers
req.Header.Set("Accept-Encoding", "br, gzip")
req.Header.Set("Accept", defaultAcceptHeader) req.Header.Set("Accept", defaultAcceptHeader)
req.Header.Set("Connection", "close") req.Header.Set("Connection", "close")

View File

@ -8,12 +8,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"os"
"strings"
"miniflux.app/v2/internal/locale" "miniflux.app/v2/internal/locale"
) )
@ -56,12 +52,12 @@ func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bo
return false return false
} }
if r.ETag() != "" { if r.ETag() != "" && r.ETag() == lastEtagValue {
return r.ETag() != lastEtagValue return false
} }
if r.LastModified() != "" { if r.LastModified() != "" && r.LastModified() == lastModifiedValue {
return r.LastModified() != lastModifiedValue return false
} }
return true return true
@ -73,31 +69,12 @@ 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 { func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
return r.getReader(maxBodySize) return http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
} }
func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) { func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {
limitedReader := r.getReader(maxBodySize) limitedReader := http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
buffer, err := io.ReadAll(limitedReader) buffer, err := io.ReadAll(limitedReader)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
@ -117,18 +94,23 @@ func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.Localized
func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper { func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
if r.clientErr != nil { if r.clientErr != nil {
switch { switch r.clientErr.(type) {
case isSSLError(r.clientErr): case x509.CertificateInvalidError, x509.HostnameError:
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.tls_error", r.clientErr) return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.tls_error", r.clientErr)
case isNetworkError(r.clientErr): case *net.OpError:
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_operation", r.clientErr) return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_operation", r.clientErr)
case os.IsTimeout(r.clientErr): case net.Error:
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_timeout", r.clientErr) networkErr := r.clientErr.(net.Error)
case errors.Is(r.clientErr, io.EOF): if networkErr.Timeout() {
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_empty_response") return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_timeout", r.clientErr)
default: }
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_client_error", r.clientErr)
} }
if errors.Is(r.clientErr, io.EOF) {
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_empty_response")
}
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_client_error", r.clientErr)
} }
switch r.httpResponse.StatusCode { switch r.httpResponse.StatusCode {
@ -163,32 +145,3 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
return nil return nil
} }
func isNetworkError(err error) bool {
if _, ok := err.(*url.Error); ok {
return true
}
if err == io.EOF {
return true
}
var opErr *net.OpError
if ok := errors.As(err, &opErr); ok {
return true
}
return false
}
func isSSLError(err error) bool {
var certErr x509.UnknownAuthorityError
if errors.As(err, &certErr) {
return true
}
var hostErr x509.HostnameError
if errors.As(err, &hostErr) {
return true
}
var algErr x509.InsecureAlgorithmError
return errors.As(err, &algErr)
}

View File

@ -1,69 +0,0 @@
// 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)
}
})
}
}

View File

@ -1,31 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package googleplay // import "miniflux.app/v2/internal/reader/googleplay"
// Specs:
// https://support.google.com/googleplay/podcasts/answer/6260341
// https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd
type GooglePlayChannelElement struct {
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
GooglePlayEmail string `xml:"http://www.google.com/schemas/play-podcasts/1.0 email"`
GooglePlayImage GooglePlayImageElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 image"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
GooglePlayCategory GooglePlayCategoryElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 category"`
}
type GooglePlayItemElement struct {
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
GooglePlayExplicit string `xml:"http://www.google.com/schemas/play-podcasts/1.0 explicit"`
GooglePlayBlock string `xml:"http://www.google.com/schemas/play-podcasts/1.0 block"`
GooglePlayNewFeedURL string `xml:"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url"`
}
type GooglePlayImageElement struct {
Href string `xml:"href,attr"`
}
type GooglePlayCategoryElement struct {
Text string `xml:"text,attr"`
}

View File

@ -169,7 +169,6 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.BlocklistRules = feedCreationRequest.BlocklistRules subscription.BlocklistRules = feedCreationRequest.BlocklistRules
subscription.KeeplistRules = feedCreationRequest.KeeplistRules subscription.KeeplistRules = feedCreationRequest.KeeplistRules
subscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules subscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules
subscription.HideGlobally = feedCreationRequest.HideGlobally
subscription.EtagHeader = responseHandler.ETag() subscription.EtagHeader = responseHandler.ETag()
subscription.LastModifiedHeader = responseHandler.LastModified() subscription.LastModifiedHeader = responseHandler.LastModified()
subscription.FeedURL = responseHandler.EffectiveURL() subscription.FeedURL = responseHandler.EffectiveURL()
@ -237,18 +236,14 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password) requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent()) requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(originalFeed.Cookie) requestBuilder.WithCookie(originalFeed.Cookie)
requestBuilder.WithETag(originalFeed.EtagHeader)
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout()) requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy()) requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(originalFeed.FetchViaProxy) requestBuilder.UseProxy(originalFeed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates) requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2) requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
ignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh
if !ignoreHTTPCache {
requestBuilder.WithETag(originalFeed.EtagHeader)
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
}
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL)) responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
defer responseHandler.Close() defer responseHandler.Close()
@ -266,7 +261,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
return localizedError return localizedError
} }
if ignoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) { if originalFeed.IgnoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
slog.Debug("Feed modified", slog.Debug("Feed modified",
slog.Int64("user_id", userID), slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID), slog.Int64("feed_id", feedID),

View File

@ -15,11 +15,11 @@ import (
"miniflux.app/v2/internal/config" "miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto" "miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model" "miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/encoding"
"miniflux.app/v2/internal/reader/fetcher" "miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/urllib"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"golang.org/x/net/html/charset"
) )
type IconFinder struct { type IconFinder struct {
@ -191,7 +191,7 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
"link[rel='apple-touch-icon-precomposed.png']", "link[rel='apple-touch-icon-precomposed.png']",
} }
htmlDocumentReader, err := charset.NewReader(body, contentType) htmlDocumentReader, err := encoding.CharsetReaderFromContentType(contentType, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err) return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
} }

View File

@ -1,75 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package itunes // import "miniflux.app/v2/internal/reader/itunes"
import "strings"
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
type ItunesChannelElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesBlock string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd block"`
ItunesCategories []ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
ItunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
ItunesCopyright string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd copyright"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
Keywords string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd keywords"`
ItunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
ItunesOwner ItunesOwnerElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
}
func (i *ItunesChannelElement) GetItunesCategories() []string {
var categories []string
for _, category := range i.ItunesCategories {
categories = append(categories, category.Text)
if category.SubCategory != nil {
categories = append(categories, category.SubCategory.Text)
}
}
return categories
}
type ItunesItemElement struct {
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
ItunesEpisodeType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ItunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
ItunesSubtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
ItunesTranscript string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd transcript"`
}
type ItunesImageElement struct {
Href string `xml:"href,attr"`
}
type ItunesCategoryElement struct {
Text string `xml:"text,attr"`
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
}
type ItunesOwnerElement struct {
Name string `xml:"name"`
Email string `xml:"email"`
}
func (i *ItunesOwnerElement) String() string {
var name string
switch {
case i.Name != "":
name = i.Name
case i.Email != "":
name = i.Email
}
return strings.TrimSpace(name)
}

View File

@ -1,172 +0,0 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package json // import "miniflux.app/v2/internal/reader/json"
import (
"log/slog"
"slices"
"strings"
"time"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/date"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/urllib"
)
type JSONAdapter struct {
jsonFeed *JSONFeed
}
func NewJSONAdapter(jsonFeed *JSONFeed) *JSONAdapter {
return &JSONAdapter{jsonFeed}
}
func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
feed := &model.Feed{
Title: strings.TrimSpace(j.jsonFeed.Title),
FeedURL: strings.TrimSpace(j.jsonFeed.FeedURL),
SiteURL: strings.TrimSpace(j.jsonFeed.HomePageURL),
}
if feed.FeedURL == "" {
feed.FeedURL = strings.TrimSpace(baseURL)
}
// Fallback to the feed URL if the site URL is empty.
if feed.SiteURL == "" {
feed.SiteURL = feed.FeedURL
}
if feedURL, err := urllib.AbsoluteURL(baseURL, feed.FeedURL); err == nil {
feed.FeedURL = feedURL
}
if siteURL, err := urllib.AbsoluteURL(baseURL, feed.SiteURL); err == nil {
feed.SiteURL = siteURL
}
// Fallback to the feed URL if the title is empty.
if feed.Title == "" {
feed.Title = feed.SiteURL
}
// Populate the icon URL if present.
for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
iconURL = strings.TrimSpace(iconURL)
if iconURL != "" {
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, iconURL); err == nil {
feed.IconURL = absoluteIconURL
break
}
}
}
for _, item := range j.jsonFeed.Items {
entry := model.NewEntry()
entry.Title = strings.TrimSpace(item.Title)
entry.URL = strings.TrimSpace(item.URL)
// Make sure the entry URL is absolute.
if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
entry.URL = entryURL
}
// The entry title is optional, so we need to find a fallback.
if entry.Title == "" {
for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
if value != "" {
entry.Title = sanitizer.TruncateHTML(value, 100)
}
}
}
// Fallback to the entry URL if the title is empty.
if entry.Title == "" {
entry.Title = entry.URL
}
// Populate the entry content.
for _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {
value = strings.TrimSpace(value)
if value != "" {
entry.Content = value
break
}
}
// Populate the entry date.
for _, value := range []string{item.DatePublished, item.DateModified} {
value = strings.TrimSpace(value)
if value != "" {
if date, err := date.Parse(value); err != nil {
slog.Debug("Unable to parse date from JSON feed",
slog.String("date", value),
slog.String("url", entry.URL),
slog.Any("error", err),
)
} else {
entry.Date = date
break
}
}
}
if entry.Date.IsZero() {
entry.Date = time.Now()
}
// Populate the entry author.
itemAuthors := j.jsonFeed.Authors
itemAuthors = append(itemAuthors, item.Authors...)
itemAuthors = append(itemAuthors, item.Author, j.jsonFeed.Author)
var authorNames []string
for _, author := range itemAuthors {
authorName := strings.TrimSpace(author.Name)
if authorName != "" {
authorNames = append(authorNames, authorName)
}
}
slices.Sort(authorNames)
authorNames = slices.Compact(authorNames)
entry.Author = strings.Join(authorNames, ", ")
// Populate the entry enclosures.
for _, attachment := range item.Attachments {
attachmentURL := strings.TrimSpace(attachment.URL)
if attachmentURL != "" {
if absoluteAttachmentURL, err := urllib.AbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
URL: absoluteAttachmentURL,
MimeType: attachment.MimeType,
Size: attachment.Size,
})
}
}
}
// Populate the entry tags.
for _, tag := range item.Tags {
tag = strings.TrimSpace(tag)
if tag != "" {
entry.Tags = append(entry.Tags, tag)
}
}
// Generate a hash for the entry.
for _, value := range []string{item.ID, item.URL, item.ContentText + item.ContentHTML + item.Summary} {
value = strings.TrimSpace(value)
if value != "" {
entry.Hash = crypto.Hash(value)
break
}
}
feed.Entries = append(feed.Entries, entry)
}
return feed
}

Some files were not shown because too many files have changed in this diff Show More