Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d345c87376 | |||
| bd91e5f320 | |||
| 276b2d8b0b | |||
| bca9bea676 | |||
| 7346d751cc | |||
| 7939b54341 | |||
| a06657b74d | |||
| 2df59b4865 | |||
| 777d0dd248 | |||
| cfda948c3a | |||
| 14a6e8ed3a | |||
| c3e842eba6 | |||
| fd9cfd757a | |||
| 945d436055 | |||
| c3649bd6b1 | |||
| 6ad5ad0bb2 | |||
| 113abeea59 | |||
| e6185b1393 | |||
| 1b0b8b9c42 | |||
| 68448b4abb | |||
| 3caa16ac31 | |||
| 637fb85de0 | |||
| 02c6d14659 | |||
| 728423339a | |||
| eed3fcf92a | |||
| d5cfcf8956 | |||
| dea46ac0ea | |||
| 30c44380e0 | |||
| a913f3f75f | |||
| c1ef986cab | |||
| 2671f57edd | |||
| 2f56ebd3a6 | |||
| 059f5c0905 | |||
| 58178d90cb | |||
| cc885bbabb | |||
| 0e185849b4 | |||
| d0984f29da | |||
| 902ca63c45 | |||
| 2314500515 | |||
| 787d373211 | |||
| fefbf2c935 | |||
| bfb429b919 | |||
| 331c831c23 | |||
| 92a49d7e69 | |||
| 8cdf76df69 | |||
| 7bc0bffd85 | |||
| 3a18e5d205 | |||
| c3ca603960 | |||
| 7e2b50efee | |||
| b61ee15c1b | |||
| b7b0ccbf4b | |||
| 9c5228b2ee | |||
| 53beec685b | |||
| b1fb8be185 | |||
| 3a966c6ce5 | |||
| b2e702218d | |||
| 30c2e09a56 | |||
| c6c71c58b8 | |||
| 6eb1f25a53 | |||
| f0fe91172f | |||
| c3016a4c55 | |||
| 3a028a0669 | |||
| da951164d5 | |||
| a0988f6c16 | |||
| fddc861e41 | |||
| 9288001f09 | |||
| da261a5fc7 | |||
| edeb5f0366 | |||
| 65ff328804 | |||
| 7759ea1b43 | |||
| 7c5d6cf35f | |||
| 39cc1887ea | |||
| b0a3b4d5d9 | |||
| 469f23968e | |||
| 051bdecabd | |||
| fe10d4302d | |||
| b9dfd5bf6d | |||
| 51030ef1a8 | |||
| 2bcc4b8399 | |||
| ea4d0a4f72 | |||
| 5c5ad19c43 | |||
| 191f3a7ad7 | |||
| 366928b35d | |||
| 3b654fefa7 | |||
| 0adbcc3a04 | |||
| 7fdb450446 | |||
| 86285f5a05 | |||
| 8a4b4e459e | |||
| 879446bdb2 | |||
| ef633dc427 | |||
| 86c0cc61ba | |||
| ffe3ed4b9a | |||
| 5c4df786de | |||
| ee8c6621e9 | |||
| f748513df6 | |||
| e1050e21b5 | |||
| e555e442fb | |||
| 600dea6ce5 | |||
| e07203ad46 | |||
| f16735fd6d | |||
| 562a7b79a5 | |||
| cb610230d9 | |||
| 628b2b388d | |||
| faa70f3019 | |||
| cfe410f202 | |||
| c2ac2bfb83 | |||
| c326d5574b | |||
| f1c8c060c0 | |||
| 6944fb1e50 | |||
| 7d21298fab | |||
| 95201fc5cf | |||
| 5e335995e1 | |||
| 70f126fc5a | |||
| 38cdc4d3df | |||
| 4ab1cdd2e9 | |||
| f3e48505df | |||
| 60c75ab3b6 | |||
| 349f040921 | |||
| 74376cd33c | |||
| 2a4d2985c4 | |||
| 907941394b | |||
| 88ea0ade3e | |||
| fcf9fde118 | |||
| 5c38688783 | |||
| e0850fc648 | |||
| 8708a109b3 | |||
| 0fe787bb93 | |||
| 2dffcfeadc | |||
| 4bbc12e3b2 | |||
| 3e0e8dda2b | |||
| eb4bca6eb7 | |||
| 810b351772 | |||
| 89ff33ddd0 | |||
| f3a5a3ee14 | |||
| e98e16e45a | |||
| eb057d0415 | |||
| fa51c3ead7 | |||
| 56d7e4d5e9 | |||
| cc94ab704a | |||
| 9b8eabf036 | |||
| a8ac3dec47 | |||
| 6feee555ba | |||
| ee926e73cb | |||
| ade412f453 | |||
| 6fb7e84ce1 | |||
| 770cc1dbb3 | |||
| 59dac15bdf | |||
| da6aa36758 | |||
| 2a22fe6b75 | |||
| bcbf9f4025 | |||
| 569529d73b | |||
| 31cb06026d | |||
| d048d59d39 | |||
| 4f55361f5f | |||
| 37309adbc0 | |||
| 92f3dc26e4 | |||
| f6dc952551 | |||
| 29387f2d60 | |||
| c0f6e32a99 | |||
| 11cafec863 | |||
| 8cfe77a3cd | |||
| 8d4d092cd7 | |||
| 968355f9b9 | |||
| 3ca52c7f7f | |||
| 2e856a6bf0 | |||
| 36c25e7689 | |||
| cb97d4a1a8 | |||
| 79ea9e28b5 | |||
| f847c3e754 | |||
| 4ca19d123a | |||
| 90ef864edd | |||
| 01133c586f | |||
| b683756d8e | |||
| 3dfc70cee6 | |||
| 91b4a7d35f | |||
| 2fbe2df086 | |||
| 964698f363 | |||
| e34af65ae9 | |||
| e99a675912 | |||
| d96ad4ddef | |||
| 4272932402 | |||
| a60996e666 | |||
| a09ddbbaf4 | |||
| 92db691344 | |||
| a334c8e691 | |||
| bf1c851093 | |||
| c787bb5b48 | |||
| 1a81866bb9 | |||
| c4278821cb | |||
| 4498ba10e8 | |||
| a46e702536 | |||
| f0e8323f19 | |||
| a0106c9ffc | |||
| f98d5de484 | |||
| ee5e18ea9f | |||
| 3ef2522c62 | |||
| 839c4ad044 | |||
| 9f3a8e7f1b | |||
| e54825bf02 | |||
| 07f6d397d4 | |||
| f33e76eb8c | |||
| 84e97826d8 | |||
| 839fc3843a | |||
| 0bab8fac8e | |||
| 0cf1a40276 | |||
| 91479bc0ee | |||
| 251821289c | |||
| cac0bc682f | |||
| a733c14c61 | |||
| b68b05c64c | |||
| 5ce3f24838 | |||
| 48ddc02ba8 | |||
| fe9f1bba16 | |||
| 740fa4a5d2 | |||
| 8a38f54ef5 | |||
| ae432bc9c6 | |||
| 96f7e8bae0 | |||
| 1f35ed1675 | |||
| d6deac1810 | |||
| b692768730 | |||
| 2178580a75 | |||
| b52f61cc77 | |||
| 3388f8e376 | |||
| 83ceb20c1c | |||
| c06850ca34 | |||
| d856c02fbb | |||
| a33b1adf13 | |||
| a631bd527d | |||
| ca62b0b36b | |||
| 7d6a4243c1 | |||
| d056aa1f73 | |||
| 018e24404e | |||
| 4d3ee0d15d | |||
| 797450986b | |||
| 93bc9ce24d | |||
| 9233568da3 | |||
| fb075b60b5 | |||
| 2c4c845cd2 | |||
| 2caabbe939 | |||
| 771f9d2b5f | |||
| 647c66e70a | |||
| b205b5aad0 | |||
| 4ab0d9422d | |||
| 38b80d96ea | |||
| 35edd8ea92 | |||
| f0cb041885 | |||
| fdd1b3f18e | |||
| 6e870cdccc | |||
| 194f517be8 | |||
| 11fd1c935e | |||
| 47e1111908 | |||
| c5b812eb7b | |||
| 53be550e8a | |||
| d0d693a6ef | |||
| 1b8c45d162 | |||
| 19ce519836 | |||
| 3e0d5de7a3 |
@@ -1,7 +1,7 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/go:1.22
|
||||
image: mcr.microsoft.com/devcontainers/go:1.23
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
command: sleep infinity
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
labels: wishlist
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Do you follow the guidelines?
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] There is no breaking changes
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] I really tested my changes and there is no regression
|
||||
- [ ] Ideally, my commit messages use the same convention as the Go project: https://go.dev/doc/contribute#commit_messages
|
||||
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
check-latest: true
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -8,35 +8,8 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
jobs:
|
||||
test-docker-images:
|
||||
if: github.event.pull_request
|
||||
name: Test Images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Build Alpine image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
push: false
|
||||
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
|
||||
- name: Test Alpine Docker image
|
||||
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
|
||||
- name: Build Distroless image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
push: false
|
||||
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
|
||||
- name: Test Distroless Docker image
|
||||
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
|
||||
|
||||
publish-docker-images:
|
||||
if: ${{ ! github.event.pull_request }}
|
||||
name: Publish Images
|
||||
docker-images:
|
||||
name: Docker Images
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,33 +19,33 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Alpine Docker tag
|
||||
id: docker_alpine_tag
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
||||
DOCKER_VERSION=dev
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
DOCKER_VERSION=nightly
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
- name: Generate Alpine Docker tags
|
||||
id: docker_alpine_tags
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
ghcr.io/${{ github.repository_owner }}/miniflux
|
||||
quay.io/${{ github.repository_owner }}/miniflux
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=schedule,pattern=nightly
|
||||
type=semver,pattern={{raw}}
|
||||
|
||||
- name: Generate Distroless Docker tag
|
||||
id: docker_distroless_tag
|
||||
run: |
|
||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
||||
DOCKER_VERSION=dev-distroless
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
DOCKER_VERSION=nightly-distroless
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
|
||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
|
||||
fi
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
- name: Generate Distroless Docker tags
|
||||
id: docker_distroless_tags
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ github.repository_owner }}/miniflux
|
||||
ghcr.io/${{ github.repository_owner }}/miniflux
|
||||
quay.io/${{ github.repository_owner }}/miniflux
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=schedule,pattern=nightly
|
||||
type=semver,pattern={{raw}}
|
||||
flavor: |
|
||||
suffix=-distroless,onlatest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -81,12 +54,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -94,6 +69,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Quay Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
@@ -101,19 +77,21 @@ jobs:
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
|
||||
- name: Build and Push Alpine images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/alpine/Dockerfile
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||
|
||||
- name: Build and Push Distroless images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ vars.PUBLISH_DOCKER_IMAGES == 'true' }}
|
||||
with:
|
||||
context: .
|
||||
file: ./packaging/docker/distroless/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
|
||||
|
||||
@@ -28,12 +28,16 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
- run: "go vet ./..."
|
||||
- uses: golangci/golangci-lint-action@v4
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
|
||||
args: >
|
||||
--timeout 10m
|
||||
--exclude-dirs=tests
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
with:
|
||||
version: "2023.1.7"
|
||||
version: "2024.1.1"
|
||||
install-go: false
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.22.x"]
|
||||
go-version: ["1.23.x"]
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Postgres client
|
||||
|
||||
+5
-4
@@ -1,6 +1,7 @@
|
||||
miniflux-*
|
||||
./*.sha256
|
||||
./miniflux
|
||||
*.rpm
|
||||
*.deb
|
||||
.idea
|
||||
.vscode
|
||||
.vscode
|
||||
*.deb
|
||||
*.rpm
|
||||
miniflux-*
|
||||
@@ -1,3 +1,237 @@
|
||||
Version 2.2.4 (December 20, 2024)
|
||||
---------------------------------
|
||||
|
||||
* test(rewrite): add unit test for referer rewrite function
|
||||
* refactor(subscription): use `strings.HasSuffix` instead of a regex in `FindSubscriptionsFromYouTubePlaylistPage`
|
||||
* refactor(sanitizer): use `token.String()` instead of `html.EscapeString(token.Data)`
|
||||
* refactor(sanitizer): simplify `isValidTag`
|
||||
* refactor(sanitizer): simplify `hasRequiredAttributes`
|
||||
* refactor(sanitizer): remove condition because `config.Opts` is guaranteed to never be nil
|
||||
* refactor(sanitizer): remove a now-useless function after refactoring
|
||||
* refactor(sanitizer): refactor conditions to highlight their similitude, enabling further refactoring
|
||||
* refactor(sanitizer): optimize `strip_tags.go`
|
||||
* refactor(sanitizer): micro-optimizations of `srcset.go`
|
||||
* refactor(sanitizer): merge two conditions
|
||||
* refactor(sanitizer): inline a function in `sanitizeAttributes` and fix a bug in it
|
||||
* refactor(sanitizer): inline a condition in `sanitizeSrcsetAttr`
|
||||
* refactor(sanitizer): improve `rewriteIframeURL()`
|
||||
* refactor(sanitizer): Google+ isn't a thing anymore
|
||||
* refactor(sanitizer): change the scope of a variable
|
||||
* refactor(rewriter): replace regex with URL parsing for referrer override
|
||||
* refactor(rewriter): avoid the use of regex in `addDynamicImage`
|
||||
* refactor(rewrite): remove unused function arguments
|
||||
* refactor(readability): various improvements and optimizations
|
||||
* refactor(readability): simplify the regexes in `readability.go`
|
||||
* refactor(processor): use URL parsing instead of a regex
|
||||
* refactor(processor): improve the `rewrite` URL rule regex
|
||||
* refactor(locale): delay parsing of translations until they're used
|
||||
* refactor(js): factorise a line in `app.js`
|
||||
* refactor(handler): delay `store.UserByID()` as much as possible
|
||||
* refactor(css): replace `-ms-text-size-adjust` with `text-size-adjust`
|
||||
* refactor(css): remove `-webkit-clip-path`
|
||||
* refactor(css): factorise `.pagination-next` and `.pagination-last` together
|
||||
* refactor: use a better construct than `doc.Find(…).First()`
|
||||
* refactor: use `min/max` instead of `math.Min/math.Max`
|
||||
* refactor: refactor `internal/reader/readability/testdata`
|
||||
* refactor: optimize `sanitizeAttributes`
|
||||
* refactor: get rid of `numberOfPluralFormsPerLanguage` test-only variable
|
||||
* fix(storage): replace timezone function call with view
|
||||
* fix(consistency): align feed modification behavior between API and UI
|
||||
* fix(ci): fix grammar in pull-request template
|
||||
* fix: load icon from site URL instead of feed URL
|
||||
* fix: feed icon from xml ignored during force refresh
|
||||
* feat(rewrite)!: remove `parse_markdown` rewrite rule
|
||||
* feat(mediaproxy): update predefined referer spoofing rules for restricted media resources
|
||||
* feat(locale): update translations to clarify readeck URL instead of readeck API endpoint
|
||||
* feat(locale): update German translations
|
||||
* feat(locale): update Chinese translations
|
||||
* feat(apprise): update `SendNotification` to handle multiple entries and add logging
|
||||
* feat(apprise): add title in notification request body
|
||||
* feat: resize favicons before storing them in the database
|
||||
* feat: optionally fetch watch time from YouTube API instead of website
|
||||
* feat: only show the commit URL if it's not empty on `/about`
|
||||
* feat: add predefined scraper rules for `arstechnica.com`
|
||||
* feat: add date-based entry filtering rules
|
||||
* chore: remove `blog.laravel.com` rewrite rule
|
||||
* build(deps): bump `library/alpine` in `/packaging/docker/alpine` to `3.21`
|
||||
* build(deps): bump `golang.org/x/term` from `0.26.0` to `0.27.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.31.0` to `0.33.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.30.0` to `0.31.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.1` to `2.21.2`
|
||||
|
||||
Version 2.2.3 (November 10, 2024)
|
||||
---------------------------------
|
||||
|
||||
* fix: unable to change password due to a typo in SQL parameter
|
||||
* fix: show only one player when there are several audio/video enclosures
|
||||
* feat(mediaproxy): pass original filename in `Content-Disposition` header
|
||||
* feat(mediaproxy): implement referer spoofing for restricted media resources
|
||||
* feat(integration): update Shiori integration to use new API endpoints for login/bookmark
|
||||
* build(deps): bump `golang.org/x/text` from `0.19.0` to `0.20.0`
|
||||
* build(deps): bump `golang.org/x/term` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.30.0` to `0.31.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.28.0` to `0.29.0`
|
||||
|
||||
Version 2.2.2 (October 29, 2024)
|
||||
--------------------------------
|
||||
|
||||
* fix(webauthn): add backup eligibility flag workaround to avoid a 401 response
|
||||
* fix: update `Last-Modified` if it changes in a 304 response
|
||||
* feat(webauthn): show help message regarding username and non-discoverable credentials
|
||||
* feat(rss): calculate hash based on item title/content for feeds without GUID and link
|
||||
* feat(locale): update Chinese translations
|
||||
* feat(locale): update Polish translations
|
||||
* feat(integration): add Cubox integration
|
||||
* feat(client): add `custom_js` field to Go API client
|
||||
* feat(api): add endpoint for user integration status
|
||||
* feat: update feed icon during force refresh
|
||||
* feat: take `Retry-After` header into consideration for rate limited feeds
|
||||
* feat: set entry URL to rewritten URL if a rewrite rule is defined
|
||||
* feat: replace `xurls` third-party module with an ad-hoc regexp
|
||||
* feat: add new settings option to allow external fonts
|
||||
* feat: add custom user JavaScript similar to custom CSS
|
||||
* chore: update test case comment
|
||||
* build(deps): bump `golang.org/x/net` from `0.29.0` to `0.30.0`
|
||||
* build(deps): bump `github.com/yuin/goldmark` from `1.7.4` to `1.7.8`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.37` to `2.21.1`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build(deps): bump `github.com/andybalholm/brotli` from `1.1.0` to `1.1.1`
|
||||
|
||||
Version 2.2.1 (September 28, 2024)
|
||||
----------------------------------
|
||||
|
||||
* refactor: split processor package into smaller files
|
||||
* fix(mediaproxy): forward client user-agent to origin to bypass bot protection
|
||||
* fix: use root URL to generate absolute proxy URL
|
||||
* fix: remove progression save on shared entry
|
||||
* fix: add datasource variable and upgrade depecrated panels on the Grafana dashboard
|
||||
* feat(locale): update zh_CN translations
|
||||
* feat(locale): update Ukrainian translations
|
||||
* feat(locale): update Spanish translations
|
||||
* feat(locale): update Dutch translations
|
||||
* feat: use Bilibili API instead of web scraping to get videos watch time
|
||||
* feat: add pagination to shared entries listing
|
||||
* feat: add button to show only starred entries per category
|
||||
* build(deps): bump `golang.org/x/term` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.22.0` to `0.23.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.9.2` to `1.10.0`
|
||||
* build(deps): bump `github.com/prometheus/client_golang` from `1.20.3` to `1.20.4`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.10.2` to `0.11.2`
|
||||
* build: update go.mod to Go 1.23
|
||||
* build: bump devcontainer version to go 1.23
|
||||
|
||||
Version 2.2.0 (August 18, 2024)
|
||||
-------------------------------
|
||||
|
||||
* refactor: simplify Youtube feeds discovery
|
||||
* fix(integration): define content encoding explicitly when sending article body to Readeck
|
||||
* fix(fever): correct sorting direction when using `max_id` argument
|
||||
* fix(client): Return `nil` and error if endpoint is an empty string
|
||||
* fix: video poster image URL is encoded twice when using `MEDIA_PROXY_MODE=all`
|
||||
* fix: use `BASE_URL` instead of `r.Host` to generate absolute media proxy URL
|
||||
* fix: panic during YouTube channel feed discovery
|
||||
* fix: honor `hide_globally` when creating a new feed through the api
|
||||
* fix: align pagination correctly on small screens with non-English text
|
||||
* fix: `store.GetEnclosure()` should return `nil` if no rows are returned
|
||||
* feat(locale): update Turkish translations
|
||||
* feat(locale): update French translations
|
||||
* feat(locale): update Chinese` translations
|
||||
* feat(integration): add ntfy integration
|
||||
* feat(api): add API routes `/v1/enclosures/{enclosureID}`
|
||||
* feat: validate `OAUTH2_PROVIDER` config option value
|
||||
* feat: remove YouTube video page subscription finder because `meta[itemprop="channelId"]` no longer exists
|
||||
* feat: remove well-known URL parameter trackers
|
||||
* feat: mark media as read when playback reaches 90%
|
||||
* feat: change log level to info when running migrations
|
||||
* feat: allow customizing the display name of the OpenID Connect provider
|
||||
* feat: add support for `base` HTML element when discovering feeds
|
||||
* feat: add support for `aside` HTML element in entry content
|
||||
* feat: Add option to disable local auth form
|
||||
* feat: add license info to Javascript files for LibreJS compatibility
|
||||
* feat: add `FETCH_BILIBILI_WATCH_TIME` config option
|
||||
* docs: update links to filtering rules
|
||||
* chore: avoid using legacy key/value format in Dockerfile
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.21.0` to `0.22.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.27.0` to `0.28.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.36` to `2.20.37`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build: update GitHub Actions to Go 1.23
|
||||
* build: publish OCI images only if `PUBLISH_DOCKER_IMAGES=true`
|
||||
* build: bump Alpine Linux build image to v3.20
|
||||
* build: add sha256 checksum file for published binaries
|
||||
|
||||
Version 2.1.4 (July 9, 2024)
|
||||
----------------------------
|
||||
|
||||
* test: add unit tests for `IsModified()` behaviour
|
||||
* refactor: improve YouTube page feed detection
|
||||
* fix(ui): settings form is not populated correctly after validation errors
|
||||
* fix(ui): playback speed indicator precision
|
||||
* fix(ui): playback speed indicator on shared entries
|
||||
* fix(integration): preserve existing Pinboard bookmarks
|
||||
* fix(googlereader): set `CrawlTimeMsec` to the correct precision
|
||||
* fix(build): failed to solve container image `arm64v8/golang:1.22-bookworm`
|
||||
* fix(build): add `distroless` suffix on `latest` tag in GitHub workflow
|
||||
* fix: use `ETag` as a stronger validator than `Last-Modified`
|
||||
* fix: update `theverge.com` rewrite rule to avoid duplicate image
|
||||
* fix: incorrect Go package comment `reader/readingtime`
|
||||
* fix: error out for improper rewrite regexp when processing feed entries
|
||||
* fix: ensures that session cookies are not expiring before the session is cleaned up from the database as per `CLEANUP_REMOVE_SESSIONS_DAYS`
|
||||
* fix: `<img>` aspect ratio with `height: auto`
|
||||
* feat(ui): add `viewport-fit=cover`
|
||||
* feat(sanitizer): add support for HTML hidden attribute
|
||||
* feat(locale): update French translations
|
||||
* feat(integration): add Raindrop integration
|
||||
* feat(integration): add feed name to Telegram message
|
||||
* feat(integration): add Betula integration
|
||||
* feat: use of insecure TLS ciphers when "Allow self-signed or invalid certificates" is enabled to workaround some broken websites
|
||||
* feat: discover feeds from a Youtube playlist pages
|
||||
* feat: add navigation to last/first page
|
||||
* feat: add global block and keep filters
|
||||
* feat: add description field to feed settings
|
||||
* feat: add `pitchfork.com` scraping rule
|
||||
* feat: add `FETCH_NEBULA_WATCH_TIME` config option
|
||||
* Bump `github.com/PuerkitoBio/goquery` from` 1.9.1` to` 1.9.2`
|
||||
* Bump `github.com/prometheus/client_golang` from `1.19.0` to `1.19.1`
|
||||
* build(deps): bump `library/alpine` in `/packaging/docker/alpine`
|
||||
* build(deps): bump `golangci/golangci-lint-action` from `4` to `6`
|
||||
* build(deps): bump `golang.org/x/term` from `0.19.0` to `0.22.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.19.0` to `0.21.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.22.0` to `0.27.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.24.0` to `0.25.0`
|
||||
* build(deps): bump `github.com/yuin/goldmark` from `1.7.1` to `1.7.4`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.20` to `2.20.36`
|
||||
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.10.0` to `3.11.0`
|
||||
* build(deps): bump `docker/build-push-action` from `5` to `6`
|
||||
|
||||
Version 2.1.3 (April 27, 2024)
|
||||
------------------------------
|
||||
|
||||
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
|
||||
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
|
||||
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
|
||||
* `finder`: find feeds from YouTube playlist
|
||||
* `http/response`: add brotli compression support
|
||||
* `integration/matrix`: fix function name in comment
|
||||
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
|
||||
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
|
||||
* `reader/fetcher`: add brotli content encoding support
|
||||
* `reader/processor`: minimize feed entries HTML content
|
||||
* `reader/rewrite`: add a rule for `oglaf.com`
|
||||
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
|
||||
* `ui`: add seek and speed controls to media player
|
||||
* `ui`: add tag entries page
|
||||
* `ui`: fix JavaScript error when clicking on unread counter
|
||||
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
|
||||
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
|
||||
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
|
||||
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
|
||||
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
|
||||
|
||||
Version 2.1.2 (March 30, 2024)
|
||||
------------------------------
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DEB_IMG_ARCH := amd64
|
||||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||
DOCKER_PLATFORM := amd64
|
||||
|
||||
export PGPASSWORD := postgres
|
||||
|
||||
@@ -51,33 +51,43 @@ miniflux-no-pie:
|
||||
|
||||
linux-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-arm64:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv7:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv6:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
linux-armv5:
|
||||
@ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-amd64:
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
darwin-arm64:
|
||||
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
freebsd-amd64:
|
||||
@ CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
openbsd-amd64:
|
||||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ sha256sum $(APP)-$@ > $(APP)-$@.sha256
|
||||
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
@ sha256sum $(APP)-$@.exe > $(APP)-$@.exe.sha256
|
||||
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
|
||||
|
||||
@@ -104,7 +114,7 @@ run:
|
||||
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
@@ -163,15 +173,15 @@ rpm: clean
|
||||
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
|
||||
|
||||
debian:
|
||||
@ docker build --load \
|
||||
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
|
||||
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
|
||||
@ docker buildx build --load \
|
||||
--platform linux/$(DOCKER_PLATFORM) \
|
||||
-t miniflux-deb-builder \
|
||||
-f packaging/debian/Dockerfile \
|
||||
.
|
||||
@ docker run --rm \
|
||||
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
|
||||
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
|
||||
-v ${PWD}:/pkg miniflux-deb-builder
|
||||
|
||||
debian-packages: clean
|
||||
$(MAKE) debian DEB_IMG_ARCH=amd64
|
||||
$(MAKE) debian DEB_IMG_ARCH=arm64v8
|
||||
$(MAKE) debian DEB_IMG_ARCH=arm32v7
|
||||
$(MAKE) debian DOCKER_PLATFORM=amd64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm64
|
||||
$(MAKE) debian DOCKER_PLATFORM=arm/v7
|
||||
|
||||
@@ -185,6 +185,25 @@ func (c *Client) MarkAllAsRead(userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IntegrationsStatus fetches the integrations status for the logged user.
|
||||
func (c *Client) IntegrationsStatus() (bool, error) {
|
||||
body, err := c.request.Get("/v1/integrations/status")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var response struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&response); err != nil {
|
||||
return false, fmt.Errorf("miniflux: response error (%v)", err)
|
||||
}
|
||||
|
||||
return response.HasIntegrations, nil
|
||||
}
|
||||
|
||||
// Discover try to find subscriptions from a website.
|
||||
func (c *Client) Discover(url string) (Subscriptions, error) {
|
||||
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
|
||||
@@ -613,6 +632,28 @@ func (c *Client) Icon(iconID int64) (*FeedIcon, error) {
|
||||
return feedIcon, nil
|
||||
}
|
||||
|
||||
// Enclosure fetches a specific enclosure.
|
||||
func (c *Client) Enclosure(enclosureID int64) (*Enclosure, error) {
|
||||
body, err := c.request.Get(fmt.Sprintf("/v1/enclosures/%d", enclosureID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var enclosure *Enclosure
|
||||
if err := json.NewDecoder(body).Decode(&enclosure); err != nil {
|
||||
return nil, fmt.Errorf("miniflux: response error(%v)", err)
|
||||
}
|
||||
|
||||
return enclosure, nil
|
||||
}
|
||||
|
||||
// UpdateEnclosure updates an enclosure.
|
||||
func (c *Client) UpdateEnclosure(enclosureID int64, enclosureUpdate *EnclosureUpdateRequest) error {
|
||||
_, err := c.request.Put(fmt.Sprintf("/v1/enclosures/%d", enclosureID), enclosureUpdate)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildFilterQueryString(path string, filter *Filter) string {
|
||||
if filter != nil {
|
||||
values := url.Values{}
|
||||
@@ -685,6 +726,10 @@ func buildFilterQueryString(path string, filter *Filter) string {
|
||||
values.Set("feed_id", strconv.FormatInt(filter.FeedID, 10))
|
||||
}
|
||||
|
||||
if filter.GloballyVisible {
|
||||
values.Set("globally_visible", "true")
|
||||
}
|
||||
|
||||
for _, status := range filter.Statuses {
|
||||
values.Add("status", status)
|
||||
}
|
||||
|
||||
+20
-6
@@ -27,6 +27,7 @@ type User struct {
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
@@ -42,6 +43,9 @@ type User struct {
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
}
|
||||
|
||||
func (u User) String() string {
|
||||
@@ -68,6 +72,7 @@ type UserModificationRequest struct {
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
@@ -82,6 +87,9 @@ type UserModificationRequest struct {
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
}
|
||||
|
||||
// Users represents a list of users.
|
||||
@@ -238,12 +246,17 @@ type Entries []*Entry
|
||||
|
||||
// Enclosure represents an attachment.
|
||||
type Enclosure struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
EntryID int64 `json:"entry_id"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
EntryID int64 `json:"entry_id"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
type EnclosureUpdateRequest struct {
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
// Enclosures represents a list of attachments.
|
||||
@@ -274,6 +287,7 @@ type Filter struct {
|
||||
CategoryID int64
|
||||
FeedID int64
|
||||
Statuses []string
|
||||
GloballyVisible bool
|
||||
}
|
||||
|
||||
// EntryResultSet represents the response when fetching entries.
|
||||
|
||||
@@ -27,6 +27,7 @@ var (
|
||||
ErrServerError = errors.New("miniflux: internal server error")
|
||||
ErrNotFound = errors.New("miniflux: resource not found")
|
||||
ErrBadRequest = errors.New("miniflux: bad request")
|
||||
ErrEmptyEndpoint = errors.New("miniflux: empty endpoint provided")
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
@@ -62,6 +63,9 @@ func (r *request) Delete(path string) error {
|
||||
}
|
||||
|
||||
func (r *request) execute(method, path string, data interface{}) (io.ReadCloser, error) {
|
||||
if r.endpoint == "" {
|
||||
return nil, ErrEmptyEndpoint
|
||||
}
|
||||
if r.endpoint[len(r.endpoint)-1:] == "/" {
|
||||
r.endpoint = r.endpoint[:len(r.endpoint)-1]
|
||||
}
|
||||
|
||||
+1639
-1458
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,48 @@
|
||||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.22
|
||||
// +heroku goVersion go1.23
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.1
|
||||
github.com/PuerkitoBio/goquery v1.10.0
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/coreos/go-oidc/v3 v3.10.0
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/tdewolff/minify/v2 v2.20.19
|
||||
github.com/yuin/goldmark v1.7.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/net v0.22.0
|
||||
golang.org/x/oauth2 v0.18.0
|
||||
golang.org/x/term v0.18.0
|
||||
golang.org/x/text v0.14.0
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/tdewolff/minify/v2 v2.21.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.12 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.19 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
@@ -1,72 +1,77 @@
|
||||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
|
||||
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
|
||||
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo=
|
||||
github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
|
||||
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
|
||||
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
|
||||
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -74,10 +79,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -88,35 +93,27 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||
|
||||
@@ -72,6 +72,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
||||
sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/flush-history", handler.flushHistory).Methods(http.MethodPut, http.MethodDelete)
|
||||
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
|
||||
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
|
||||
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -58,7 +57,7 @@ func (c *integrationTestConfig) isConfigured() bool {
|
||||
}
|
||||
|
||||
func (c *integrationTestConfig) genRandomUsername() string {
|
||||
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Intn(math.MaxInt64))
|
||||
return fmt.Sprintf("%s_%10d", c.testRegularUsername, rand.Int())
|
||||
}
|
||||
|
||||
func TestIncorrectEndpoint(t *testing.T) {
|
||||
@@ -68,10 +67,14 @@ func TestIncorrectEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
client := miniflux.NewClient("incorrect url")
|
||||
_, err := client.Users()
|
||||
if err == nil {
|
||||
if _, err := client.Users(); err == nil {
|
||||
t.Fatal(`Using an incorrect URL should raise an error`)
|
||||
}
|
||||
|
||||
client = miniflux.NewClient("")
|
||||
if _, err := client.Users(); err == nil {
|
||||
t.Fatal(`Using an empty URL should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckEndpoint(t *testing.T) {
|
||||
@@ -589,6 +592,88 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedUser.ExternalFontHosts != "fonts.example.org" {
|
||||
t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
|
||||
}
|
||||
|
||||
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
|
||||
t.Fatal(`Updating the user with an invalid external font host should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
userUpdateRequest := &miniflux.UserModificationRequest{
|
||||
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
|
||||
}
|
||||
|
||||
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedUser.CustomJS != "alert('Hello, World!');" {
|
||||
t.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -1987,6 +2072,176 @@ func TestGetAllEntriesEndpointWithFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGlobalEntriesEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
HideGlobally: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedIDEntry, err := regularUserClient.Feed(feedID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedIDEntry.HideGlobally != true {
|
||||
t.Fatalf(`Expected feed to have globally_hidden set to true, was false.`)
|
||||
}
|
||||
|
||||
/* Not filtering on GloballyVisible should return all entries */
|
||||
feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feedEntries.Entries) == 0 {
|
||||
t.Fatalf(`Expected entries but response contained none.`)
|
||||
}
|
||||
|
||||
/* Feed is hidden globally, so this should be empty */
|
||||
globallyVisibleEntries, err := regularUserClient.Entries(&miniflux.Filter{GloballyVisible: true})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(globallyVisibleEntries.Entries) != 0 {
|
||||
t.Fatalf(`Expected no entries, got %d`, len(globallyVisibleEntries.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
var enclosure *miniflux.Enclosure
|
||||
for _, entry := range result.Entries {
|
||||
if len(entry.Enclosures) > 0 {
|
||||
enclosure = entry.Enclosures[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
t.Skip(`Skipping test, missing enclosure in feed.`)
|
||||
}
|
||||
|
||||
err = regularUserClient.UpdateEnclosure(enclosure.ID, &miniflux.EnclosureUpdateRequest{
|
||||
MediaProgression: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updatedEnclosure, err := regularUserClient.Enclosure(enclosure.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedEnclosure.MediaProgression != 20 {
|
||||
t.Fatalf(`Failed to update media_progression, expected %d but got %d`, 20, updatedEnclosure.MediaProgression)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnclosureEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
|
||||
FeedURL: testConfig.testFeedURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := regularUserClient.FeedEntries(feedID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf(`Failed to get entries: %v`, err)
|
||||
}
|
||||
|
||||
var expectedEnclosure *miniflux.Enclosure
|
||||
for _, entry := range result.Entries {
|
||||
if len(entry.Enclosures) > 0 {
|
||||
expectedEnclosure = entry.Enclosures[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if expectedEnclosure == nil {
|
||||
t.Skip(`Skipping test, missing enclosure in feed.`)
|
||||
}
|
||||
|
||||
enclosure, err := regularUserClient.Enclosure(expectedEnclosure.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if enclosure.ID != expectedEnclosure.ID {
|
||||
t.Fatalf(`Invalid enclosureID, got %d while expecting %d`, enclosure.ID, expectedEnclosure.ID)
|
||||
}
|
||||
|
||||
if _, err = regularUserClient.Enclosure(99999); err == nil {
|
||||
t.Fatalf(`Fetching an inexisting enclosure should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntryEndpoints(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
@@ -2228,6 +2483,32 @@ func TestSaveEntryEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIntegrationsStatusEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
t.Skip(skipIntegrationTestsMessage)
|
||||
}
|
||||
|
||||
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
|
||||
|
||||
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adminClient.DeleteUser(regularTestUser.ID)
|
||||
|
||||
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
|
||||
|
||||
hasIntegrations, err := regularUserClient.IntegrationsStatus()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch integrations status: %v", err)
|
||||
}
|
||||
|
||||
if hasIntegrations {
|
||||
t.Fatalf("New user should not have integrations configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchContentEndpoint(t *testing.T) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api // import "miniflux.app/v2/internal/api"
|
||||
|
||||
import (
|
||||
json_parser "encoding/json"
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.ProxifyEnclosureURL(h.router)
|
||||
|
||||
json.OK(w, r, enclosure)
|
||||
}
|
||||
|
||||
func (h *handler) updateEnclosureByID(w http.ResponseWriter, r *http.Request) {
|
||||
enclosureID := request.RouteInt64Param(r, "enclosureID")
|
||||
|
||||
var enclosureUpdateRequest model.EnclosureUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEnclosureUpdateRequest(&enclosureUpdateRequest); err != nil {
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure, err := h.store.GetEnclosure(enclosureID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if enclosure == nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := request.UserID(r)
|
||||
if enclosure.UserID != userID {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
enclosure.MediaProgression = enclosureUpdateRequest.MediaProgression
|
||||
if err := h.store.UpdateEnclosure(enclosure); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
+12
-16
@@ -8,10 +8,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/integration"
|
||||
@@ -20,7 +18,6 @@ import (
|
||||
"miniflux.app/v2/internal/reader/processor"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
)
|
||||
|
||||
@@ -36,19 +33,9 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
|
||||
return
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
for i := range entry.Enclosures {
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
json.OK(w, r, entry)
|
||||
}
|
||||
@@ -149,6 +136,15 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
builder.WithLimit(limit)
|
||||
builder.WithTags(tags)
|
||||
builder.WithEnclosures()
|
||||
|
||||
if request.HasQueryParam(r, "globally_visible") {
|
||||
globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
|
||||
|
||||
if globallyVisible {
|
||||
builder.WithGloballyVisible()
|
||||
}
|
||||
}
|
||||
|
||||
configureFilters(builder, r)
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
@@ -164,7 +160,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||
}
|
||||
|
||||
for i := range entries {
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entries[i].Content)
|
||||
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entries[i].Content)
|
||||
}
|
||||
|
||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||
|
||||
@@ -121,6 +121,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
feedModificationRequest.Patch(originalFeed)
|
||||
originalFeed.ResetErrorCounter()
|
||||
if err := h.store.UpdateFeed(originalFeed); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
|
||||
feedID := request.RouteInt64Param(r, "feedID")
|
||||
|
||||
if !h.store.HasIcon(feedID) {
|
||||
if !h.store.HasFeedIcon(feedID) {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
json_parser "encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
@@ -82,6 +84,18 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
cleanEnd := regexp.MustCompile(`(?m)\r\n\s*$`)
|
||||
if userModificationRequest.BlockFilterEntryRules != nil {
|
||||
*userModificationRequest.BlockFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.BlockFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.BlockFilterEntryRules = strings.ReplaceAll(*userModificationRequest.BlockFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
if userModificationRequest.KeepFilterEntryRules != nil {
|
||||
*userModificationRequest.KeepFilterEntryRules = cleanEnd.ReplaceAllLiteralString(*userModificationRequest.KeepFilterEntryRules, "")
|
||||
// Clean carriage returns for Windows environments
|
||||
*userModificationRequest.KeepFilterEntryRules = strings.ReplaceAll(*userModificationRequest.KeepFilterEntryRules, "\r\n", "\n")
|
||||
}
|
||||
|
||||
if validationErr := validator.ValidateUserModification(h.store, originalUser.ID, &userModificationRequest); validationErr != nil {
|
||||
json.BadRequest(w, r, validationErr.Error())
|
||||
return
|
||||
@@ -116,6 +130,25 @@ func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
|
||||
if _, err := h.store.UserByID(userID); err != nil {
|
||||
json.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hasIntegrations := h.store.HasSaveEntry(userID)
|
||||
|
||||
response := struct {
|
||||
HasIntegrations bool `json:"has_integrations"`
|
||||
}{
|
||||
HasIntegrations: hasIntegrations,
|
||||
}
|
||||
|
||||
json.OK(w, r, response)
|
||||
}
|
||||
|
||||
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
if !request.IsAdminUser(r) {
|
||||
json.Forbidden(w, r)
|
||||
|
||||
+18
-5
@@ -4,6 +4,7 @@
|
||||
package cli // import "miniflux.app/v2/internal/cli"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/database"
|
||||
"miniflux.app/v2/internal/locale"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/ui/static"
|
||||
"miniflux.app/v2/internal/version"
|
||||
@@ -88,6 +88,23 @@ func Parse() {
|
||||
printErrorAndExit(err)
|
||||
}
|
||||
|
||||
if oauth2Provider := config.Opts.OAuth2Provider(); oauth2Provider != "" {
|
||||
if oauth2Provider != "oidc" && oauth2Provider != "google" {
|
||||
printErrorAndExit(fmt.Errorf(`unsupported OAuth2 provider: %q (Possible values are "google" or "oidc")`, oauth2Provider))
|
||||
}
|
||||
}
|
||||
|
||||
if config.Opts.DisableLocalAuth() {
|
||||
switch {
|
||||
case config.Opts.OAuth2Provider() == "" && config.Opts.AuthProxyHeader() == "":
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled but neither OAUTH2_PROVIDER nor AUTH_PROXY_HEADER is not set. Please enable at least one authentication source"))
|
||||
case config.Opts.OAuth2Provider() != "" && !config.Opts.IsOAuth2UserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an OAUTH2_PROVIDER is configured, but OAUTH2_USER_CREATION is not enabled"))
|
||||
case config.Opts.AuthProxyHeader() != "" && !config.Opts.IsAuthProxyUserCreationAllowed():
|
||||
printErrorAndExit(errors.New("DISABLE_LOCAL_AUTH is enabled and an AUTH_PROXY_HEADER is configured, but AUTH_PROXY_USER_CREATION is not enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
if flagConfigDump {
|
||||
fmt.Print(config.Opts)
|
||||
return
|
||||
@@ -135,10 +152,6 @@ func Parse() {
|
||||
slog.Info("The default value for DATABASE_URL is used")
|
||||
}
|
||||
|
||||
if err := locale.LoadCatalogMessages(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to load translations: %v", err))
|
||||
}
|
||||
|
||||
if err := static.CalculateBinaryFileChecksums(); err != nil {
|
||||
printErrorAndExit(fmt.Errorf("unable to calculate binary file checksums: %v", err))
|
||||
}
|
||||
|
||||
@@ -259,6 +259,29 @@ func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomBaseURLWithCustomPort(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "http://example.org:88/folder/")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
if opts.BaseURL() != "http://example.org:88/folder" {
|
||||
t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
|
||||
}
|
||||
|
||||
if opts.RootURL() != "http://example.org:88" {
|
||||
t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
|
||||
}
|
||||
|
||||
if opts.BasePath() != "/folder" {
|
||||
t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLWithoutScheme(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("BASE_URL", "example.org/folder/")
|
||||
@@ -2021,6 +2044,42 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBilibiliWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_BILIBILI_WATCH_TIME", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.FetchBilibiliWatchTime()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected FETCH_BILIBILI_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNebulaWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := true
|
||||
result := opts.FetchNebulaWatchTime()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOdyseeWatchTime(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
|
||||
@@ -2057,6 +2116,24 @@ func TestFetchYouTubeWatchTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeApiKey(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
|
||||
|
||||
parser := NewParser()
|
||||
opts, err := parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
|
||||
result := opts.YouTubeApiKey()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeEmbedUrlOverride(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
|
||||
|
||||
@@ -56,8 +56,11 @@ const (
|
||||
defaultMediaResourceTypes = "image"
|
||||
defaultMediaProxyURL = ""
|
||||
defaultFilterEntryMaxAgeDays = 0
|
||||
defaultFetchBilibiliWatchTime = false
|
||||
defaultFetchNebulaWatchTime = false
|
||||
defaultFetchOdyseeWatchTime = false
|
||||
defaultFetchYouTubeWatchTime = false
|
||||
defaultYouTubeApiKey = ""
|
||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||
defaultCreateAdmin = false
|
||||
defaultAdminUsername = ""
|
||||
@@ -67,7 +70,9 @@ const (
|
||||
defaultOAuth2ClientSecret = ""
|
||||
defaultOAuth2RedirectURL = ""
|
||||
defaultOAuth2OidcDiscoveryEndpoint = ""
|
||||
defaultOauth2OidcProviderName = "OpenID Connect"
|
||||
defaultOAuth2Provider = ""
|
||||
defaultDisableLocalAuth = false
|
||||
defaultPocketConsumerKey = ""
|
||||
defaultHTTPClientTimeout = 20
|
||||
defaultHTTPClientMaxBodySize = 15
|
||||
@@ -140,16 +145,21 @@ type Options struct {
|
||||
mediaProxyMode string
|
||||
mediaProxyResourceTypes []string
|
||||
mediaProxyCustomURL string
|
||||
fetchBilibiliWatchTime bool
|
||||
fetchNebulaWatchTime bool
|
||||
fetchOdyseeWatchTime bool
|
||||
fetchYouTubeWatchTime bool
|
||||
filterEntryMaxAgeDays int
|
||||
youTubeApiKey string
|
||||
youTubeEmbedUrlOverride string
|
||||
oauth2UserCreationAllowed bool
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
oauth2RedirectURL string
|
||||
oidcDiscoveryEndpoint string
|
||||
oidcProviderName string
|
||||
oauth2Provider string
|
||||
disableLocalAuth bool
|
||||
pocketConsumerKey string
|
||||
httpClientTimeout int
|
||||
httpClientMaxBodySize int64
|
||||
@@ -216,15 +226,20 @@ func NewOptions() *Options {
|
||||
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||
fetchBilibiliWatchTime: defaultFetchBilibiliWatchTime,
|
||||
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||
youTubeApiKey: defaultYouTubeApiKey,
|
||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
|
||||
oauth2ClientID: defaultOAuth2ClientID,
|
||||
oauth2ClientSecret: defaultOAuth2ClientSecret,
|
||||
oauth2RedirectURL: defaultOAuth2RedirectURL,
|
||||
oidcDiscoveryEndpoint: defaultOAuth2OidcDiscoveryEndpoint,
|
||||
oidcProviderName: defaultOauth2OidcProviderName,
|
||||
oauth2Provider: defaultOAuth2Provider,
|
||||
disableLocalAuth: defaultDisableLocalAuth,
|
||||
pocketConsumerKey: defaultPocketConsumerKey,
|
||||
httpClientTimeout: defaultHTTPClientTimeout,
|
||||
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
|
||||
@@ -445,11 +460,21 @@ func (o *Options) OIDCDiscoveryEndpoint() string {
|
||||
return o.oidcDiscoveryEndpoint
|
||||
}
|
||||
|
||||
// OIDCProviderName returns the OAuth2 OIDC provider's display name
|
||||
func (o *Options) OIDCProviderName() string {
|
||||
return o.oidcProviderName
|
||||
}
|
||||
|
||||
// OAuth2Provider returns the name of the OAuth2 provider configured.
|
||||
func (o *Options) OAuth2Provider() string {
|
||||
return o.oauth2Provider
|
||||
}
|
||||
|
||||
// DisableLocalAUth returns true if the local user database should not be used to authenticate users
|
||||
func (o *Options) DisableLocalAuth() bool {
|
||||
return o.disableLocalAuth
|
||||
}
|
||||
|
||||
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
|
||||
func (o *Options) HasHSTS() bool {
|
||||
return o.hsts
|
||||
@@ -481,17 +506,34 @@ func (o *Options) FetchYouTubeWatchTime() bool {
|
||||
return o.fetchYouTubeWatchTime
|
||||
}
|
||||
|
||||
// YouTubeApiKey returns the YouTube API key if defined.
|
||||
func (o *Options) YouTubeApiKey() string {
|
||||
return o.youTubeApiKey
|
||||
}
|
||||
|
||||
// YouTubeEmbedUrlOverride returns YouTube URL which will be used for embeds
|
||||
func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||
return o.youTubeEmbedUrlOverride
|
||||
}
|
||||
|
||||
// FetchNebulaWatchTime returns true if the Nebula video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchNebulaWatchTime() bool {
|
||||
return o.fetchNebulaWatchTime
|
||||
}
|
||||
|
||||
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchOdyseeWatchTime() bool {
|
||||
return o.fetchOdyseeWatchTime
|
||||
}
|
||||
|
||||
// FetchBilibiliWatchTime returns true if the Bilibili video duration
|
||||
// should be fetched and used as a reading time.
|
||||
func (o *Options) FetchBilibiliWatchTime() bool {
|
||||
return o.fetchBilibiliWatchTime
|
||||
}
|
||||
|
||||
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||
func (o *Options) MediaProxyMode() string {
|
||||
return o.mediaProxyMode
|
||||
@@ -647,7 +689,9 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
|
||||
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
|
||||
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
|
||||
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
|
||||
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
|
||||
"FETCH_BILIBILI_WATCH_TIME": o.fetchBilibiliWatchTime,
|
||||
"HTTPS": o.HTTPS,
|
||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||
"HTTP_CLIENT_PROXY": o.httpClientProxy,
|
||||
@@ -672,9 +716,11 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"OAUTH2_CLIENT_ID": o.oauth2ClientID,
|
||||
"OAUTH2_CLIENT_SECRET": redactSecretValue(o.oauth2ClientSecret, redactSecret),
|
||||
"OAUTH2_OIDC_DISCOVERY_ENDPOINT": o.oidcDiscoveryEndpoint,
|
||||
"OAUTH2_OIDC_PROVIDER_NAME": o.oidcProviderName,
|
||||
"OAUTH2_PROVIDER": o.oauth2Provider,
|
||||
"OAUTH2_REDIRECT_URL": o.oauth2RedirectURL,
|
||||
"OAUTH2_USER_CREATION": o.oauth2UserCreationAllowed,
|
||||
"DISABLE_LOCAL_AUTH": o.disableLocalAuth,
|
||||
"POCKET_CONSUMER_KEY": redactSecretValue(o.pocketConsumerKey, redactSecret),
|
||||
"POLLING_FREQUENCY": o.pollingFrequency,
|
||||
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
|
||||
@@ -695,6 +741,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||
"SERVER_TIMING_HEADER": o.serverTimingHeader,
|
||||
"WATCHDOG": o.watchdog,
|
||||
"WORKER_POOL_SIZE": o.workerPoolSize,
|
||||
"YOUTUBE_API_KEY": redactSecretValue(o.youTubeApiKey, redactSecret),
|
||||
"YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride,
|
||||
"WEBAUTHN": o.webAuthn,
|
||||
}
|
||||
|
||||
@@ -225,8 +225,12 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
|
||||
case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
|
||||
p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
|
||||
case "OAUTH2_OIDC_PROVIDER_NAME":
|
||||
p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
|
||||
case "OAUTH2_PROVIDER":
|
||||
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
|
||||
case "DISABLE_LOCAL_AUTH":
|
||||
p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
|
||||
case "HTTP_CLIENT_TIMEOUT":
|
||||
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
|
||||
case "HTTP_CLIENT_MAX_BODY_SIZE":
|
||||
@@ -259,10 +263,16 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||
case "METRICS_PASSWORD_FILE":
|
||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||
case "FETCH_BILIBILI_WATCH_TIME":
|
||||
p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
|
||||
case "FETCH_NEBULA_WATCH_TIME":
|
||||
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||
case "FETCH_ODYSEE_WATCH_TIME":
|
||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
|
||||
case "YOUTUBE_API_KEY":
|
||||
p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
|
||||
case "YOUTUBE_EMBED_URL_OVERRIDE":
|
||||
p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
|
||||
case "WATCHDOG":
|
||||
|
||||
@@ -32,7 +32,7 @@ func Migrate(db *sql.DB) error {
|
||||
var currentVersion int
|
||||
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||
|
||||
slog.Debug("Running database migrations",
|
||||
slog.Info("Running database migrations",
|
||||
slog.Int("current_version", currentVersion),
|
||||
slog.Int("latest_version", schemaVersion),
|
||||
)
|
||||
|
||||
@@ -882,4 +882,82 @@ var migrations = []func(tx *sql.Tx) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
// Entry URLs can exceeds btree maximum size
|
||||
// Checking entry existence is now using entries_feed_id_status_hash_idx index
|
||||
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
|
||||
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE users
|
||||
ADD COLUMN block_filter_entry_rules text not null default '',
|
||||
ADD COLUMN keep_filter_entry_rules text not null default ''
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN betula_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN betula_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN betula_enabled bool default 'f';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_topic text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_api_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_username text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_password text default '';
|
||||
ALTER TABLE integrations ADD COLUMN ntfy_icon_url text default '';
|
||||
|
||||
ALTER TABLE feeds ADD COLUMN ntfy_enabled bool default 'f';
|
||||
ALTER TABLE feeds ADD COLUMN ntfy_priority int default '3';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN mark_read_on_media_player_completion bool default 'f';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -247,7 +247,6 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithLimit(50)
|
||||
builder.WithSorting("id", model.DefaultSortingDirection)
|
||||
|
||||
switch {
|
||||
case request.HasQueryParam(r, "since_id"):
|
||||
@@ -258,6 +257,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Int64("since_id", sinceID),
|
||||
)
|
||||
builder.AfterEntryID(sinceID)
|
||||
builder.WithSorting("id", "ASC")
|
||||
}
|
||||
case request.HasQueryParam(r, "max_id"):
|
||||
maxID := request.QueryInt64Param(r, "max_id", 0)
|
||||
@@ -324,7 +324,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||
FeedID: entry.FeedID,
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content),
|
||||
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
|
||||
URL: entry.URL,
|
||||
IsSaved: isSaved,
|
||||
IsRead: isRead,
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
mff "miniflux.app/v2/internal/reader/handler"
|
||||
mfs "miniflux.app/v2/internal/reader/subscription"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/validator"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -1003,28 +1002,18 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
categories = append(categories, userStarred)
|
||||
}
|
||||
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content)
|
||||
|
||||
for i := range entry.Enclosures {
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Enclosures.ProxifyEnclosureURL(h.router)
|
||||
|
||||
contentItems[i] = contentItem{
|
||||
ID: fmt.Sprintf(EntryIDLong, entry.ID),
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
|
||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
|
||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
|
||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
|
||||
Published: entry.Date.Unix(),
|
||||
Updated: entry.Date.Unix(),
|
||||
Updated: entry.ChangedAt.Unix(),
|
||||
Categories: categories,
|
||||
Canonical: []contentHREF{
|
||||
{
|
||||
|
||||
@@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
)
|
||||
|
||||
// Cookie names.
|
||||
const (
|
||||
CookieAppSessionID = "MinifluxAppSessionID"
|
||||
CookieUserSessionID = "MinifluxUserSessionID"
|
||||
|
||||
// Cookie duration in days.
|
||||
cookieDuration = 30
|
||||
)
|
||||
|
||||
// New creates a new cookie.
|
||||
@@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||
Path: basePath(path),
|
||||
Secure: isHTTPS,
|
||||
HttpOnly: true,
|
||||
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
|
||||
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
const compressionThreshold = 1024
|
||||
@@ -110,8 +112,15 @@ func (b *Builder) writeHeaders() {
|
||||
func (b *Builder) compress(data []byte) {
|
||||
if b.enableCompression && len(data) > compressionThreshold {
|
||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||
|
||||
switch {
|
||||
case strings.Contains(acceptEncoding, "br"):
|
||||
b.headers["Content-Encoding"] = "br"
|
||||
b.writeHeaders()
|
||||
|
||||
brotliWriter := brotli.NewWriterV2(b.w, brotli.DefaultCompression)
|
||||
defer brotliWriter.Close()
|
||||
brotliWriter.Write(data)
|
||||
return
|
||||
case strings.Contains(acceptEncoding, "gzip"):
|
||||
b.headers["Content-Encoding"] = "gzip"
|
||||
b.writeHeaders()
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
func TestBuildResponseWithBrotliCompression(t *testing.T) {
|
||||
body := strings.Repeat("a", compressionThreshold+1)
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
@@ -245,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expected := "br"
|
||||
actual := resp.Header.Get("Content-Encoding")
|
||||
if actual != expected {
|
||||
t.Fatalf(`Unexpected header value, got %q instead of %q`, actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||
body := strings.Repeat("a", compressionThreshold+1)
|
||||
r, err := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
New(w, r).WithBody(body).Write()
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expected := "gzip"
|
||||
actual := resp.Header.Get("Content-Encoding")
|
||||
if actual != expected {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -26,42 +27,53 @@ func NewClient(serviceURL, baseURL string) *Client {
|
||||
return &Client{serviceURL, baseURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendNotification(entry *model.Entry) error {
|
||||
func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error {
|
||||
if c.baseURL == "" || c.servicesURL == "" {
|
||||
return fmt.Errorf("apprise: missing base URL or services URL")
|
||||
}
|
||||
|
||||
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"urls": c.servicesURL,
|
||||
"body": message,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to encode request body: %v", err)
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"urls": c.servicesURL,
|
||||
"body": message,
|
||||
"title": feed.Title,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to create request: %v", err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
slog.Debug("Sending Apprise notification",
|
||||
slog.String("apprise_url", c.baseURL),
|
||||
slog.String("services_url", c.servicesURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("body", message),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package betula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
url string
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(url, token string) *Client {
|
||||
return &Client{url: url, token: token}
|
||||
}
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) error {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.url, "/save-link")
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to generate save-link endpoint: %v", err)
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("url", entryURL)
|
||||
values.Add("title", entryTitle)
|
||||
values.Add("tags", strings.Join(tags, ","))
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, apiEndpoint+"?"+values.Encode(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("betula: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("betula: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Cubox API documentation: https://help.cubox.cc/save/api/
|
||||
|
||||
package cubox // import "miniflux.app/v2/internal/integration/cubox"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
apiLink string
|
||||
}
|
||||
|
||||
func NewClient(apiLink string) *Client {
|
||||
return &Client{apiLink: apiLink}
|
||||
}
|
||||
|
||||
func (c *Client) SaveLink(entryURL string) error {
|
||||
if c.apiLink == "" {
|
||||
return errors.New("cubox: missing API link")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&card{
|
||||
Type: "url",
|
||||
Content: entryURL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to encode request body: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to create request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cubox: unable to send request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return fmt.Errorf("cubox: unable to save link: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/integration/apprise"
|
||||
"miniflux.app/v2/internal/integration/betula"
|
||||
"miniflux.app/v2/internal/integration/cubox"
|
||||
"miniflux.app/v2/internal/integration/espial"
|
||||
"miniflux.app/v2/internal/integration/instapaper"
|
||||
"miniflux.app/v2/internal/integration/linkace"
|
||||
@@ -15,10 +17,12 @@ import (
|
||||
"miniflux.app/v2/internal/integration/linkwarden"
|
||||
"miniflux.app/v2/internal/integration/matrixbot"
|
||||
"miniflux.app/v2/internal/integration/notion"
|
||||
"miniflux.app/v2/internal/integration/ntfy"
|
||||
"miniflux.app/v2/internal/integration/nunuxkeeper"
|
||||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
@@ -31,6 +35,30 @@ import (
|
||||
|
||||
// SendEntry sends the entry to third-party providers when the user click on "Save".
|
||||
func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
if userIntegrations.BetulaEnabled {
|
||||
slog.Debug("Sending entry to Betula",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := betula.NewClient(userIntegrations.BetulaURL, userIntegrations.BetulaToken)
|
||||
err := client.CreateBookmark(
|
||||
entry.URL,
|
||||
entry.Title,
|
||||
entry.Tags,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Unable to send entry to Betula",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PinboardEnabled {
|
||||
slog.Debug("Sending entry to Pinboard",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -295,6 +323,25 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.CuboxEnabled {
|
||||
slog.Debug("Sending entry to Cubox",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := cubox.NewClient(userIntegrations.CuboxAPILink)
|
||||
|
||||
if err := client.SaveLink(entry.URL); err != nil {
|
||||
slog.Error("Unable to send entry to Cubox",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.ShioriEnabled {
|
||||
slog.Debug("Sending entry to Shiori",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -359,6 +406,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.OmnivoreEnabled {
|
||||
slog.Debug("Sending entry to Omnivore",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
@@ -376,6 +424,24 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.RaindropEnabled {
|
||||
slog.Debug("Sending entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := raindrop.NewClient(userIntegrations.RaindropToken, userIntegrations.RaindropCollectionID, userIntegrations.RaindropTags)
|
||||
if err := client.CreateRaindrop(entry.URL, entry.Title); err != nil {
|
||||
slog.Error("Unable to send entry to Raindrop",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
|
||||
@@ -425,8 +491,52 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.AppriseEnabled {
|
||||
slog.Debug("Sending new entries to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
appriseServiceURLs := userIntegrations.AppriseServicesURL
|
||||
if feed.AppriseServiceURLs != "" {
|
||||
appriseServiceURLs = feed.AppriseServiceURLs
|
||||
}
|
||||
|
||||
client := apprise.NewClient(
|
||||
appriseServiceURLs,
|
||||
userIntegrations.AppriseURL,
|
||||
)
|
||||
|
||||
if err := client.SendNotification(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Apprise", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
slog.Debug("Sending a new entry to Telegram",
|
||||
@@ -453,35 +563,6 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.AppriseEnabled {
|
||||
slog.Debug("Sending a new entry to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("apprise_url", userIntegrations.AppriseURL),
|
||||
)
|
||||
|
||||
appriseServiceURLs := userIntegrations.AppriseServicesURL
|
||||
if feed.AppriseServiceURLs != "" {
|
||||
appriseServiceURLs = feed.AppriseServiceURLs
|
||||
}
|
||||
|
||||
client := apprise.NewClient(
|
||||
appriseServiceURLs,
|
||||
userIntegrations.AppriseURL,
|
||||
)
|
||||
|
||||
if err := client.SendNotification(entry); err != nil {
|
||||
slog.Error("Unable to send entry to Apprise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("apprise_url", userIntegrations.AppriseURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
// PushEntry pushes entries to matrix chat using integration settings provided
|
||||
// PushEntries pushes entries to matrix chat using integration settings provided
|
||||
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
|
||||
client := NewClient(matrixBaseURL)
|
||||
discovery, err := client.DiscoverEndpoints()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ntfy // import "miniflux.app/v2/internal/integration/ntfy"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultNtfyURL = "https://ntfy.sh"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string
|
||||
ntfyPriority int
|
||||
}
|
||||
|
||||
func NewClient(ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL string, ntfyPriority int) *Client {
|
||||
if ntfyURL == "" {
|
||||
ntfyURL = defaultNtfyURL
|
||||
}
|
||||
return &Client{ntfyURL, ntfyTopic, ntfyApiToken, ntfyUsername, ntfyPassword, ntfyIconURL, ntfyPriority}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
ntfyMessage := &ntfyMessage{
|
||||
Topic: c.ntfyTopic,
|
||||
Message: entry.Title,
|
||||
Title: feed.Title,
|
||||
Priority: c.ntfyPriority,
|
||||
Click: entry.URL,
|
||||
}
|
||||
|
||||
if c.ntfyIconURL != "" {
|
||||
ntfyMessage.Icon = c.ntfyIconURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending Ntfy message",
|
||||
slog.String("url", c.ntfyURL),
|
||||
slog.String("topic", c.ntfyTopic),
|
||||
slog.Int("priority", ntfyMessage.Priority),
|
||||
slog.String("message", ntfyMessage.Message),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
if err := c.makeRequest(ntfyMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload any) error {
|
||||
requestBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.ntfyURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#access-tokens
|
||||
if c.ntfyApiToken != "" {
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ntfyApiToken))
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#username-password
|
||||
if c.ntfyUsername != "" && c.ntfyPassword != "" {
|
||||
request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("ntfy: incorrect response status code %d for url %s", response.StatusCode, c.ntfyURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#publish-as-json
|
||||
type ntfyMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // https://docs.ntfy.sh/publish/#icons
|
||||
Click string `json:"click,omitempty"`
|
||||
Actions []ntfyAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// See https://docs.ntfy.sh/publish/#action-buttons
|
||||
type ntfyAction struct {
|
||||
Action string `json:"action"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -12,6 +14,9 @@ import (
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
var errPostNotFound = fmt.Errorf("pinboard: post not found")
|
||||
var errMissingCredentials = fmt.Errorf("pinboard: missing auth token")
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
@@ -24,20 +29,27 @@ func NewClient(authToken string) *Client {
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
|
||||
if c.authToken == "" {
|
||||
return fmt.Errorf("pinboard: missing auth token")
|
||||
return errMissingCredentials
|
||||
}
|
||||
|
||||
toRead := "no"
|
||||
// We check if the url is already bookmarked to avoid overriding existing data.
|
||||
post, err := c.getBookmark(entryURL)
|
||||
|
||||
if err != nil && errors.Is(err, errPostNotFound) {
|
||||
post = NewPost(entryURL, entryTitle)
|
||||
} else if err != nil {
|
||||
// In case of any other error, we return immediately to avoid overriding existing data.
|
||||
return err
|
||||
}
|
||||
|
||||
post.addTag(pinboardTags)
|
||||
if markAsUnread {
|
||||
toRead = "yes"
|
||||
post.SetToread()
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("auth_token", c.authToken)
|
||||
values.Add("url", entryURL)
|
||||
values.Add("description", entryTitle)
|
||||
values.Add("tags", pinboardTags)
|
||||
values.Add("toread", toRead)
|
||||
post.AddValues(values)
|
||||
|
||||
apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
|
||||
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
|
||||
@@ -61,3 +73,46 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBookmark fetches a bookmark from Pinboard. https://www.pinboard.in/api/#posts_get
|
||||
func (c *Client) getBookmark(entryURL string) (*Post, error) {
|
||||
if c.authToken == "" {
|
||||
return nil, errMissingCredentials
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("auth_token", c.authToken)
|
||||
values.Add("url", entryURL)
|
||||
|
||||
apiEndpoint := "https://api.pinboard.in/v1/posts/get?" + values.Encode()
|
||||
request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("pinboard: unable to fetch bookmark, status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
var results posts
|
||||
err = xml.NewDecoder(response.Body).Decode(&results)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pinboard: unable to decode XML: %v", err)
|
||||
}
|
||||
|
||||
if len(results.Posts) == 0 {
|
||||
return nil, errPostNotFound
|
||||
}
|
||||
|
||||
return &results.Posts[0], nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post a Pinboard bookmark. "inspiration" from https://github.com/drags/pinboard/blob/master/posts.go#L32-L42
|
||||
type Post struct {
|
||||
XMLName xml.Name `xml:"post"`
|
||||
Url string `xml:"href,attr"`
|
||||
Description string `xml:"description,attr"`
|
||||
Tags string `xml:"tag,attr"`
|
||||
Extended string `xml:"extended,attr"`
|
||||
Date time.Time `xml:"time,attr"`
|
||||
Shared string `xml:"shared,attr"`
|
||||
Toread string `xml:"toread,attr"`
|
||||
}
|
||||
|
||||
// Posts A result of a Pinboard API call
|
||||
type posts struct {
|
||||
XMLName xml.Name `xml:"posts"`
|
||||
Posts []Post `xml:"post"`
|
||||
}
|
||||
|
||||
func NewPost(url string, description string) *Post {
|
||||
return &Post{
|
||||
Url: url,
|
||||
Description: description,
|
||||
Date: time.Now(),
|
||||
Toread: "no",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Post) addTag(tag string) {
|
||||
if !strings.Contains(p.Tags, tag) {
|
||||
p.Tags += " " + tag
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Post) SetToread() {
|
||||
p.Toread = "yes"
|
||||
}
|
||||
|
||||
func (p *Post) AddValues(values url.Values) {
|
||||
values.Add("url", p.Url)
|
||||
values.Add("description", p.Description)
|
||||
values.Add("tags", p.Tags)
|
||||
if p.Toread != "" {
|
||||
values.Add("toread", p.Toread)
|
||||
}
|
||||
if p.Shared != "" {
|
||||
values.Add("shared", p.Shared)
|
||||
}
|
||||
values.Add("dt", p.Date.Format(time.RFC3339))
|
||||
values.Add("extended", p.Extended)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package raindrop // import "miniflux.app/v2/internal/integration/raindrop"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
collectionID string
|
||||
tags []string
|
||||
}
|
||||
|
||||
func NewClient(token, collectionID, tags string) *Client {
|
||||
return &Client{token: token, collectionID: collectionID, tags: strings.Split(tags, ",")}
|
||||
}
|
||||
|
||||
// https://developer.raindrop.io/v1/raindrops/single#create-raindrop
|
||||
func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
|
||||
if c.token == "" {
|
||||
return fmt.Errorf("raindrop: missing token")
|
||||
}
|
||||
|
||||
var request *http.Request
|
||||
requestBodyJson, err := json.Marshal(&raindrop{
|
||||
Link: entryURL,
|
||||
Title: entryTitle,
|
||||
Collection: collection{Id: c.collectionID},
|
||||
Tags: c.tags,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err = http.NewRequest(http.MethodPost, "https://api.raindrop.io/rest/v1/raindrop", bytes.NewReader(requestBodyJson))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raindrop: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("raindrop: unable to create bookmark: status=%d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type raindrop struct {
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Collection collection `json:"collection,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type collection struct {
|
||||
Id string `json:"$id"`
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
|
||||
|
||||
contentBodyHeader, err := json.Marshal(&partContentHeader{
|
||||
Url: entryURL,
|
||||
ContentHeader: contentHeader{ContentType: "text/html"},
|
||||
ContentHeader: contentHeader{ContentType: "text/html; charset=utf-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)
|
||||
|
||||
@@ -31,7 +31,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return fmt.Errorf("shiori: missing base URL, username or password")
|
||||
}
|
||||
|
||||
sessionID, err := c.authenticate()
|
||||
token, err := c.authenticate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("shiori: unable to authenticate: %v", err)
|
||||
}
|
||||
@@ -44,7 +44,11 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
requestBody, err := json.Marshal(&addBookmarkRequest{
|
||||
URL: entryURL,
|
||||
Title: entryTitle,
|
||||
Excerpt: "",
|
||||
CreateArchive: true,
|
||||
CreateEbook: false,
|
||||
Public: 0,
|
||||
Tags: make([]string, 0),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -58,7 +62,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("X-Session-Id", sessionID)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
|
||||
@@ -75,13 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authenticate() (sessionID string, err error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
|
||||
func (c *Client) authenticate() (token string, err error) {
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
|
||||
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
|
||||
}
|
||||
@@ -111,21 +115,31 @@ func (c *Client) authenticate() (sessionID string, err error) {
|
||||
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
|
||||
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
return authResponse.SessionID, nil
|
||||
return authResponse.Message.Token, nil
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
RememberMe bool `json:"remember_me"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message authResponseMessage `json:"message"`
|
||||
}
|
||||
|
||||
type authResponseMessage struct {
|
||||
SessionID string `json:"session"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type addBookmarkRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
CreateArchive bool `json:"createArchive"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
CreateArchive bool `json:"create_archive"`
|
||||
CreateEbook bool `json:"create_ebook"`
|
||||
Public int `json:"public"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
|
||||
formattedText := fmt.Sprintf(
|
||||
`<a href=%q>%s</a>`,
|
||||
`<b>%s</b> - <a href=%q>%s</a>`,
|
||||
feed.Title,
|
||||
entry.URL,
|
||||
entry.Title,
|
||||
)
|
||||
|
||||
@@ -12,17 +12,26 @@ import (
|
||||
type translationDict map[string]interface{}
|
||||
type catalog map[string]translationDict
|
||||
|
||||
var defaultCatalog catalog
|
||||
var defaultCatalog = make(catalog, len(AvailableLanguages))
|
||||
|
||||
//go:embed translations/*.json
|
||||
var translationFiles embed.FS
|
||||
|
||||
func GetTranslationDict(language string) (translationDict, error) {
|
||||
if _, ok := defaultCatalog[language]; !ok {
|
||||
var err error
|
||||
if defaultCatalog[language], err = loadTranslationFile(language); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return defaultCatalog[language], nil
|
||||
}
|
||||
|
||||
// LoadCatalogMessages loads and parses all translations encoded in JSON.
|
||||
func LoadCatalogMessages() error {
|
||||
var err error
|
||||
defaultCatalog = make(catalog, len(AvailableLanguages()))
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
defaultCatalog[language], err = loadTranslationFile(language)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestLoadCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKeysHaveValue(t *testing.T) {
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
@@ -71,7 +71,7 @@ func TestMissingTranslations(t *testing.T) {
|
||||
t.Fatal(`Unable to parse reference language`)
|
||||
}
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
for language := range AvailableLanguages {
|
||||
if language == refLang {
|
||||
continue
|
||||
}
|
||||
@@ -90,7 +90,27 @@ func TestMissingTranslations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
for language := range AvailableLanguages() {
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
if err != nil {
|
||||
t.Fatalf(`Unable to load translation messages for language %q`, language)
|
||||
|
||||
+20
-43
@@ -3,47 +3,24 @@
|
||||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the list of available languages.
|
||||
func AvailableLanguages() map[string]string {
|
||||
return map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
}
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
import "testing"
|
||||
|
||||
func TestAvailableLanguages(t *testing.T) {
|
||||
results := AvailableLanguages()
|
||||
results := AvailableLanguages
|
||||
for k, v := range results {
|
||||
if k == "" {
|
||||
t.Errorf(`Empty language key detected`)
|
||||
|
||||
+19
-14
@@ -11,9 +11,11 @@ type Printer struct {
|
||||
}
|
||||
|
||||
func (p *Printer) Print(key string) string {
|
||||
if str, ok := defaultCatalog[p.language][key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
if str, ok := dict[key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
}
|
||||
}
|
||||
}
|
||||
return key
|
||||
@@ -21,16 +23,16 @@ func (p *Printer) Print(key string) string {
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
var translation string
|
||||
translation := key
|
||||
|
||||
str, found := defaultCatalog[p.language][key]
|
||||
if !found {
|
||||
translation = key
|
||||
} else {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
if dict, err := GetTranslationDict(p.language); err == nil {
|
||||
str, found := dict[key]
|
||||
if found {
|
||||
var valid bool
|
||||
translation, valid = str.(string)
|
||||
if !valid {
|
||||
translation = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +41,12 @@ func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
|
||||
// Plural returns the translation of the given key by using the language plural form.
|
||||
func (p *Printer) Plural(key string, n int, args ...interface{}) string {
|
||||
choices, found := defaultCatalog[p.language][key]
|
||||
dict, err := GetTranslationDict(p.language)
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
|
||||
if found {
|
||||
if choices, found := dict[key]; found {
|
||||
var plurals []string
|
||||
|
||||
switch v := choices.(type) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Zum Inhalt springen",
|
||||
"confirm.question": "Sind Sie sicher?",
|
||||
"confirm.question.refresh": "Möchten Sie eine erzwungene Aktualisierung durchführen?",
|
||||
"confirm.yes": "ja",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Tastenkürzel: %s",
|
||||
"tooltip.logged_user": "Angemeldet als %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Startseite",
|
||||
"menu.unread": "Ungelesen",
|
||||
"menu.starred": "Lesezeichen",
|
||||
"menu.history": "Verlauf",
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Alle als gelesen markieren",
|
||||
"menu.show_all_entries": "Zeige alle Artikel",
|
||||
"menu.show_only_unread_entries": "Nur ungelesene Artikel anzeigen",
|
||||
"menu.show_only_starred_entries": "Nur markierte Artikel anzeigen",
|
||||
"menu.refresh_feed": "Aktualisieren",
|
||||
"menu.refresh_all_feeds": "Alle Abonnements im Hintergrund aktualisieren",
|
||||
"menu.edit_feed": "Bearbeiten",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Suche",
|
||||
"search.placeholder": "Suche...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Nächste",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Vorherige",
|
||||
"entry.status.unread": "Ungelesen",
|
||||
"entry.status.read": "Gelesen",
|
||||
@@ -90,22 +93,22 @@
|
||||
"entry.tags.label": "Stichworte:",
|
||||
"page.shared_entries.title": "Geteilte Artikel",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d geteilter Artikel",
|
||||
"%d geteilte Artikel"
|
||||
],
|
||||
"page.unread.title": "Ungelesen",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d ungelesener Artikel",
|
||||
"%d ungelesene Artikel"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d Artikel insgesamt",
|
||||
"%d Artikel insgesamt"
|
||||
],
|
||||
"page.starred.title": "Lesezeichen",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d Lesezeichen",
|
||||
"%d Lesezeichen"
|
||||
],
|
||||
"page.categories.title": "Kategorien",
|
||||
"page.categories.no_feed": "Kein Abonnement.",
|
||||
@@ -116,15 +119,15 @@
|
||||
"Es gibt %d Abonnements."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d Kategorie",
|
||||
"%d Kategorien"
|
||||
],
|
||||
"page.new_category.title": "Neue Kategorie",
|
||||
"page.new_user.title": "Neuer Benutzer",
|
||||
"page.edit_category.title": "Kategorie bearbeiten: %s",
|
||||
"page.edit_user.title": "Benutzer bearbeiten: %s",
|
||||
"page.feeds.title": "Abonnements",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Kategorie: %s",
|
||||
"page.feeds.last_check": "Letzte Aktualisierung:",
|
||||
"page.feeds.next_check": "Nächste Aktualisierung:",
|
||||
"page.feeds.read_counter": "Anzahl der gelesenen Artikel",
|
||||
@@ -134,8 +137,8 @@
|
||||
],
|
||||
"page.history.title": "Verlauf",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d gelesener Artikel",
|
||||
"%d gelesene Artikel"
|
||||
],
|
||||
"page.import.title": "Importieren",
|
||||
"page.search.title": "Suchergebnisse",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Einstellungen",
|
||||
"page.settings.link_google_account": "Google-Konto verknüpfen",
|
||||
"page.settings.unlink_google_account": "Verknüpfung mit Google-Konto entfernen",
|
||||
"page.settings.link_oidc_account": "OpenID-Connect-Konto verknüpfen",
|
||||
"page.settings.unlink_oidc_account": "Verknüpfung mit OpenID-Connect-Konto entfernen",
|
||||
"page.settings.link_oidc_account": "%s-Konto verknüpfen",
|
||||
"page.settings.unlink_oidc_account": "Verknüpfung mit %s-Konto entfernen",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Aktionen",
|
||||
"page.settings.webauthn.passkey_name": "Name des Passkeys",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
|
||||
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
|
||||
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
|
||||
"page.login.webauthn_login.help": "Bitte geben Sie Ihren Benutzernamen ein, sofern Sie einen Sicherheitsschlüssel verwenden. Dies ist nicht nötig, wenn Sie einen Passkey verwenden (auffindbare Anmeldeinformationen).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Entfernen Sie %d Hauptschlüssel",
|
||||
"%d Hauptschlüssel entfernen"
|
||||
],
|
||||
"page.login.title": "Anmeldung",
|
||||
"page.login.google_signin": "Anmeldung mit Google",
|
||||
"page.login.oidc_signin": "Anmeldung mit OpenID Connect",
|
||||
"page.login.oidc_signin": "Anmeldung mit %s",
|
||||
"page.login.webauthn_login": "Melden Sie sich mit dem Passkey an",
|
||||
"page.login.webauthn_login.error": "Anmeldung mit Passkey nicht möglich",
|
||||
"page.integrations.title": "Dienste",
|
||||
@@ -251,13 +255,14 @@
|
||||
"page.api_keys.never_used": "Nie benutzt",
|
||||
"page.new_api_key.title": "Neuer API-Schlüssel",
|
||||
"page.offline.title": "Offline-Modus",
|
||||
"page.offline.message": "Du bist offline",
|
||||
"page.offline.message": "Sie sind offline",
|
||||
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
|
||||
"page.webauthn_rename.title": "Passkey umbenennen",
|
||||
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
|
||||
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
|
||||
"alert.no_category": "Es ist keine Kategorie vorhanden.",
|
||||
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
|
||||
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
|
||||
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
|
||||
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
|
||||
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
|
||||
@@ -268,12 +273,12 @@
|
||||
"alert.no_user": "Sie sind der einzige Benutzer.",
|
||||
"alert.account_unlinked": "Ihr externer Account ist jetzt getrennt!",
|
||||
"alert.account_linked": "Ihr externes Konto wurde verknüpft!",
|
||||
"alert.pocket_linked": "Ihr Pocket Konto ist jetzt verknüpft!",
|
||||
"alert.pocket_linked": "Ihr Pocket-Konto ist jetzt verknüpft!",
|
||||
"alert.prefs_saved": "Einstellungen gespeichert!",
|
||||
"error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
|
||||
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
|
||||
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google Reader Benutzernamen!",
|
||||
"error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever-Benutzernamen!",
|
||||
"error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google-Reader-Benutzernamen!",
|
||||
"error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
|
||||
"error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
|
||||
"error.category_already_exists": "Diese Kategorie existiert bereits.",
|
||||
@@ -288,7 +293,7 @@
|
||||
"error.invalid_language": "Ungültige Sprache.",
|
||||
"error.invalid_timezone": "Ungültige Zeitzone.",
|
||||
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
|
||||
"error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
|
||||
"error.invalid_display_mode": "Progressive-Web-App- (PWA-)Anzeigemodus",
|
||||
"error.invalid_gesture_nav": "Ungültige Gestennavigation.",
|
||||
"error.invalid_default_home_page": "Ungültige Standard-Startseite!",
|
||||
"error.empty_file": "Diese Datei ist leer.",
|
||||
@@ -299,13 +304,21 @@
|
||||
"error.password_min_length": "Wenigstens 6 Zeichen müssen genutzt werden.",
|
||||
"error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
|
||||
"error.settings_reading_speed_is_positive": "Die Lesegeschwindigkeiten müssen positive ganze Zahlen sein.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Ungültige Blockierregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_block_rule_separator_required": "Ungültige Blockierregel: Das Muster für Regel #%d muss per '=' getrennt werden",
|
||||
"error.settings_block_rule_regex_required": "Ungültige Blockierregel: Regel #%d hat kein Muster",
|
||||
"error.settings_block_rule_invalid_regex": "Ungültige Blockierregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ungültige Erlaubnisregel: Regel #%d hat keinen gültigen Feldnamen (Optionen: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Ungültige Erlaubnisregel: Das Muster für Regel #%d muss per '=' getrennt werden",
|
||||
"error.settings_keep_rule_regex_required": "Ungültige Erlaubnisregel: Regel #%d hat kein Muster",
|
||||
"error.settings_keep_rule_invalid_regex": "Ungültige Erlaubnisregel: Das Muster für Regel #%d ist kein zulässiger regulärer Ausdruck",
|
||||
"error.entries_per_page_invalid": "Die Anzahl der Einträge pro Seite ist ungültig.",
|
||||
"error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
|
||||
"error.feed_already_exists": "Dieser Feed existiert bereits.",
|
||||
"error.invalid_feed_url": "Ungültige Feed-URL.",
|
||||
"error.invalid_site_url": "Ungültige Site-URL.",
|
||||
"error.feed_url_not_empty": "Die Feed-URL darf nicht leer sein.",
|
||||
"error.site_url_not_empty": "Die Site-URL darf nicht leer sein.",
|
||||
"error.invalid_feed_url": "Ungültiger Feed-URL.",
|
||||
"error.invalid_site_url": "Ungültiger Site-URL.",
|
||||
"error.feed_url_not_empty": "Der Feed-URL darf nicht leer sein.",
|
||||
"error.site_url_not_empty": "Der Site-URL darf nicht leer sein.",
|
||||
"error.feed_title_not_empty": "Der Feed-Titel darf nicht leer sein.",
|
||||
"error.feed_category_not_found": "Diese Kategorie existiert nicht oder gehört nicht zu diesem Benutzer.",
|
||||
"error.feed_invalid_blocklist_rule": "Die Blockierregel ist ungültig.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "Titel",
|
||||
"form.feed.label.site_url": "URL der Webseite",
|
||||
"form.feed.label.feed_url": "URL des Abonnements",
|
||||
"form.feed.label.description": "Beschreibung",
|
||||
"form.feed.label.category": "Kategorie",
|
||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||
"form.feed.label.feed_username": "Benutzername des Abonnements",
|
||||
@@ -324,17 +338,26 @@
|
||||
"form.feed.label.cookie": "Cookies setzen",
|
||||
"form.feed.label.scraper_rules": "Extraktionsregeln",
|
||||
"form.feed.label.rewrite_rules": "Umschreiberegeln",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise Service-URLs",
|
||||
"form.feed.label.apprise_service_urls": "Kommaseparierte Liste der Apprise-Service-URLs",
|
||||
"form.feed.label.blocklist_rules": "Blockierregeln",
|
||||
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
|
||||
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
|
||||
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-Cache",
|
||||
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "HTTP/2 deaktivieren, um Fingerprinting zu verhindern",
|
||||
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
"form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
|
||||
"form.feed.label.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.feed.label.ntfy_priority": "Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_max_priority": "Höchste Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_high_priority": "Hohe Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_default_priority": "Normale Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_low_priority": "Niedrige Ntfy-Priorität",
|
||||
"form.feed.label.ntfy_min_priority": "Niedrigste Ntfy-Priorität",
|
||||
"form.integration.cubox_activate": "Einträge in Cubox speichern",
|
||||
"form.integration.cubox_api_link": "Cubox-API-Link",
|
||||
"form.feed.fieldset.general": "Allgemein",
|
||||
"form.feed.fieldset.rules": "Regeln",
|
||||
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
|
||||
@@ -359,7 +382,7 @@
|
||||
"form.prefs.select.standalone": "Eigenständige",
|
||||
"form.prefs.select.minimal_ui": "Minimal",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlichte am",
|
||||
"form.prefs.select.publish_time": "Artikel veröffentlicht am",
|
||||
"form.prefs.select.created_time": "Artikel erstellt am",
|
||||
"form.prefs.select.alphabetical": "Alphabetisch",
|
||||
"form.prefs.select.unread_count": "Ungelesen",
|
||||
@@ -371,60 +394,71 @@
|
||||
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
|
||||
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
|
||||
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
|
||||
"form.prefs.label.custom_js": "Benutzerdefiniertes JavaScript",
|
||||
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
|
||||
"form.prefs.label.default_home_page": "Standard-Startseite",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
|
||||
"form.prefs.label.mark_read_on_view": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Einträge automatisch als gelesen markieren, wenn sie angezeigt werden. Audio/Video bei 90%% Wiedergabe als gelesen markieren",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Nur als gelesen markieren, wenn Audio/Video zu 90%% wiedergegeben wurden",
|
||||
"form.prefs.label.mark_read_manually": "Einträge manuell als gelesen markieren",
|
||||
"form.prefs.fieldset.application_settings": "Anwendungseinstellungen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
|
||||
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
|
||||
"form.import.label.file": "OPML Datei",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feedeinstellungen",
|
||||
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
|
||||
"form.prefs.help.external_font_hosts": "Per Leerzeichen getrennte Liste externer Schriftarten-Hosts, die erlaubt werden sollen. Beispiel: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Ungültige Domainliste. Bitte geben Sie eine per Leerzeichen getrennte Liste von Domains an.",
|
||||
"form.import.label.file": "OPML-Datei",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.fever_activate": "Fever API aktivieren",
|
||||
"form.integration.fever_username": "Fever Benutzername",
|
||||
"form.integration.fever_password": "Fever Passwort",
|
||||
"form.integration.fever_endpoint": "Fever API Endpunkt:",
|
||||
"form.integration.googlereader_activate": "Google Reader API aktivieren",
|
||||
"form.integration.googlereader_username": "Google Reader Benutzername",
|
||||
"form.integration.googlereader_password": "Google Reader Passwort",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API-Endpunkt:",
|
||||
"form.integration.betula_activate": "Einträge in Betula speichern",
|
||||
"form.integration.betula_url": "Betula-Server-URL",
|
||||
"form.integration.betula_token": "Betula-Token",
|
||||
"form.integration.fever_activate": "Fever-API aktivieren",
|
||||
"form.integration.fever_username": "Fever-Benutzername",
|
||||
"form.integration.fever_password": "Fever-Passwort",
|
||||
"form.integration.fever_endpoint": "Fever-API-Endpunkt:",
|
||||
"form.integration.googlereader_activate": "Google-Reader-API aktivieren",
|
||||
"form.integration.googlereader_username": "Google-Reader-Benutzername",
|
||||
"form.integration.googlereader_password": "Google-Reader-Passwort",
|
||||
"form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
|
||||
"form.integration.pinboard_activate": "Einträge in Pinboard speichern",
|
||||
"form.integration.pinboard_token": "Pinboard API-Token",
|
||||
"form.integration.pinboard_tags": "Pinboard Tags",
|
||||
"form.integration.pinboard_token": "Pinboard-API-Token",
|
||||
"form.integration.pinboard_tags": "Pinboard-Tags",
|
||||
"form.integration.pinboard_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.instapaper_activate": "Einträge in Instapaper speichern",
|
||||
"form.integration.instapaper_username": "Instapaper Benutzername",
|
||||
"form.integration.instapaper_password": "Instapaper Passwort",
|
||||
"form.integration.instapaper_username": "Instapaper-Benutzername",
|
||||
"form.integration.instapaper_password": "Instapaper-Passwort",
|
||||
"form.integration.pocket_activate": "Einträge in Pocket speichern",
|
||||
"form.integration.pocket_consumer_key": "Pocket Verbraucher-Schlüssel",
|
||||
"form.integration.pocket_access_token": "Pocket Zugangs-Token",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket Konto",
|
||||
"form.integration.pocket_consumer_key": "Pocket-Verbraucher-Schlüssel",
|
||||
"form.integration.pocket_access_token": "Pocket-Zugangstoken",
|
||||
"form.integration.pocket_connect_link": "Verbinden Sie Ihr Pocket-Konto",
|
||||
"form.integration.wallabag_activate": "Einträge in Wallabag speichern",
|
||||
"form.integration.wallabag_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Geheimnis",
|
||||
"form.integration.wallabag_username": "Wallabag Benutzername",
|
||||
"form.integration.wallabag_password": "Wallabag Passwort",
|
||||
"form.integration.wallabag_endpoint": "Wallabag-URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag-Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag-Client-Geheimnis",
|
||||
"form.integration.wallabag_username": "Wallabag-Benutzername",
|
||||
"form.integration.wallabag_password": "Wallabag-Passwort",
|
||||
"form.integration.notion_activate": "Einträge in Notion speichern",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Geheimnis-Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise service URLs",
|
||||
"form.integration.notion_page_id": "Notion-Page-ID",
|
||||
"form.integration.notion_token": "Notion-Geheimnis-Token",
|
||||
"form.integration.apprise_activate": "Einträge zu Apprise pushen",
|
||||
"form.integration.apprise_url": "Apprise-API-URL",
|
||||
"form.integration.apprise_services_url": "Kommaseparierte Liste von Apprise-Dienst-URLs",
|
||||
"form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux-Keeper-API-Endpunkt",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux-Keeper-API-Schlüssel",
|
||||
"form.integration.omnivore_activate": "Einträge in Omnivore speichern",
|
||||
"form.integration.omnivore_url": "Omnivore API-Endpunkt",
|
||||
"form.integration.omnivore_api_key": "Omnivore API-Schlüssel",
|
||||
"form.integration.espial_activate": "Einträge in Espial",
|
||||
"form.integration.espial_endpoint": "Espial API-Endpunkt",
|
||||
"form.integration.espial_api_key": "Espial API-Schlüssel",
|
||||
"form.integration.espial_tags": "Espial Tags",
|
||||
"form.integration.omnivore_url": "Omnivore-API-Endpunkt",
|
||||
"form.integration.omnivore_api_key": "Omnivore-API-Schlüssel",
|
||||
"form.integration.espial_activate": "Einträge in Espial speichern",
|
||||
"form.integration.espial_endpoint": "Espial-API-Endpunkt",
|
||||
"form.integration.espial_api_key": "Espial-API-Schlüssel",
|
||||
"form.integration.espial_tags": "Espial-Tags",
|
||||
"form.integration.readwise_activate": "Einträge in Readwise Reader speichern",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Zugangs-Token",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise Zugangs-Token",
|
||||
"form.integration.readwise_api_key": "Readwise-Reader-Zugangstoken",
|
||||
"form.integration.readwise_api_key_link": "Erhalten Sie Ihren Readwise-Zugangstoken",
|
||||
"form.integration.telegram_bot_activate": "Schicken Sie neue Artikel in den Telegram-Chat",
|
||||
"form.integration.telegram_bot_token": "Bot-Token",
|
||||
"form.integration.telegram_chat_id": "Chat-ID",
|
||||
@@ -432,42 +466,53 @@
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Webseiten-Vorschau deaktivieren",
|
||||
"form.integration.telegram_bot_disable_notification": "Benachrichtigungen deaktivieren",
|
||||
"form.integration.telegram_bot_disable_buttons": "Schaltfächen deaktivieren",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_activate": "Einträge in LinkAce speichern",
|
||||
"form.integration.linkace_endpoint": "LinkAce-API-Endpunkt",
|
||||
"form.integration.linkace_api_key": "LinkAce-API-Schlüssel",
|
||||
"form.integration.linkace_tags": "LinkAce-Tags",
|
||||
"form.integration.linkace_is_private": "Link als privat markieren",
|
||||
"form.integration.linkace_check_disabled": "Linkprüfung deaktivieren",
|
||||
"form.integration.linkding_activate": "Artikel in Linkding speichern",
|
||||
"form.integration.linkding_endpoint": "Linkding API-Endpunkt",
|
||||
"form.integration.linkding_api_key": "Linkding API-Schlüssel",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_endpoint": "Linkding-API-Endpunkt",
|
||||
"form.integration.linkding_api_key": "Linkding-API-Schlüssel",
|
||||
"form.integration.linkding_tags": "Linkding-Tags",
|
||||
"form.integration.linkding_bookmark": "Lesezeichen als ungelesen markieren",
|
||||
"form.integration.linkwarden_activate": "Artikel in Linkwarden speichern",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API-Endpunkt",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-Schlüssel",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden-API-Endpunkt",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden-API-Schlüssel",
|
||||
"form.integration.matrix_bot_activate": "Neue Artikel in Matrix übertragen",
|
||||
"form.integration.matrix_bot_user": "Benutzername für Matrix",
|
||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.raindrop_activate": "Einträge in Raindrop speichern",
|
||||
"form.integration.raindrop_token": "(Test-)Token",
|
||||
"form.integration.raindrop_collection_id": "Sammlungs-ID",
|
||||
"form.integration.raindrop_tags": "Tags (kommagetrennt)",
|
||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_endpoint": "Readeck-URL",
|
||||
"form.integration.readeck_api_key": "Readeck-API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck-Labels",
|
||||
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.shiori_activate": "Artikel in Shiori speichern",
|
||||
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori Benutzername",
|
||||
"form.integration.shiori_password": "Shiori Passwort",
|
||||
"form.integration.shiori_endpoint": "Shiori-API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori-Benutzername",
|
||||
"form.integration.shiori_password": "Shiori-Passwort",
|
||||
"form.integration.shaarli_activate": "Artikel in Shaarli speichern",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Geheimnis",
|
||||
"form.integration.shaarli_endpoint": "Shaarli-URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli-API-Geheimnis",
|
||||
"form.integration.webhook_activate": "Webhook aktivieren",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Geheimnis",
|
||||
"form.integration.webhook_url": "Webhook-URL",
|
||||
"form.integration.webhook_secret": "Webhook-Geheimnis",
|
||||
"form.integration.rssbridge_activate": "Beim Hinzufügen von Abonnements RSS-Bridge prüfen.",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge-Server-URL",
|
||||
"form.integration.ntfy_activate": "Einträge zu ntfy pushen",
|
||||
"form.integration.ntfy_topic": "Ntfy-Thema",
|
||||
"form.integration.ntfy_url": "Ntfy-URL (optional, Standard ist ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy-API-Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy-Benutzername (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy-Passwort (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy-Symbol-URL (optional)",
|
||||
"form.api_key.label.description": "API-Schlüsselbezeichnung",
|
||||
"form.submit.loading": "Lade...",
|
||||
"form.submit.saving": "Speichern...",
|
||||
@@ -511,7 +556,7 @@
|
||||
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
|
||||
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
|
||||
"error.http_client_error": "HTTP-Client-Fehler: %v.",
|
||||
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise sind der Benutzername oder das Passwort falsch.",
|
||||
"error.http_not_authorized": "Der Zugriff auf diese Website ist nicht erlaubt. Möglicherweise ist der Benutzername oder das Passwort falsch.",
|
||||
"error.http_too_many_requests": "Miniflux hat zu viele Anfragen an diese Webseite gestellt. Bitte versuchen Sie es später erneut oder ändern Sie die Konfiguration der Anwendung.",
|
||||
"error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
||||
"error.http_resource_not_found": "Die gewünschte Quelle wurde nicht gefunden. Bitte stellen Sie sicher, dass die URL korrekt ist.",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
|
||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs"
|
||||
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
|
||||
"enclosure_media_controls.seek" : "Vorspulen:",
|
||||
"enclosure_media_controls.seek.title" : "%s Sekunden vorspulen",
|
||||
"enclosure_media_controls.speed" : "Geschwindigkeit:",
|
||||
"enclosure_media_controls.speed.faster" : "Schneller",
|
||||
"enclosure_media_controls.speed.faster.title" : "%sx schneller",
|
||||
"enclosure_media_controls.speed.slower" : "Langsamer",
|
||||
"enclosure_media_controls.speed.slower.title" : "%sx langsamer",
|
||||
"enclosure_media_controls.speed.reset" : "Zurücksetzen",
|
||||
"enclosure_media_controls.speed.reset.title" : "Wiedergabegeschwindigkeit auf 1x zurücksetzen"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
|
||||
"menu.show_all_entries": "Εμφάνιση όλων των καταχωρήσεων",
|
||||
"menu.show_only_unread_entries": "Εμφάνιση μόνο μη αναγνωσμένων καταχωρήσεων",
|
||||
"menu.show_only_starred_entries": "Εμφάνιση μόνο αγαπημένων καταχωρήσεων",
|
||||
"menu.refresh_feed": "Ανανέωση",
|
||||
"menu.refresh_all_feeds": "Ανανέωση όλων των ροών στο παρασκήνιο",
|
||||
"menu.edit_feed": "Επεξεργασία",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Αναζήτηση",
|
||||
"search.placeholder": "Αναζήτηση...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Επόμενη",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Προηγούμενη",
|
||||
"entry.status.unread": "Μη αναγνωσμένο",
|
||||
"entry.status.read": "Αναγνωσμένο",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Ρυθμίσεις",
|
||||
"page.settings.link_google_account": "Σύνδεση του λογαριασμό μου Google",
|
||||
"page.settings.unlink_google_account": "Αποσύνδεση του λογαριασμού μου Google",
|
||||
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Σύνδεση του λογαριασμού μου %s",
|
||||
"page.settings.unlink_oidc_account": "Αποσύνδεση του λογαριασμού μου %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
|
||||
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Αφαιρέστε %d κωδικό πρόσβασης",
|
||||
"Καταργήστε %d κωδικούς πρόσβασης"
|
||||
],
|
||||
"page.login.title": "Είσοδος",
|
||||
"page.login.google_signin": "Συνδεθείτε με τo Google",
|
||||
"page.login.oidc_signin": "Συνδεθείτε με το OpenID Connect",
|
||||
"page.login.oidc_signin": "Συνδεθείτε με το %s",
|
||||
"page.login.webauthn_login": "Είσοδος με κωδικό πρόσβασης",
|
||||
"page.login.webauthn_login.error": "Δεν είναι δυνατή η σύνδεση με κωδικό πρόσβασης",
|
||||
"page.integrations.title": "Ενσωμάτωση",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
||||
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
||||
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
|
||||
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
|
||||
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
||||
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
|
||||
@@ -299,6 +304,14 @@
|
||||
"error.password_min_length": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες.",
|
||||
"error.settings_mandatory_fields": "Τα πεδία όνομα χρήστη, θέμα, Γλώσσα και ζώνη ώρας είναι υποχρεωτικά.",
|
||||
"error.settings_reading_speed_is_positive": "Οι ταχύτητες ανάγνωσης πρέπει να είναι θετικοί ακέραιοι αριθμοί.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Ο αριθμός των καταχωρήσεων ανά σελίδα δεν είναι έγκυρος.",
|
||||
"error.feed_mandatory_fields": "Η διεύθυνση URL και η κατηγορία είναι υποχρεωτικά.",
|
||||
"error.feed_already_exists": "Αυτή η ροή υπάρχει ήδη.",
|
||||
@@ -318,6 +331,7 @@
|
||||
"form.feed.label.title": "Τίτλος",
|
||||
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
|
||||
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
|
||||
"form.feed.label.description": "Περιγραφή",
|
||||
"form.feed.label.category": "Κατηγορία",
|
||||
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
||||
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
|
||||
@@ -339,6 +353,15 @@
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.category.label.title": "Τίτλος",
|
||||
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
|
||||
"form.user.label.username": "Χρήστης",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
|
||||
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
|
||||
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
|
||||
"form.prefs.label.custom_js": "Προσαρμοσμένο JavaScript",
|
||||
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
|
||||
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
|
||||
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
|
||||
"form.prefs.label.mark_read_on_view": "Αυτόματη επισήμανση καταχωρήσεων ως αναγνωσμένων κατά την προβολή",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Αρχείο OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Ενεργοποιήστε το Fever API",
|
||||
"form.integration.fever_username": "Όνομα Χρήστη Fever",
|
||||
"form.integration.fever_password": "Κωδικός Πρόσβασης Fever",
|
||||
@@ -451,6 +485,10 @@
|
||||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
||||
@@ -468,6 +506,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Ετικέτα κλειδιού API",
|
||||
"form.submit.loading": "Φόρτωση...",
|
||||
"form.submit.saving": "Αποθήκευση...",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
|
||||
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους"
|
||||
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"menu.mark_page_as_read": "Mark this page as read",
|
||||
"menu.mark_all_as_read": "Mark all as read",
|
||||
"menu.show_all_entries": "Show all entries",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.show_only_unread_entries": "Show only unread entries",
|
||||
"menu.refresh_feed": "Refresh",
|
||||
"menu.refresh_all_feeds": "Refresh all feeds in the background",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Search",
|
||||
"search.placeholder": "Search…",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Next",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Previous",
|
||||
"entry.status.unread": "Unread",
|
||||
"entry.status.read": "Read",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Settings",
|
||||
"page.settings.link_google_account": "Link my Google account",
|
||||
"page.settings.unlink_google_account": "Unlink my Google account",
|
||||
"page.settings.link_oidc_account": "Link my OpenID Connect account",
|
||||
"page.settings.unlink_oidc_account": "Unlink my OpenID Connect account",
|
||||
"page.settings.link_oidc_account": "Link my %s account",
|
||||
"page.settings.unlink_oidc_account": "Unlink my %s account",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -223,9 +226,10 @@
|
||||
],
|
||||
"page.login.title": "Sign In",
|
||||
"page.login.google_signin": "Sign in with Google",
|
||||
"page.login.oidc_signin": "Sign in with OpenID Connect",
|
||||
"page.login.oidc_signin": "Sign in with %s",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.integrations.title": "Integrations",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API Endpoint",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "There are no starred entries.",
|
||||
"alert.no_category": "There is no category.",
|
||||
"alert.no_category_entry": "There are no entries in this category.",
|
||||
"alert.no_tag_entry": "There are no entries matching this tag.",
|
||||
"alert.no_feed_entry": "There are no entries for this feed.",
|
||||
"alert.no_feed": "You don’t have any feeds.",
|
||||
"alert.no_feed_in_category": "There is no feed for this category.",
|
||||
@@ -299,6 +304,14 @@
|
||||
"error.password_min_length": "The password must have at least 6 characters.",
|
||||
"error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
|
||||
"error.settings_reading_speed_is_positive": "The reading speeds must be positive integers.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "The number of entries per page is not valid.",
|
||||
"error.feed_mandatory_fields": "The URL and the category are mandatory.",
|
||||
"error.feed_already_exists": "This feed already exists.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "Title",
|
||||
"form.feed.label.site_url": "Site URL",
|
||||
"form.feed.label.feed_url": "Feed URL",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.category": "Category",
|
||||
"form.feed.label.crawler": "Fetch original content",
|
||||
"form.feed.label.feed_username": "Feed Username",
|
||||
@@ -335,6 +349,13 @@
|
||||
"form.feed.label.disabled": "Do not refresh this feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Hide entries in global unread list",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -371,15 +392,26 @@
|
||||
"form.prefs.label.gesture_nav": "Gesture to navigate between entries",
|
||||
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
|
||||
"form.prefs.label.custom_css": "Custom CSS",
|
||||
"form.prefs.label.custom_js": "Custom JavaScript",
|
||||
"form.prefs.label.entry_order": "Entry sorting column",
|
||||
"form.prefs.label.default_home_page": "Default home page",
|
||||
"form.prefs.label.categories_sorting_order": "Categories sorting",
|
||||
"form.prefs.label.mark_read_on_view": "Automatically mark entries as read when viewed",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML file",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Activate Fever API",
|
||||
"form.integration.fever_username": "Fever Username",
|
||||
"form.integration.fever_password": "Fever Password",
|
||||
@@ -451,8 +483,12 @@
|
||||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||
"form.integration.matrix_bot_url": "Matrix server URL",
|
||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Save entries to readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
|
||||
@@ -468,6 +504,15 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Loading…",
|
||||
"form.submit.saving": "Saving…",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
|
||||
"error.settings_media_playback_rate_range": "Playback speed is out of range"
|
||||
"error.settings_media_playback_rate_range": "Playback speed is out of range",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Saltar al contenido",
|
||||
"confirm.question": "¿Estás seguro?",
|
||||
"confirm.question.refresh": "¿Quieres forzar la actualización?",
|
||||
"confirm.yes": "sí",
|
||||
@@ -9,8 +9,8 @@
|
||||
"action.save": "Guardar",
|
||||
"action.or": "o",
|
||||
"action.cancel": "Cancelar",
|
||||
"action.remove": "Quitar",
|
||||
"action.remove_feed": "Quitar esta fuente",
|
||||
"action.remove": "Eliminar",
|
||||
"action.remove_feed": "Eliminar esta fuente",
|
||||
"action.update": "Actualizar",
|
||||
"action.edit": "Editar",
|
||||
"action.download": "Descargar",
|
||||
@@ -19,8 +19,8 @@
|
||||
"action.home_screen": "Añadir a la pantalla principal",
|
||||
"tooltip.keyboard_shortcuts": "Atajo de teclado: %s",
|
||||
"tooltip.logged_user": "Registrado como %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "Menú",
|
||||
"menu.home_page": "Página de inicio",
|
||||
"menu.unread": "No leídos",
|
||||
"menu.starred": "Marcadores",
|
||||
"menu.history": "Historial",
|
||||
@@ -41,8 +41,9 @@
|
||||
"menu.mark_all_as_read": "Marcar todos como leídos",
|
||||
"menu.show_all_entries": "Mostrar todos los artículos",
|
||||
"menu.show_only_unread_entries": "Mostrar solo los artículos no leídos",
|
||||
"menu.show_only_starred_entries": "Mostrar solo los artículos marcados con una estrella",
|
||||
"menu.refresh_feed": "Refrescar",
|
||||
"menu.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
|
||||
"menu.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
|
||||
"menu.edit_feed": "Editar",
|
||||
"menu.edit_category": "Editar",
|
||||
"menu.add_feed": "Agregar fuente",
|
||||
@@ -54,8 +55,10 @@
|
||||
"menu.shared_entries": "Artículos compartidos",
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Búsqueda...",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "Buscar",
|
||||
"pagination.last": "Último",
|
||||
"pagination.next": "Siguiente",
|
||||
"pagination.first": "Primero",
|
||||
"pagination.previous": "Anterior",
|
||||
"entry.status.unread": "No leído",
|
||||
"entry.status.read": "Leído",
|
||||
@@ -90,22 +93,22 @@
|
||||
"entry.tags.label": "Etiquetas:",
|
||||
"page.shared_entries.title": "Artículos compartidos",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d artículo compartido",
|
||||
"%d artículos compartidos"
|
||||
],
|
||||
"page.unread.title": "No leídos",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d artículo no leído",
|
||||
"%d artículos no leídos"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d artículo en total",
|
||||
"%d artículos en total"
|
||||
],
|
||||
"page.starred.title": "Marcadores",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d artículo marcado",
|
||||
"%d artículos marcados"
|
||||
],
|
||||
"page.categories.title": "Categorías",
|
||||
"page.categories.no_feed": "Sin fuente.",
|
||||
@@ -116,17 +119,17 @@
|
||||
"Hay %d fuentes."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d categoría",
|
||||
"%d categorías"
|
||||
],
|
||||
"page.new_category.title": "Nueva categoría",
|
||||
"page.new_user.title": "Nuevo usuario",
|
||||
"page.edit_category.title": "Editar categoría: %s",
|
||||
"page.edit_user.title": "Editar usuario: %s",
|
||||
"page.feeds.title": "Fuentes",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Categoría: %s",
|
||||
"page.feeds.last_check": "Última verificación:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Próxima verificación:",
|
||||
"page.feeds.read_counter": "Número de artículos leídos",
|
||||
"page.feeds.error_count": [
|
||||
"%d error",
|
||||
@@ -134,15 +137,15 @@
|
||||
],
|
||||
"page.history.title": "Historial",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d artículo leído",
|
||||
"%d artículos leídos"
|
||||
],
|
||||
"page.import.title": "Importar",
|
||||
"page.search.title": "Resultados de la búsqueda",
|
||||
"page.about.title": "Acerca de",
|
||||
"page.about.credits": "Créditos",
|
||||
"page.about.version": "Versión:",
|
||||
"page.about.build_date": "Fecha de construcción:",
|
||||
"page.about.build_date": "Fecha de compilación:",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.license": "Licencia:",
|
||||
"page.about.global_config_options": "Opciones de configuración global",
|
||||
@@ -187,7 +190,7 @@
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Abrir enlace de comentarios en la pestaña actual",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Marcar como leído o no leído, enfoque siguiente",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Marcar como leído o no leído, foco anterior",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en el fondo",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Refrescar todas las fuentes en segundo plano",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Marcar página actual como leída",
|
||||
"page.keyboard_shortcuts.download_content": "Descargar el contento original",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Agregar o quitar marcador",
|
||||
@@ -208,24 +211,25 @@
|
||||
"page.settings.title": "Ajustes",
|
||||
"page.settings.link_google_account": "Vincular mi cuenta de Google",
|
||||
"page.settings.unlink_google_account": "Desvincular mi cuenta de Google",
|
||||
"page.settings.link_oidc_account": "Vincular mi cuenta de OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de OpenID Connect",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.link_oidc_account": "Vincular mi cuenta de %s",
|
||||
"page.settings.unlink_oidc_account": "Desvincular mi cuenta de %s",
|
||||
"page.settings.webauthn.passkeys": "Claves de acceso",
|
||||
"page.settings.webauthn.actions": "Accioness",
|
||||
"page.settings.webauthn.passkey_name": "Nombre de clave de acceso",
|
||||
"page.settings.webauthn.added_on": "Añadido",
|
||||
"page.settings.webauthn.last_seen_on": "Usado por última vez",
|
||||
"page.settings.webauthn.register": "Registrar clave de acceso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de paso",
|
||||
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Eliminar %d clave de paso",
|
||||
"Eliminar %d claves de paso"
|
||||
"Eliminar %d clave de acceso",
|
||||
"Eliminar %d claves de acceso"
|
||||
],
|
||||
"page.login.title": "Iniciar sesión",
|
||||
"page.login.google_signin": "Iniciar sesión con tu cuenta de Google",
|
||||
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de OpenID Connect",
|
||||
"page.login.oidc_signin": "Iniciar sesión con tu cuenta de %s",
|
||||
"page.login.webauthn_login": "Iniciar sesión con clave de acceso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de paso",
|
||||
"page.login.webauthn_login.error": "No se puede iniciar sesión con la clave de acceso",
|
||||
"page.integrations.title": "Integraciones",
|
||||
"page.integration.miniflux_api": "API de Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Extremo de API",
|
||||
@@ -253,11 +257,12 @@
|
||||
"page.offline.title": "Modo offline",
|
||||
"page.offline.message": "Estas desconectado",
|
||||
"page.offline.refresh_page": "Intenta actualizar la página",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"page.webauthn_rename.title": "Renombrar clave de acceso",
|
||||
"alert.no_shared_entry": "No hay artículos compartidos.",
|
||||
"alert.no_bookmark": "No hay marcador en este momento.",
|
||||
"alert.no_category": "No hay categoría.",
|
||||
"alert.no_category_entry": "No hay artículos en esta categoría.",
|
||||
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
|
||||
"alert.no_feed_entry": "No hay artículos para esta fuente.",
|
||||
"alert.no_feed": "No tienes fuentes.",
|
||||
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
|
||||
@@ -292,6 +297,14 @@
|
||||
"error.password_min_length": "La contraseña debería tener al menos 6 caracteres.",
|
||||
"error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
|
||||
"error.settings_reading_speed_is_positive": "Las velocidades de lectura deben ser números enteros positivos.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Regla de bloqueo no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_block_rule_separator_required": "Regla de bloqueo no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_block_rule_regex_required": "Regla de bloqueo no válida: no se ha proporcionado el patrón de la regla #%d",
|
||||
"error.settings_block_rule_invalid_regex": "Regla de bloqueo no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regla de mantenimiento no válida: a la regla #%d le falta un nombre de campo válido (Opciones: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Regla de mantenimiento no válida: el patrón de la regla #%d debe estar separado por un '='",
|
||||
"error.settings_keep_rule_regex_required": "Regla de conservación no válida: no se ha proporcionado la regla #%d patrón",
|
||||
"error.settings_keep_rule_invalid_regex": "Regla de mantenimiento no válida: el patrón de la regla #%d no es una expresión regular válida",
|
||||
"error.entries_per_page_invalid": "El número de artículos por página no es válido.",
|
||||
"error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
|
||||
"error.feed_already_exists": "Este feed ya existe.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.site_url": "URL del sitio",
|
||||
"form.feed.label.feed_url": "URL de la fuente",
|
||||
"form.feed.label.description": "Descripción",
|
||||
"form.feed.label.category": "Categoría",
|
||||
"form.feed.label.crawler": "Obtener rastreador original",
|
||||
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
|
||||
@@ -324,21 +338,30 @@
|
||||
"form.feed.label.cookie": "Configurar las cookies",
|
||||
"form.feed.label.scraper_rules": "Reglas de extracción de información",
|
||||
"form.feed.label.rewrite_rules": "Reglas de reescribir",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.apprise_service_urls": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.feed.label.blocklist_rules": "Reglas de Filtrado (Bloquear)",
|
||||
"form.feed.label.keeplist_rules": "Reglas de Filtrado (Permitir)",
|
||||
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
|
||||
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "Deshabilite HTTP/2 para evitar huellas digitales",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
|
||||
"form.feed.label.disabled": "No actualice este feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.no_media_player": "Sin reproductor multimedia (audio/video)",
|
||||
"form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.feed.label.ntfy_activate": "Enviar entradas a ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioridad Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioridad máxima a Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Prioridad alta a Ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Prioridad baja a Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.fieldset.rules": "Reglas",
|
||||
"form.feed.fieldset.network_settings": "Ajustes de red",
|
||||
"form.feed.fieldset.integration": "Servicios de terceros",
|
||||
"form.category.label.title": "Título",
|
||||
"form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
|
||||
"form.user.label.username": "Nombre de usuario",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
|
||||
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
|
||||
"form.prefs.label.custom_css": "CSS personalizado",
|
||||
"form.prefs.label.custom_js": "JavaScript personalizado",
|
||||
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
|
||||
"form.prefs.label.default_home_page": "Página de inicio por defecto",
|
||||
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automáticamente las entradas como leídas cuando se vean",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marcar las entradas como leídas cuando se vean. Para audio/video, marcar como leído al 90%% de finalización",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Marcar como leído solo cuando la reproducción de audio/video alcance el 90%% de finalización",
|
||||
"form.prefs.label.mark_read_manually": "Marcar entradas como leídas manualmente",
|
||||
"form.prefs.fieldset.application_settings": "Ajustes de la aplicación",
|
||||
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
|
||||
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
|
||||
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Archivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Guardar artículos en Betula",
|
||||
"form.integration.betula_url": "URL del servidor Betula",
|
||||
"form.integration.betula_token": "Token de Betula",
|
||||
"form.integration.fever_activate": "Activar API de Fever",
|
||||
"form.integration.fever_username": "Nombre de usuario de Fever",
|
||||
"form.integration.fever_password": "Contraseña de Fever",
|
||||
@@ -406,12 +440,12 @@
|
||||
"form.integration.wallabag_client_secret": "Secreto de cliente de Wallabag",
|
||||
"form.integration.wallabag_username": "Nombre de usuario de Wallabag",
|
||||
"form.integration.wallabag_password": "Contraseña de Wallabag",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.notion_activate": "Guardar entradas en Notion",
|
||||
"form.integration.notion_page_id": "ID de página de Notion",
|
||||
"form.integration.notion_token": "Token secreto de Notion",
|
||||
"form.integration.apprise_activate": "Enviar artículos a Apprise",
|
||||
"form.integration.apprise_url": "URL de la API de Apprise",
|
||||
"form.integration.apprise_services_url": "Lista separada por comas de las URL del servicio Apprise",
|
||||
"form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
|
||||
@@ -422,26 +456,26 @@
|
||||
"form.integration.espial_endpoint": "Acceso API de Espial",
|
||||
"form.integration.espial_api_key": "Clave de API de Espial",
|
||||
"form.integration.espial_tags": "Etiquetas de Espial",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.readwise_activate": "Guardar artículos en Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token de acceso a Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obtener tu token de acceso a Readwise",
|
||||
"form.integration.telegram_bot_activate": "Envíe nuevos artículos al chat de Telegram",
|
||||
"form.integration.telegram_bot_token": "Token de bot",
|
||||
"form.integration.telegram_chat_id": "ID de chat",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Deshabilitar la vista previa de la página web",
|
||||
"form.integration.telegram_bot_disable_notification": "Deshabilitar notificación",
|
||||
"form.integration.telegram_bot_disable_buttons": "Deshabilitar botones",
|
||||
"form.integration.linkace_activate": "Guardar artículos en LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_api_key": "Clave API de LinkAce",
|
||||
"form.integration.linkace_tags": "Etiquetas de LinkAce",
|
||||
"form.integration.linkace_is_private": "Marcar enlace como privado",
|
||||
"form.integration.linkace_check_disabled": "Deshabilitar la comprobación de enlace",
|
||||
"form.integration.linkding_activate": "Enviar artículos a Linkding",
|
||||
"form.integration.linkding_endpoint": "Acceso API de Linkding",
|
||||
"form.integration.linkding_api_key": "Clave de API de Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Etiquetas de Linkding",
|
||||
"form.integration.linkding_bookmark": "Marcar marcador como no leído",
|
||||
"form.integration.linkwarden_activate": "Enviar artículos a Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Acceso API de Linkwarden",
|
||||
@@ -451,23 +485,34 @@
|
||||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||
"form.integration.raindrop_activate": "Guardar artículos en Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Colección ID",
|
||||
"form.integration.raindrop_tags": "Etiquetas (separadas por comas)",
|
||||
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
||||
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_labels": "Etiquetas de Readeck",
|
||||
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.shiori_activate": "Guardar artículos a Shiori",
|
||||
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
|
||||
"form.integration.shiori_username": "Nombre de usuario de Shiori",
|
||||
"form.integration.shiori_password": "Contraseña de Shiori",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.shaarli_activate": "Guardar artículos en Shaarli",
|
||||
"form.integration.shaarli_endpoint": "URL de Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Secreto API de Shaarli",
|
||||
"form.integration.webhook_activate": "Habilitar Webhook",
|
||||
"form.integration.webhook_url": "URL de Webhook",
|
||||
"form.integration.webhook_secret": "Secreto de Webhook",
|
||||
"form.integration.rssbridge_activate": "Vericar RSS-Bridge al agregar suscripciones",
|
||||
"form.integration.rssbridge_url": "URL del servidro RSS-Bridge",
|
||||
"form.integration.ntfy_activate": "Enviar artículos a ntfy",
|
||||
"form.integration.ntfy_topic": "Tema Ntfy",
|
||||
"form.integration.ntfy_url": "URL de Ntfy (opcional, la predeterminada es ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Token de API de Ntfy (opcional)",
|
||||
"form.integration.ntfy_username": "Nombre de usuario de Ntfy (opcional)",
|
||||
"form.integration.ntfy_password": "Contraseña de Ntfy (opcional)",
|
||||
"form.integration.ntfy_icon_url": "URL del icono de Ntfy (opcional)",
|
||||
"form.api_key.label.description": "Etiqueta de clave API",
|
||||
"form.submit.loading": "Cargando...",
|
||||
"form.submit.saving": "Guardando...",
|
||||
@@ -499,34 +544,43 @@
|
||||
"hace %d años"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minuto antes de volver a intentarlo.",
|
||||
"Has activado demasiadas actualizaciones del feed. Espere %d minutos antes de volver a intentarlo."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "Todos los feeds se actualizan en segundo plano. Puede continuar usando Miniflux mientras se ejecuta este proceso.",
|
||||
"error.http_response_too_large": "La respuesta HTTP es demasiado grande. Puede aumentar el límite de tamaño de respuesta HTTP en la configuración global (requiere reiniciar el servidor).",
|
||||
"error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
|
||||
"error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
|
||||
"error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.tls_error": "Error de TLS: %q. Puede desactivar la verificación TLS en la configuración del feed si lo desea.",
|
||||
"error.network_operation": "Miniflux no puede acceder a este sitio web debido a un error de red: %v.",
|
||||
"error.network_timeout": "Este sitio web es demasiado lento y se agotó el tiempo de espera de la solicitud: %v",
|
||||
"error.http_client_error": "Error cliente HTTP: %v.",
|
||||
"error.http_not_authorized": "El acceso a este sitio web no está autorizado. Podría ser un nombre de usuario o contraseña incorrectos.",
|
||||
"error.http_too_many_requests": "Miniflux generó demasiadas solicitudes a este sitio web. Por favor, inténtalo de nuevo más tarde o cambia la configuración de la aplicación.",
|
||||
"error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
|
||||
"error.http_resource_not_found": "No se encuentra el recurso solicitado. Por favor, verifique la URL.",
|
||||
"error.http_internal_server_error": "El sitio web no está disponible en estos momentos debido a un error del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_service_unavailable": "El sitio web no está disponible en estos momentos debido a un error interno del servidor. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_gateway_timeout": "El sitio web no está disponible en este momento debido a un error de tiempo de espera de la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.http_unexpected_status_code": "El sitio web no está disponible en este momento debido a un código de estado HTTP inesperado: %d. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error.database_error": "Error en la base de datos: %v.",
|
||||
"error.category_not_found": "Esta categoría no existe o no pertenece a este usuario.",
|
||||
"error.duplicated_feed": "Este feed ya existe.",
|
||||
"error.unable_to_parse_feed": "No se puede analizar este feed: %v.",
|
||||
"error.feed_not_found": "Este feed no existe o no pertenece a este usuario.",
|
||||
"error.unable_to_detect_rssbridge": "No se puede detectar la fuente usando RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "No se puede detectar el formato del feed: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
|
||||
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango"
|
||||
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
|
||||
"enclosure_media_controls.seek" : "Buscar:",
|
||||
"enclosure_media_controls.seek.title" : "Buscar %s segundos",
|
||||
"enclosure_media_controls.speed" : "Velocidad:",
|
||||
"enclosure_media_controls.speed.faster" : "Más rápido",
|
||||
"enclosure_media_controls.speed.faster.title" : "Más rápido a %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Despacio",
|
||||
"enclosure_media_controls.speed.slower.title" : "Más despacio a %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Restablecer",
|
||||
"enclosure_media_controls.speed.reset.title" : "Restablecer la velocidad a 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
|
||||
"menu.show_all_entries": "Näytä kaikki artikkelit",
|
||||
"menu.show_only_unread_entries": "Näytä vain lukemattomat artikkelit",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "Päivitä",
|
||||
"menu.refresh_all_feeds": "Päivitä kaikki syötteet taustalla",
|
||||
"menu.edit_feed": "Muokkaa",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Haku",
|
||||
"search.placeholder": "Hae...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Seuraava",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Edellinen",
|
||||
"entry.status.unread": "Lukematon",
|
||||
"entry.status.read": "Luettu",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Asetukset",
|
||||
"page.settings.link_google_account": "Linkitä Google-tilini",
|
||||
"page.settings.unlink_google_account": "Poista Google-tilini linkitys",
|
||||
"page.settings.link_oidc_account": "Linkitä OpenID Connect -tilini",
|
||||
"page.settings.unlink_oidc_account": "Poista OpenID Connect -tilini linkitys",
|
||||
"page.settings.link_oidc_account": "Linkitä %s -tilini",
|
||||
"page.settings.unlink_oidc_account": "Poista %s -tilini linkitys",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Rekisteröi salasana",
|
||||
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Poista %d salasana",
|
||||
"Poista %d salasanaa"
|
||||
],
|
||||
"page.login.title": "Kirjaudu sisään",
|
||||
"page.login.google_signin": "Kirjaudu sisään Googlella",
|
||||
"page.login.oidc_signin": "Kirjaudu sisään OpenID Connectilla",
|
||||
"page.login.oidc_signin": "Kirjaudu sisään %silla",
|
||||
"page.login.webauthn_login": "Kirjaudu sisään salasanalla",
|
||||
"page.login.webauthn_login.error": "Ei voida kirjautua sisään salasanalla",
|
||||
"page.integrations.title": "Integraatiot",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||
"alert.no_category": "Ei ole kategoriaa.",
|
||||
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
|
||||
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
|
||||
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
|
||||
"alert.no_feed": "Sinulla ei ole tilauksia.",
|
||||
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
|
||||
@@ -299,6 +304,14 @@
|
||||
"error.password_min_length": "Salasanassa on oltava vähintään 6 merkkiä.",
|
||||
"error.settings_mandatory_fields": "Käyttäjätunnus, teema, kieli ja aikavyöhyke ovat pakollisia.",
|
||||
"error.settings_reading_speed_is_positive": "Lukunopeuksien on oltava positiivisia kokonaislukuja.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Artikkelien määrä sivulla ei kelpaa.",
|
||||
"error.feed_mandatory_fields": "URL-osoite ja kategoria ovat pakollisia.",
|
||||
"error.feed_already_exists": "Tämä syöte on jo olemassa.",
|
||||
@@ -318,6 +331,7 @@
|
||||
"form.feed.label.title": "Otsikko",
|
||||
"form.feed.label.site_url": "Sivuston URL-osoite",
|
||||
"form.feed.label.feed_url": "Syötteen URL-osoite",
|
||||
"form.feed.label.description": "Kuvaus",
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
|
||||
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
|
||||
@@ -335,6 +349,15 @@
|
||||
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
|
||||
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
|
||||
"form.prefs.label.custom_css": "Mukautettu CSS",
|
||||
"form.prefs.label.custom_js": "Mukautettu JavaScript",
|
||||
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
|
||||
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
|
||||
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
|
||||
"form.prefs.label.mark_read_on_view": "Merkitse kohdat automaattisesti luetuiksi, kun niitä tarkastellaan",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML-tiedosto",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Ota Fever API käyttöön",
|
||||
"form.integration.fever_username": "Fever-käyttäjätunnus",
|
||||
"form.integration.fever_password": "Fever-salasana",
|
||||
@@ -451,6 +485,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
||||
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
||||
"form.integration.readeck_api_key": "Readeck API-avain",
|
||||
@@ -468,6 +506,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API Key Label",
|
||||
"form.submit.loading": "Ladataan...",
|
||||
"form.submit.saving": "Tallennetaan...",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
|
||||
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella"
|
||||
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Tout marquer comme lu",
|
||||
"menu.show_all_entries": "Afficher tous les articles",
|
||||
"menu.show_only_unread_entries": "Afficher uniquement les articles non lus",
|
||||
"menu.show_only_starred_entries": "Afficher uniquement les favoris",
|
||||
"menu.refresh_feed": "Actualiser",
|
||||
"menu.refresh_all_feeds": "Actualiser les abonnements en arrière-plan",
|
||||
"menu.edit_feed": "Modifier",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Recherche",
|
||||
"search.placeholder": "Recherche...",
|
||||
"search.submit": "Rechercher",
|
||||
"pagination.last": "Dernière page",
|
||||
"pagination.next": "Suivant",
|
||||
"pagination.first": "Première page",
|
||||
"pagination.previous": "Précédent",
|
||||
"entry.status.unread": "Non lu",
|
||||
"entry.status.read": "Lu",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Réglages",
|
||||
"page.settings.link_google_account": "Associer mon compte Google",
|
||||
"page.settings.unlink_google_account": "Dissocier mon compte Google",
|
||||
"page.settings.link_oidc_account": "Associer mon compte OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Dissocier mon compte OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Associer mon compte %s",
|
||||
"page.settings.unlink_oidc_account": "Dissocier mon compte %s",
|
||||
"page.settings.webauthn.passkeys": "Clés d’accès",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Nom de la clé d’accès",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
|
||||
"page.settings.webauthn.register": "Enregister une nouvelle clé d’accès",
|
||||
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
|
||||
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Supprimer %d clé d’accès",
|
||||
"Supprimer %d clés d’accès"
|
||||
],
|
||||
"page.login.title": "Connexion",
|
||||
"page.login.google_signin": "Se connecter avec Google",
|
||||
"page.login.oidc_signin": "Se connecter avec OpenID Connect",
|
||||
"page.login.oidc_signin": "Se connecter avec %s",
|
||||
"page.login.webauthn_login": "Se connecter avec une clé d’accès",
|
||||
"page.login.webauthn_login.error": "Impossible de se connecter avec la clé d’accès",
|
||||
"page.integrations.title": "Intégrations",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
|
||||
"alert.no_category": "Il n'y a aucune catégorie.",
|
||||
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
|
||||
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
|
||||
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
|
||||
"alert.no_feed": "Vous n'avez aucun abonnement.",
|
||||
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
|
||||
@@ -292,6 +297,14 @@
|
||||
"error.password_min_length": "Vous devez utiliser au moins 6 caractères pour le mot de passe.",
|
||||
"error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
|
||||
"error.settings_reading_speed_is_positive": "Les vitesses de lecture doivent être des entiers positifs.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Règle de blocage invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
|
||||
"error.settings_block_rule_separator_required": "Règle de blocage invalide : le motif de la règle n°%d doit être séparé par un '='",
|
||||
"error.settings_block_rule_regex_required": "Règle de blocage invalide : le motif de la règle n°%d n'est pas fourni",
|
||||
"error.settings_block_rule_invalid_regex": "Règle de blocage invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Règle de conservation invalide : la règle n°%d ne contient pas un nom de champ valide (Options : %s)",
|
||||
"error.settings_keep_rule_separator_required": "Règle de conservation invalide : le motif de la règle n°%d doit être séparé par un '='",
|
||||
"error.settings_keep_rule_regex_required": "Règle de conservation invalide : le motif de la règle n°%d n'est pas fourni",
|
||||
"error.settings_keep_rule_invalid_regex": "Règle de conservation invalide : le motif de la règle n°%d n'est pas une expression régulière valide",
|
||||
"error.entries_per_page_invalid": "Le nombre d'entrées par page n'est pas valide.",
|
||||
"error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
|
||||
"error.feed_already_exists": "Ce flux existe déjà.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "Titre",
|
||||
"form.feed.label.site_url": "URL du site web",
|
||||
"form.feed.label.feed_url": "URL du flux",
|
||||
"form.feed.label.description": "Description",
|
||||
"form.feed.label.category": "Catégorie",
|
||||
"form.feed.label.crawler": "Récupérer le contenu original",
|
||||
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
|
||||
@@ -335,6 +349,15 @@
|
||||
"form.feed.label.disabled": "Ne pas actualiser ce flux",
|
||||
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
|
||||
"form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
|
||||
"form.feed.label.ntfy_activate": "Activer les notifications",
|
||||
"form.feed.label.ntfy_priority": "Priorité de notification",
|
||||
"form.feed.label.ntfy_max_priority": "Priorité maximale de notification",
|
||||
"form.feed.label.ntfy_high_priority": "Priorité élevée de notification",
|
||||
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
|
||||
"form.feed.label.ntfy_low_priority": "Priorité basse de notification",
|
||||
"form.feed.label.ntfy_min_priority": "Priorité minimale de notification",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "Général",
|
||||
"form.feed.fieldset.rules": "Règles",
|
||||
"form.feed.fieldset.network_settings": "Paramètres réseau",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
|
||||
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
|
||||
"form.prefs.label.custom_css": "Feuille de style personnalisée",
|
||||
"form.prefs.label.custom_js": "Code JavaScript personnalisé",
|
||||
"form.prefs.label.entry_order": "Colonne de tri des entrées",
|
||||
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
|
||||
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
|
||||
"form.prefs.label.mark_read_on_view": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marquer automatiquement les entrées comme lues lorsqu'elles sont consultées. Pour l'audio/vidéo, marquer comme lues après 90%%",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Marqué les entrées comme lues uniquement après 90%% de lecture de l'audio/vidéo",
|
||||
"form.prefs.label.mark_read_manually": "Marqué les entrées comme lues manuellement",
|
||||
"form.prefs.fieldset.application_settings": "Paramètres de l'application",
|
||||
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
|
||||
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
|
||||
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
|
||||
"form.prefs.label.external_font_hosts": "Polices externes autorisées",
|
||||
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
|
||||
"error.settings_invalid_domain_list": "Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.",
|
||||
"form.import.label.file": "Fichier OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Sauvegarder les entrées vers Betula",
|
||||
"form.integration.betula_url": "URL du serveur Betula",
|
||||
"form.integration.betula_token": "Jeton de sécurité de l'API de Betula",
|
||||
"form.integration.fever_activate": "Activer l'API de Fever",
|
||||
"form.integration.fever_username": "Nom d'utilisateur pour l'API de Fever",
|
||||
"form.integration.fever_password": "Mot de passe pour l'API de Fever",
|
||||
@@ -422,7 +456,7 @@
|
||||
"form.integration.espial_endpoint": "URL de l'API de Espial",
|
||||
"form.integration.espial_api_key": "Clé d'API de Espial",
|
||||
"form.integration.espial_tags": "Libellés de Espial",
|
||||
"form.integration.readwise_activate": "Enregistrer les entrées dans Readwise Reader",
|
||||
"form.integration.readwise_activate": "Enregistrer les entrées vers Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Jeton d'accès au lecteur Readwise",
|
||||
"form.integration.readwise_api_key_link": "Obtenez votre jeton d'accès Readwise",
|
||||
"form.integration.telegram_bot_activate": "Envoyer les nouveaux articles vers Telegram",
|
||||
@@ -431,17 +465,17 @@
|
||||
"form.integration.telegram_topic_id": "Identifiant du sujet (Topic ID)",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Désactiver l'aperçu de la page Web",
|
||||
"form.integration.telegram_bot_disable_notification": "Désactiver les notifications",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.telegram_bot_disable_buttons": "Désactiver les boutons",
|
||||
"form.integration.linkace_activate": "Enregistrer les entrées vers LinkAce",
|
||||
"form.integration.linkace_endpoint": "Point de terminaison de l'API LinkAce",
|
||||
"form.integration.linkace_api_key": "Clé d'API LinkAce",
|
||||
"form.integration.linkace_tags": "Étiquettes LinkAce",
|
||||
"form.integration.linkace_is_private": "Marquer le lien comme privé",
|
||||
"form.integration.linkace_check_disabled": "Désactiver la vérification des liens",
|
||||
"form.integration.linkding_activate": "Sauvegarder les articles vers Linkding",
|
||||
"form.integration.linkding_endpoint": "URL de l'API de Linkding",
|
||||
"form.integration.linkding_api_key": "Clé d'API de Linkding",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_tags": "Libellés",
|
||||
"form.integration.linkding_bookmark": "Marquer le lien comme non lu",
|
||||
"form.integration.linkwarden_activate": "Sauvegarder les articles vers Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "URL de l'API de Linkwarden",
|
||||
@@ -451,6 +485,10 @@
|
||||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||
"form.integration.matrix_bot_url": "URL du serveur Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||
"form.integration.raindrop_activate": "Enregistrer les entrées vers Raindrop",
|
||||
"form.integration.raindrop_token": "Jeton d'accès de Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Identifiant de la collection",
|
||||
"form.integration.raindrop_tags": "Libellés (séparées par des virgules)",
|
||||
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
||||
@@ -468,6 +506,13 @@
|
||||
"form.integration.webhook_secret": "Secret du webhook",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Envoyer les entrées vers ntfy",
|
||||
"form.integration.ntfy_topic": "Sujet Ntfy",
|
||||
"form.integration.ntfy_url": "URL de Ntfy (optionnel, ntfy.sh par défaut)",
|
||||
"form.integration.ntfy_api_token": "Jeton d'API Ntfy (optionnel)",
|
||||
"form.integration.ntfy_username": "Nom d'utilisateur Ntfy (optionnel)",
|
||||
"form.integration.ntfy_password": "Mot de passe Ntfy (facultatif)",
|
||||
"form.integration.ntfy_icon_url": "URL de l'icône Ntfy (facultatif)",
|
||||
"form.api_key.label.description": "Libellé de la clé d'API",
|
||||
"form.submit.loading": "Chargement...",
|
||||
"form.submit.saving": "Sauvegarde en cours...",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
|
||||
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
|
||||
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites"
|
||||
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
|
||||
"enclosure_media_controls.seek" : "Avancer/Reculer :",
|
||||
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
|
||||
"enclosure_media_controls.speed" : "Vitesse :",
|
||||
"enclosure_media_controls.speed.faster" : "Accélérer",
|
||||
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Ralentir",
|
||||
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Réinitialiser",
|
||||
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
|
||||
"menu.show_all_entries": "सभी प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_unread_entries": "सभी अपठित प्रविष्टियाँ दिखाए",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "ताज़ा करें",
|
||||
"menu.refresh_all_feeds": "पृष्ठभूमि में सभी फ़ीड को ताज़ा करें",
|
||||
"menu.edit_feed": "फ़ीड संपाद करे",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "खोजे",
|
||||
"search.placeholder": "खोजे...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "अगला",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "पिछला",
|
||||
"entry.status.unread": "अपठित",
|
||||
"entry.status.read": "पढ़े",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "समायोजन",
|
||||
"page.settings.link_google_account": "मेरा गूगल खाता जोरीय",
|
||||
"page.settings.unlink_google_account": "मेरा गूगल खाता हटाय",
|
||||
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय",
|
||||
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय",
|
||||
"page.settings.link_oidc_account": "मेरा ओपन-ईद खाता जोरीय (%s)",
|
||||
"page.settings.unlink_oidc_account": "मेरा ओपन-ईद खाता हटाय (%s)",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "रजिस्टर पासकी",
|
||||
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"%d पासकुंजी निकालें",
|
||||
"%d पासकी हटाएं"
|
||||
],
|
||||
"page.login.title": "साइन इन करें",
|
||||
"page.login.google_signin": "गूगल के साथ साइन इन करें",
|
||||
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें",
|
||||
"page.login.oidc_signin": "ओपन-ईद के साथ साइन इन करें (%s)",
|
||||
"page.login.webauthn_login": "पासकी से लॉगिन करें",
|
||||
"page.login.webauthn_login.error": "पासकी से लॉगिन करने में असमर्थ",
|
||||
"page.integrations.title": "एकीकरण",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
||||
"alert.no_category": "कोई श्रेणी नहीं है।",
|
||||
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
|
||||
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
|
||||
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
||||
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
|
||||
@@ -299,6 +304,14 @@
|
||||
"error.password_min_length": "पासवर्ड में कम से कम 6 अक्षर होने चाहिए।",
|
||||
"error.settings_mandatory_fields": "उपयोगकर्ता नाम, विषयवस्तु, भाषा और समयक्षेत्र फ़ील्ड अनिवार्य हैं।",
|
||||
"error.settings_reading_speed_is_positive": "पढ़ने की गति सकारात्मक पूर्णांक होनी चाहिए।",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "प्रति पृष्ठ प्रविष्टियों की संख्या मान्य नहीं है।",
|
||||
"error.feed_mandatory_fields": "URL और श्रेणी अनिवार्य हैं।",
|
||||
"error.feed_already_exists": "यह फ़ीड पहले से मौजूद है.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "शीर्षक",
|
||||
"form.feed.label.site_url": "साइट यूआरएल",
|
||||
"form.feed.label.feed_url": "फ़ीड यूआरएल",
|
||||
"form.feed.label.description": "विवरण",
|
||||
"form.feed.label.category": "श्रेणी",
|
||||
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
||||
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
|
||||
@@ -335,6 +349,15 @@
|
||||
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
|
||||
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
|
||||
"form.prefs.label.custom_css": "कस्टम सीएसएस",
|
||||
"form.prefs.label.custom_js": "कस्टम जेएस",
|
||||
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
|
||||
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
|
||||
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
|
||||
"form.prefs.label.mark_read_on_view": "देखे जाने पर स्वचालित रूप से प्रविष्टियों को पढ़ने के रूप में चिह्नित करें",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "ओपीएमएल फ़ाइल",
|
||||
"form.import.label.url": "यूआरएल",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "फीवर एपीआई सक्रिय करें",
|
||||
"form.integration.fever_username": "फीवर उपयोगकर्ता नाम",
|
||||
"form.integration.fever_password": "फीवर पासवर्ड",
|
||||
@@ -451,8 +485,12 @@
|
||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
||||
"form.integration.readeck_endpoint": "Readeck यूआरएल",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
@@ -468,6 +506,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "एपीआई कुंजी लेबल",
|
||||
"form.submit.loading": "लोड हो रहा है...",
|
||||
"form.submit.saving": "सहेजा जा रहा है...",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
|
||||
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है"
|
||||
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
|
||||
"menu.show_all_entries": "Tampilkan semua entri",
|
||||
"menu.show_only_unread_entries": "Tampilkan hanya entri yang belum dibaca",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "Muat ulang",
|
||||
"menu.refresh_all_feeds": "Muat ulang semua umpan di latar belakang",
|
||||
"menu.edit_feed": "Sunting",
|
||||
@@ -56,6 +57,8 @@
|
||||
"search.placeholder": "Cari...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Berikutnya",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Sebelumnya",
|
||||
"entry.status.unread": "Belum dibaca",
|
||||
"entry.status.read": "Telah dibaca",
|
||||
@@ -199,8 +202,8 @@
|
||||
"page.settings.title": "Pengaturan",
|
||||
"page.settings.link_google_account": "Tautkan akun Google saya",
|
||||
"page.settings.unlink_google_account": "Putuskan akun Google saya",
|
||||
"page.settings.link_oidc_account": "Tautkan akun OpenID Connect saya",
|
||||
"page.settings.unlink_oidc_account": "Putuskan akun OpenID Connect saya",
|
||||
"page.settings.link_oidc_account": "Tautkan akun %s saya",
|
||||
"page.settings.unlink_oidc_account": "Putuskan akun %s saya",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -208,12 +211,13 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Register passkey",
|
||||
"page.settings.webauthn.register.error": "Unable to register passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Remove %d passkey"
|
||||
],
|
||||
"page.login.title": "Masuk",
|
||||
"page.login.google_signin": "Masuk dengan Google",
|
||||
"page.login.oidc_signin": "Masuk dengan OpenID Connect",
|
||||
"page.login.oidc_signin": "Masuk dengan %s",
|
||||
"page.login.webauthn_login": "Login with passkey",
|
||||
"page.login.webauthn_login.error": "Unable to login with passkey",
|
||||
"page.integrations.title": "Integrasi",
|
||||
@@ -248,6 +252,7 @@
|
||||
"alert.no_bookmark": "Tidak ada markah.",
|
||||
"alert.no_category": "Tidak ada kategori.",
|
||||
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
|
||||
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
|
||||
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
|
||||
"alert.no_feed": "Anda tidak memiliki langganan.",
|
||||
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
|
||||
@@ -289,6 +294,14 @@
|
||||
"error.password_min_length": "Kata sandi harus memiliki setidaknya 6 karakter.",
|
||||
"error.settings_mandatory_fields": "Harus ada nama pengguna, tema, bahasa, dan zona waktu.",
|
||||
"error.settings_reading_speed_is_positive": "Kecepatan membaca harus integer positif.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Jumlah entri per halaman tidak valid.",
|
||||
"error.feed_mandatory_fields": "Harus ada URL dan kategorinya.",
|
||||
"error.feed_already_exists": "Umpan ini sudah ada.",
|
||||
@@ -306,6 +319,7 @@
|
||||
"form.feed.label.title": "Judul",
|
||||
"form.feed.label.site_url": "URL Situs",
|
||||
"form.feed.label.feed_url": "URL Umpan",
|
||||
"form.feed.label.description": "Deskripsi",
|
||||
"form.feed.label.category": "Kategori",
|
||||
"form.feed.label.crawler": "Ambil konten asli",
|
||||
"form.feed.label.feed_username": "Nama Pengguna Umpan",
|
||||
@@ -325,6 +339,15 @@
|
||||
"form.feed.label.disabled": "Jangan perbarui umpan ini",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -361,15 +384,26 @@
|
||||
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
|
||||
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
|
||||
"form.prefs.label.custom_css": "Modifikasi CSS",
|
||||
"form.prefs.label.custom_js": "Modifikasi JavaScript",
|
||||
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
|
||||
"form.prefs.label.default_home_page": "Beranda Baku",
|
||||
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
|
||||
"form.prefs.label.mark_read_on_view": "Secara otomatis menandai entri sebagai telah dibaca saat dilihat",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Berkas OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Aktifkan API Fever",
|
||||
"form.integration.fever_username": "Nama Pengguna Fever",
|
||||
"form.integration.fever_password": "Kata Sandi Fever",
|
||||
@@ -441,6 +475,10 @@
|
||||
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
||||
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
||||
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
||||
"form.integration.readeck_api_key": "Kunci API Readeck",
|
||||
@@ -458,6 +496,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Label Kunci API",
|
||||
"form.submit.loading": "Memuat...",
|
||||
"form.submit.saving": "Menyimpan...",
|
||||
@@ -511,5 +556,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
|
||||
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan"
|
||||
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
|
||||
"menu.show_all_entries": "Mostra tutte le voci",
|
||||
"menu.show_only_unread_entries": "Mostra solo voci non lette",
|
||||
"menu.show_only_starred_entries": "Mostra solo voci preferiti",
|
||||
"menu.refresh_feed": "Aggiorna",
|
||||
"menu.refresh_all_feeds": "Aggiorna tutti i feed in background",
|
||||
"menu.edit_feed": "Modifica",
|
||||
@@ -56,6 +57,8 @@
|
||||
"search.placeholder": "Cerca...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Successivo",
|
||||
"pagination.last": "Last",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Precedente",
|
||||
"entry.status.unread": "Da leggere",
|
||||
"entry.status.read": "Letto",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Impostazioni",
|
||||
"page.settings.link_google_account": "Collega il mio account Google",
|
||||
"page.settings.unlink_google_account": "Scollega il mio account Google",
|
||||
"page.settings.link_oidc_account": "Collega il mio account OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Scollega il mio account OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Collega il mio account %s",
|
||||
"page.settings.unlink_oidc_account": "Scollega il mio account %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Registra la chiave di accesso",
|
||||
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Rimuovi %d passkey",
|
||||
"Rimuovi %d passkey"
|
||||
],
|
||||
"page.login.title": "Accedi",
|
||||
"page.login.google_signin": "Accedi tramite Google",
|
||||
"page.login.oidc_signin": "Accedi tramite OpenID Connect",
|
||||
"page.login.oidc_signin": "Accedi tramite %s",
|
||||
"page.login.webauthn_login": "Accedi con passkey",
|
||||
"page.login.webauthn_login.error": "Impossibile accedere con passkey",
|
||||
"page.integrations.title": "Integrazioni",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "Nessun preferito disponibile.",
|
||||
"alert.no_category": "Nessuna categoria disponibile.",
|
||||
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
|
||||
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
|
||||
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
|
||||
"alert.no_feed": "Nessun feed disponibile.",
|
||||
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
|
||||
@@ -292,6 +297,14 @@
|
||||
"error.password_min_length": "La password deve contenere almeno 6 caratteri.",
|
||||
"error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
|
||||
"error.settings_reading_speed_is_positive": "Le velocità di lettura devono essere numeri interi positivi.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Il numero di articoli per pagina non è valido.",
|
||||
"error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
|
||||
"error.feed_already_exists": "Questo feed esiste già.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "Titolo",
|
||||
"form.feed.label.site_url": "URL del sito",
|
||||
"form.feed.label.feed_url": "URL del feed",
|
||||
"form.feed.label.description": "Descrizione",
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.crawler": "Scarica il contenuto integrale",
|
||||
"form.feed.label.feed_username": "Nome utente del feed",
|
||||
@@ -335,6 +349,15 @@
|
||||
"form.feed.label.disabled": "Non aggiornare questo feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
|
||||
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
|
||||
"form.prefs.label.custom_css": "CSS personalizzati",
|
||||
"form.prefs.label.custom_js": "JavaScript personalizzati",
|
||||
"form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
|
||||
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
|
||||
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
|
||||
"form.prefs.label.mark_read_on_view": "Contrassegna automaticamente le voci come lette quando visualizzate",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "File OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Abilita l'API di Fever",
|
||||
"form.integration.fever_username": "Nome utente dell'account Fever",
|
||||
"form.integration.fever_password": "Password dell'account Fever",
|
||||
@@ -451,6 +485,10 @@
|
||||
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del server Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
|
||||
"form.integration.readeck_api_key": "API key dell'account Readeck",
|
||||
@@ -469,6 +507,13 @@
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.api_key.label.description": "Etichetta chiave API",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.submit.loading": "Caricamento in corso...",
|
||||
"form.submit.saving": "Salvataggio in corso...",
|
||||
"time_elapsed.not_yet": "non ancora",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
|
||||
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo"
|
||||
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "すべて既読にする",
|
||||
"menu.show_all_entries": "すべての記事を表示",
|
||||
"menu.show_only_unread_entries": "未読の記事だけを表示",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "すべてのフィードをバックグラウンドで更新",
|
||||
"menu.edit_feed": "編集",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "検索",
|
||||
"search.placeholder": "…を検索",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "次",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "前",
|
||||
"entry.status.unread": "未読にする",
|
||||
"entry.status.read": "既読にする",
|
||||
@@ -199,8 +202,8 @@
|
||||
"page.settings.title": "設定",
|
||||
"page.settings.link_google_account": "Google アカウントと接続する",
|
||||
"page.settings.unlink_google_account": "Google アカウントと接続を解除する",
|
||||
"page.settings.link_oidc_account": "OpenID Connect アカウントと接続する",
|
||||
"page.settings.unlink_oidc_account": "OpenID Connect アカウントと接続を解除する",
|
||||
"page.settings.link_oidc_account": "%s アカウントと接続する",
|
||||
"page.settings.unlink_oidc_account": "%s アカウントと接続を解除する",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -208,12 +211,13 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "パスキーを登録する",
|
||||
"page.settings.webauthn.register.error": "パスキーを登録できません",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"%d 個のパスキーを削除"
|
||||
],
|
||||
"page.login.title": "ログイン",
|
||||
"page.login.google_signin": "Google アカウントでログイン",
|
||||
"page.login.oidc_signin": "OpenID Connect アカウントでログイン",
|
||||
"page.login.oidc_signin": "%s アカウントでログイン",
|
||||
"page.login.webauthn_login": "パスキーでログイン",
|
||||
"page.login.webauthn_login.error": "パスキーでログインできない",
|
||||
"page.integrations.title": "連携",
|
||||
@@ -248,6 +252,7 @@
|
||||
"alert.no_bookmark": "現在星付きはありません。",
|
||||
"alert.no_category": "カテゴリが存在しません。",
|
||||
"alert.no_category_entry": "このカテゴリには記事がありません。",
|
||||
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
|
||||
"alert.no_feed_entry": "このフィードには記事がありません。",
|
||||
"alert.no_feed": "何も購読していません。",
|
||||
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
|
||||
@@ -289,6 +294,14 @@
|
||||
"error.password_min_length": "パスワードは6文字以上である必要があります。",
|
||||
"error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンのすべてが必要です。",
|
||||
"error.settings_reading_speed_is_positive": "読書速度は正の整数である必要があります。",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "ページあたりの記事数が無効です。",
|
||||
"error.feed_mandatory_fields": "URL と カテゴリが必要です。",
|
||||
"error.feed_already_exists": "このフィードは既に存在します。",
|
||||
@@ -306,6 +319,7 @@
|
||||
"form.feed.label.title": "タイトル",
|
||||
"form.feed.label.site_url": "サイト URL",
|
||||
"form.feed.label.feed_url": "フィード URL",
|
||||
"form.feed.label.description": "説明",
|
||||
"form.feed.label.category": "カテゴリ",
|
||||
"form.feed.label.crawler": "オリジナルの内容を取得",
|
||||
"form.feed.label.feed_username": "フィードのユーザー名",
|
||||
@@ -325,6 +339,15 @@
|
||||
"form.feed.label.disabled": "このフィードを更新しない",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "未読一覧に記事を表示しない",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -361,15 +384,26 @@
|
||||
"form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
|
||||
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
|
||||
"form.prefs.label.custom_css": "カスタム CSS",
|
||||
"form.prefs.label.custom_js": "カスタム JavaScript",
|
||||
"form.prefs.label.entry_order": "記事の表示順の基準",
|
||||
"form.prefs.label.default_home_page": "デフォルトのトップページ",
|
||||
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
|
||||
"form.prefs.label.mark_read_on_view": "表示時にエントリを自動的に既読としてマークします",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML ファイル",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Fever API を有効にする",
|
||||
"form.integration.fever_username": "Fever のユーザー名",
|
||||
"form.integration.fever_password": "Fever のパスワード",
|
||||
@@ -441,6 +475,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
||||
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
|
||||
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Readeck に記事を保存する",
|
||||
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck の API key",
|
||||
@@ -458,6 +496,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API キーラベル",
|
||||
"form.submit.loading": "読み込み中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
@@ -511,5 +556,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
|
||||
"error.settings_media_playback_rate_range": "再生速度が範囲外"
|
||||
"error.settings_media_playback_rate_range": "再生速度が範囲外",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "Ga naar inhoud",
|
||||
"confirm.question": "Weet je het zeker?",
|
||||
"confirm.question.refresh": "Wil je een gedwongen vernieuwing uitvoeren?",
|
||||
"confirm.question.refresh": "Wil je vernieuwen forceren?",
|
||||
"confirm.yes": "ja",
|
||||
"confirm.no": "nee",
|
||||
"confirm.loading": "Bezig...",
|
||||
"action.subscribe": "Abboneren",
|
||||
"action.subscribe": "Abonneren",
|
||||
"action.save": "Opslaan",
|
||||
"action.or": "of",
|
||||
"action.cancel": "annuleren",
|
||||
"action.remove": "Verwijderen",
|
||||
"action.remove_feed": "Verwijder deze feed",
|
||||
"action.update": "Updaten",
|
||||
"action.update": "Bijwerken",
|
||||
"action.edit": "Bewerken",
|
||||
"action.download": "Download",
|
||||
"action.import": "Importeren",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Sneltoets: %s",
|
||||
"tooltip.logged_user": "Ingelogd als %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Startpagina",
|
||||
"menu.unread": "Ongelezen",
|
||||
"menu.starred": "Favorieten",
|
||||
"menu.history": "Geschiedenis",
|
||||
@@ -31,16 +31,17 @@
|
||||
"menu.preferences": "Voorkeuren",
|
||||
"menu.integrations": "Integraties",
|
||||
"menu.sessions": "Sessies",
|
||||
"menu.users": "Users",
|
||||
"menu.users": "Gebruikers",
|
||||
"menu.about": "Over",
|
||||
"menu.export": "Exporteren",
|
||||
"menu.import": "Importeren",
|
||||
"menu.search": "Zoeken",
|
||||
"menu.create_category": "Categorie toevoegen",
|
||||
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alle items als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alles als gelezen",
|
||||
"menu.show_all_entries": "Toon alle artikelen",
|
||||
"menu.show_only_unread_entries": "Toon alleen ongelezen artikelen",
|
||||
"menu.show_only_starred_entries": "Toon alleen favorieten",
|
||||
"menu.refresh_feed": "Vernieuwen",
|
||||
"menu.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
|
||||
"menu.edit_feed": "Bewerken",
|
||||
@@ -48,37 +49,39 @@
|
||||
"menu.add_feed": "Feed toevoegen",
|
||||
"menu.add_user": "Gebruiker toevoegen",
|
||||
"menu.flush_history": "Verwijder geschiedenis",
|
||||
"menu.feed_entries": "Lidwoord",
|
||||
"menu.feed_entries": "Artikelen",
|
||||
"menu.api_keys": "API-sleutels",
|
||||
"menu.create_api_key": "Maak een nieuwe API-sleutel",
|
||||
"menu.shared_entries": "Gedeelde vermeldingen",
|
||||
"menu.shared_entries": "Gedeelde artikelen",
|
||||
"search.label": "Zoeken",
|
||||
"search.placeholder": "Zoeken...",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "Zoeken",
|
||||
"pagination.last": "Laatste",
|
||||
"pagination.next": "Volgende",
|
||||
"pagination.first": "Eerste",
|
||||
"pagination.previous": "Vorige",
|
||||
"entry.status.unread": "Ongelezen",
|
||||
"entry.status.read": "Gelezen",
|
||||
"entry.status.toast.unread": "Gemarkeerd als ongelezen",
|
||||
"entry.status.toast.read": "Gemarkeerd als gelezen",
|
||||
"entry.status.title": "Verander status van item",
|
||||
"entry.bookmark.toggle.on": "Ster toevoegen",
|
||||
"entry.bookmark.toggle.off": "Ster weghalen",
|
||||
"entry.bookmark.toast.on": "Met ster",
|
||||
"entry.bookmark.toast.off": "Ster verwijderd",
|
||||
"entry.state.saving": "Opslaag...",
|
||||
"entry.status.title": "Verander artikelstatus",
|
||||
"entry.bookmark.toggle.on": "Favoriet",
|
||||
"entry.bookmark.toggle.off": "Favoriet verwijderen",
|
||||
"entry.bookmark.toast.on": "Favoriet toegevoegd",
|
||||
"entry.bookmark.toast.off": "Favoriet verwijderd",
|
||||
"entry.state.saving": "Opslaan...",
|
||||
"entry.state.loading": "Laden...",
|
||||
"entry.save.label": "Opslaan",
|
||||
"entry.save.title": "Artikel opslaan",
|
||||
"entry.save.completed": "Done!",
|
||||
"entry.save.completed": "Klaar!",
|
||||
"entry.save.toast.completed": "Artikel opgeslagen",
|
||||
"entry.scraper.label": "Downloaden",
|
||||
"entry.scraper.title": "Fetch original content",
|
||||
"entry.scraper.title": "Originele inhoud ophalen",
|
||||
"entry.scraper.completed": "Klaar!",
|
||||
"entry.external_link.label": "Externe link",
|
||||
"entry.comments.label": "Comments",
|
||||
"entry.comments.title": "Bekijk de reacties",
|
||||
"entry.share.label": "Deel",
|
||||
"entry.comments.label": "Reacties",
|
||||
"entry.comments.title": "Bekijk reacties",
|
||||
"entry.share.label": "Delen",
|
||||
"entry.share.title": "Deel dit artikel",
|
||||
"entry.unshare.label": "Delen ongedaan maken",
|
||||
"entry.shared_entry.title": "Open de openbare link",
|
||||
@@ -87,117 +90,117 @@
|
||||
"%d minuut leestijd",
|
||||
"%d minuten leestijd"
|
||||
],
|
||||
"entry.tags.label": "Labels:",
|
||||
"page.shared_entries.title": "Gedeelde vermeldingen",
|
||||
"entry.tags.label": "Tags:",
|
||||
"page.shared_entries.title": "Gedeelde artikelen",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d gedeeld artikel",
|
||||
"%d gedeelde artikelen"
|
||||
],
|
||||
"page.unread.title": "Ongelezen",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d ongelezen artikel",
|
||||
"%d ongelezen artikelen"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d artikel totaal",
|
||||
"%d artikelen totaal"
|
||||
],
|
||||
"page.starred.title": "Favorieten",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d favoriet artikel",
|
||||
"%d favoriete artikelen"
|
||||
],
|
||||
"page.categories.title": "Categorieën",
|
||||
"page.categories.no_feed": "Geen feeds.",
|
||||
"page.categories.entries": "Lidwoord",
|
||||
"page.categories.feeds": "Abonnementen",
|
||||
"page.categories.no_feed": "Geen feed.",
|
||||
"page.categories.entries": "Artikelen",
|
||||
"page.categories.feeds": "Feeds",
|
||||
"page.categories.feed_count": [
|
||||
"Er is %d feed.",
|
||||
"Er zijn %d feeds."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d categorie",
|
||||
"%d categorieën"
|
||||
],
|
||||
"page.new_category.title": "Nieuwe categorie",
|
||||
"page.new_user.title": "Nieuwe gebruiker",
|
||||
"page.edit_category.title": "Bewerken van categorie: %s",
|
||||
"page.edit_category.title": "Bewerk categorie: %s",
|
||||
"page.edit_user.title": "Bewerk gebruiker: %s",
|
||||
"page.feeds.title": "Feeds",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.feeds.last_check": "Laatste update:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Aantal gelezen vermeldingen",
|
||||
"page.category_label": "Categorie: %s",
|
||||
"page.feeds.last_check": "Laatste controle:",
|
||||
"page.feeds.next_check": "Volgende controle:",
|
||||
"page.feeds.read_counter": "Aantal gelezen artikelen",
|
||||
"page.feeds.error_count": [
|
||||
"%d error",
|
||||
"%d errors"
|
||||
"%d fout",
|
||||
"%d fouten"
|
||||
],
|
||||
"page.history.title": "Geschiedenis",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d gelezen artikel",
|
||||
"%d gelezen artikelen"
|
||||
],
|
||||
"page.import.title": "Importeren",
|
||||
"page.login.title": "Inloggen",
|
||||
"page.search.title": "Zoekresultaten",
|
||||
"page.about.title": "Over",
|
||||
"page.about.credits": "Copyrights",
|
||||
"page.about.credits": "Credits",
|
||||
"page.about.version": "Versie:",
|
||||
"page.about.build_date": "Datum build:",
|
||||
"page.about.build_date": "Compilatiedatum:",
|
||||
"page.about.author": "Auteur:",
|
||||
"page.about.license": "Licentie:",
|
||||
"page.about.global_config_options": "globale configuratie-opties",
|
||||
"page.about.global_config_options": "Globale Configuratie Opties",
|
||||
"page.about.postgres_version": "Postgres versie:",
|
||||
"page.about.go_version": "Go versie:",
|
||||
"page.add_feed.title": "Nieuwe feed",
|
||||
"page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
|
||||
"page.add_feed.no_category": "Er is geen categorie. Je moet minstens één categorie hebben.",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.submit": "Feed zoeken",
|
||||
"page.add_feed.legend.advanced_options": "Geavanceerde mogelijkheden",
|
||||
"page.add_feed.legend.advanced_options": "Geavanceerde opties",
|
||||
"page.add_feed.choose_feed": "Feed kiezen",
|
||||
"page.edit_feed.title": "Bewerken van feed: %s",
|
||||
"page.edit_feed.last_check": "Laatste update:",
|
||||
"page.edit_feed.last_modified_header": "LastModified-header:",
|
||||
"page.edit_feed.etag_header": "ETAG-header:",
|
||||
"page.edit_feed.title": "Bewerk feed: %s",
|
||||
"page.edit_feed.last_check": "Laatste controle:",
|
||||
"page.edit_feed.last_modified_header": "LastModified header:",
|
||||
"page.edit_feed.etag_header": "ETAG header:",
|
||||
"page.edit_feed.no_header": "Geen",
|
||||
"page.edit_feed.last_parsing_error": "Laatste parse error",
|
||||
"page.edit_feed.last_parsing_error": "Laatste analysefout",
|
||||
"page.entry.attachments": "Bijlagen",
|
||||
"page.keyboard_shortcuts.title": "Sneltoetsen",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Naviguatie tussen menu's",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigatie tussen items",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Naviguatie tussen pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Actions",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigeren door menu's",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigeren door artikelen",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigeren door pagina's",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Acties",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Ga naar ongelezen",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Ga naar favorieten",
|
||||
"page.keyboard_shortcuts.go_to_history": "Ga naar geschiedenis",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Ga naar feeds",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Ga naar categorieën",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Ga naar instellingen",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Laat sneltoetsen zien",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Vorige item",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Volgende item",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Sneltoetsen tonen",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Vorig artikel",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Volgend artikel",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
|
||||
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste artikel",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste artikel",
|
||||
"page.keyboard_shortcuts.open_item": "Open geselecteerd artikel",
|
||||
"page.keyboard_shortcuts.open_original": "Open originele link",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
|
||||
"page.keyboard_shortcuts.open_comments": "Open opmerkingen link",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Open de reactiekoppeling op het huidige tabblad",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Open originele link in huidig tabblad",
|
||||
"page.keyboard_shortcuts.open_comments": "Open reacties",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Open reacties in huidig tabblad",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Markeer gelezen/ongelezen, focus volgende",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Markeer gelezen/ongelezen, focus vorige",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Vernieuw alle feeds in de achtergrond",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"page.keyboard_shortcuts.download_content": "Download originele content",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Ster toevoegen/weghalen",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Markeer huidige pagina als gelezen",
|
||||
"page.keyboard_shortcuts.download_content": "Download originele inhoud",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Favoriet toevoegen/verwijderen",
|
||||
"page.keyboard_shortcuts.save_article": "Artikel opslaan",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Scroll artikel naar boven",
|
||||
"page.keyboard_shortcuts.remove_feed": "Verwijder deze feed",
|
||||
"page.keyboard_shortcuts.go_to_search": "Focus instellen op zoekformulier",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.close_modal": "Sluit dialoogscherm",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Bijlagen van artikel openen/sluiten",
|
||||
"page.keyboard_shortcuts.close_modal": "Dialoogvenster sluiten",
|
||||
"page.users.title": "Gebruikers",
|
||||
"page.users.username": "Gebruikersnaam",
|
||||
"page.users.never_logged": "Nooit",
|
||||
@@ -205,27 +208,28 @@
|
||||
"page.users.admin.no": "Nee",
|
||||
"page.users.actions": "Acties",
|
||||
"page.users.last_login": "Laatste login",
|
||||
"page.users.is_admin": "Administrator",
|
||||
"page.users.is_admin": "Beheerder",
|
||||
"page.settings.title": "Instellingen",
|
||||
"page.settings.link_google_account": "Koppel mijn Google-account",
|
||||
"page.settings.unlink_google_account": "Ontkoppel mijn Google-account",
|
||||
"page.settings.link_oidc_account": "Koppel mijn OpenID Connect-account",
|
||||
"page.settings.unlink_oidc_account": "Ontkoppel mijn OpenID Connect-account",
|
||||
"page.settings.link_oidc_account": "Koppel mijn %s account",
|
||||
"page.settings.unlink_oidc_account": "Ontkoppel mijn %s account",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Wachtwoord registreren",
|
||||
"page.settings.webauthn.register.error": "Kan wachtwoord niet registreren",
|
||||
"page.settings.webauthn.actions": "Acties",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Naam",
|
||||
"page.settings.webauthn.added_on": "Toegevoegd op",
|
||||
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
|
||||
"page.settings.webauthn.register": "Passkey registreren",
|
||||
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Verwijder %d wachtwoord",
|
||||
"Verwijder %d wachtwoordsleutels"
|
||||
"Verwijder %d passkey",
|
||||
"Verwijder %d passkeys"
|
||||
],
|
||||
"page.login.oidc_signin": "Inloggen via OpenID Connect",
|
||||
"page.login.webauthn_login": "Inloggen met wachtwoord",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met wachtwoord",
|
||||
"page.login.google_signin": "Inloggen via Google",
|
||||
"page.login.oidc_signin": "Inloggen met %s",
|
||||
"page.login.webauthn_login": "Inloggen met passkey",
|
||||
"page.login.webauthn_login.error": "Kan niet inloggen met passkey",
|
||||
"page.login.google_signin": "Inloggen met Google",
|
||||
"page.integrations.title": "Integraties",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API-URL",
|
||||
@@ -235,7 +239,7 @@
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.bookmarklet.name": "Toevoegen aan Miniflux",
|
||||
"page.integration.bookmarklet.instructions": "Sleep deze link naar je bookmarks.",
|
||||
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
|
||||
"page.integration.bookmarklet.help": "Gebruik deze link als bookmark in je browser om je direct te abonneren op een website.",
|
||||
"page.sessions.title": "Sessies",
|
||||
"page.sessions.table.date": "Datum",
|
||||
"page.sessions.table.ip": "IP-adres",
|
||||
@@ -243,8 +247,8 @@
|
||||
"page.sessions.table.actions": "Acties",
|
||||
"page.sessions.table.current_session": "Huidige sessie",
|
||||
"page.api_keys.title": "API-sleutels",
|
||||
"page.api_keys.table.description": "Beschrijving",
|
||||
"page.api_keys.table.token": "Blijk",
|
||||
"page.api_keys.table.description": "Omschrijving",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.table.last_used_at": "Laatst gebruikt",
|
||||
"page.api_keys.table.created_at": "Aanmaakdatum",
|
||||
"page.api_keys.table.actions": "Acties",
|
||||
@@ -253,133 +257,163 @@
|
||||
"page.offline.title": "Offline modus",
|
||||
"page.offline.message": "Je bent offline",
|
||||
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"alert.no_shared_entry": "Er is geen gedeelde toegang.",
|
||||
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
|
||||
"page.webauthn_rename.title": "Hernoem Passkey",
|
||||
"alert.no_shared_entry": "Er is geen gedeeld artikel.",
|
||||
"alert.no_bookmark": "Er zijn geen favorieten.",
|
||||
"alert.no_category": "Er zijn geen categorieën.",
|
||||
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
|
||||
"alert.no_category_entry": "Er zijn geen artikelen in deze categorie.",
|
||||
"alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.",
|
||||
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
|
||||
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
|
||||
"alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
|
||||
"alert.no_feed": "Je hebt nog geen feed geabonneerd.",
|
||||
"alert.no_feed_in_category": "Er is geen feed voor deze categorie.",
|
||||
"alert.no_history": "Geschiedenis is op dit moment leeg.",
|
||||
"alert.feed_error": "Er is een probleem met deze feed",
|
||||
"alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.",
|
||||
"alert.no_unread_entry": "Er zijn geen ongelezen artikelen.",
|
||||
"alert.no_user": "Je bent de enige gebruiker.",
|
||||
"alert.account_unlinked": "Uw externe account is nu gedissocieerd!",
|
||||
"alert.account_linked": "Uw externe account is nu gekoppeld!",
|
||||
"alert.pocket_linked": "Uw Pocket-account is nu gekoppeld!",
|
||||
"alert.account_unlinked": "Jouw externe account is nu ontkoppeld!",
|
||||
"alert.account_linked": "Jouw externe account is nu gekoppeld!",
|
||||
"alert.pocket_linked": "Jouw Pocket-account is nu gekoppeld!",
|
||||
"alert.prefs_saved": "Instellingen opgeslagen!",
|
||||
"error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
|
||||
"error.unlink_account_without_password": "Je moet een wachtwoord opgeven anders kun je niet meer inloggen.",
|
||||
"error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
|
||||
"error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
|
||||
"error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
|
||||
"error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
|
||||
"error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
|
||||
"error.category_already_exists": "Deze categorie bestaat al.",
|
||||
"error.unable_to_create_category": "Kan deze categorie niet maken.",
|
||||
"error.unable_to_update_category": "Kon categorie niet updaten.",
|
||||
"error.unable_to_create_category": "Kan deze categorie niet aanmaken.",
|
||||
"error.unable_to_update_category": "Kan categorie niet bijwerken.",
|
||||
"error.user_already_exists": "Deze gebruiker bestaat al.",
|
||||
"error.unable_to_create_user": "Kan deze gebruiker niet maken.",
|
||||
"error.unable_to_update_user": "Kan deze gebruiker niet updaten.",
|
||||
"error.unable_to_create_user": "Kan deze gebruiker niet aanmaken.",
|
||||
"error.unable_to_update_user": "Kan deze gebruiker niet bijwerken.",
|
||||
"error.unable_to_update_feed": "Kan deze feed niet bijwerken.",
|
||||
"error.subscription_not_found": "Kon geen feeds vinden.",
|
||||
"error.subscription_not_found": "Kan geen feeds vinden.",
|
||||
"error.empty_file": "Dit bestand is leeg.",
|
||||
"error.bad_credentials": "Onjuiste gebruikersnaam of wachtwoord.",
|
||||
"error.fields_mandatory": "Alle velden moeten ingevuld zijn.",
|
||||
"error.title_required": "Naam van categorie is verplicht.",
|
||||
"error.title_required": "De titel is verplicht.",
|
||||
"error.different_passwords": "Wachtwoorden zijn niet hetzelfde.",
|
||||
"error.password_min_length": "Je moet minstens 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
|
||||
"error.password_min_length": "Minimaal 6 tekens gebruiken.",
|
||||
"error.settings_mandatory_fields": "Gebruikersnaam, thema, taal en tijdzone zijn verplichte velden.",
|
||||
"error.settings_reading_speed_is_positive": "De leessnelheden moeten positieve gehele getallen zijn.",
|
||||
"error.entries_per_page_invalid": "Het aantal inzendingen per pagina is niet geldig.",
|
||||
"error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Ongeldige blokkeerregel: regel #%d mist een geldige veldnaam (Opties: %s)",
|
||||
"error.settings_block_rule_separator_required": "Ongeldige blokkeerregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_block_rule_regex_required": "Ongeldige blokkeerregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_block_rule_invalid_regex": "Ongeldige blokkeerregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Ongeldige bewaarregel: regel #%d mist een geldige veldnaam (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Ongeldige bewaarregel: het patroon van regel #%d moet worden gescheiden door een '='",
|
||||
"error.settings_keep_rule_regex_required": "Ongeldige bewaarregel: het patroon van regel #%d is niet opgegeven",
|
||||
"error.settings_keep_rule_invalid_regex": "Ongeldige bewaarregel: het patroon van regel #%d is geen geldige regex",
|
||||
"error.entries_per_page_invalid": "Het aantal artikelen per pagina is niet geldig.",
|
||||
"error.feed_mandatory_fields": "De velden URL en categorie zijn verplicht.",
|
||||
"error.feed_already_exists": "Deze feed bestaat al.",
|
||||
"error.invalid_feed_url": "Ongeldige feed-URL.",
|
||||
"error.invalid_site_url": "Ongeldige site-URL.",
|
||||
"error.feed_url_not_empty": "De feed-URL mag niet leeg zijn.",
|
||||
"error.site_url_not_empty": "De site-URL mag niet leeg zijn.",
|
||||
"error.feed_title_not_empty": "De feedtitel mag niet leeg zijn.",
|
||||
"error.invalid_feed_url": "Ongeldige feed URL.",
|
||||
"error.invalid_site_url": "Ongeldige site URL.",
|
||||
"error.feed_url_not_empty": "De feed URL mag niet leeg zijn.",
|
||||
"error.site_url_not_empty": "De site URL mag niet leeg zijn.",
|
||||
"error.feed_title_not_empty": "De feed titel mag niet leeg zijn.",
|
||||
"error.feed_category_not_found": "Deze categorie bestaat niet of behoort niet tot deze gebruiker.",
|
||||
"error.feed_invalid_blocklist_rule": "De regel voor de blokkeerlijst is ongeldig.",
|
||||
"error.feed_invalid_keeplist_rule": "De regel voor het bewaren van een lijst is ongeldig.",
|
||||
"error.feed_invalid_blocklist_rule": "De blokkeerregel is ongeldig.",
|
||||
"error.feed_invalid_keeplist_rule": "De bewaarregel is ongeldig.",
|
||||
"error.user_mandatory_fields": "Gebruikersnaam is verplicht",
|
||||
"error.api_key_already_exists": "This API Key already exists.",
|
||||
"error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
|
||||
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
|
||||
"error.unable_to_create_api_key": "Kan deze API-sleutel niet aanmaken.",
|
||||
"error.invalid_theme": "Ongeldig thema.",
|
||||
"error.invalid_language": "Ongeldige taal.",
|
||||
"error.invalid_timezone": "Ongeldige tijdzone.",
|
||||
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
|
||||
"error.invalid_display_mode": "Ongeldige weergavemodus voor de webapp.",
|
||||
"error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
|
||||
"error.invalid_default_home_page": "Ongeldige standaard homepage!",
|
||||
"form.feed.label.title": "Naam",
|
||||
"error.invalid_default_home_page": "Ongeldige startpagina!",
|
||||
"form.feed.label.title": "Titel",
|
||||
"form.feed.label.site_url": "Website URL",
|
||||
"form.feed.label.feed_url": "Feed URL",
|
||||
"form.feed.label.description": "Omschrijving",
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.crawler": "Download originele content",
|
||||
"form.feed.label.feed_username": "Feed-gebruikersnaam",
|
||||
"form.feed.label.crawler": "Download originele inhoud",
|
||||
"form.feed.label.feed_username": "Feed gebruikersnaam",
|
||||
"form.feed.label.feed_password": "Feed wachtwoord",
|
||||
"form.feed.label.user_agent": "Standaard User Agent overschrijven",
|
||||
"form.feed.label.user_agent": "Standaard User-agent overschrijven",
|
||||
"form.feed.label.cookie": "Cookies instellen",
|
||||
"form.feed.label.scraper_rules": "Scraper regels",
|
||||
"form.feed.label.rewrite_rules": "Rewrite regels",
|
||||
"form.feed.label.blocklist_rules": "Blokkeer regels",
|
||||
"form.feed.label.keeplist_rules": "toestemmingsregels",
|
||||
"form.feed.label.urlrewrite_rules": "Regels voor het herschrijven van URL's",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.scraper_rules": "Extractieregels",
|
||||
"form.feed.label.rewrite_rules": "Herschrijfregels",
|
||||
"form.feed.label.blocklist_rules": "Blokkeerregels",
|
||||
"form.feed.label.keeplist_rules": "Bewaarregels",
|
||||
"form.feed.label.urlrewrite_rules": "Herschrijfregels voor URL's",
|
||||
"form.feed.label.apprise_service_urls": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
|
||||
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zelfondertekende of ongeldige certificaten toestaan",
|
||||
"form.feed.label.disable_http2": "HTTP/2 uitschakelen om fingerprinting te voorkomen",
|
||||
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
|
||||
"form.feed.label.disabled": "Vernieuw deze feed niet",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.category.label.title": "Naam",
|
||||
"form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
|
||||
"form.feed.label.disabled": "Deze feed niet vernieuwen",
|
||||
"form.feed.label.no_media_player": "Geen mediaspeler (audio/video)",
|
||||
"form.feed.label.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.feed.label.ntfy_activate": "Artikelen naar ntfy sturen",
|
||||
"form.feed.label.ntfy_priority": "Ntfy prioriteit",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy maximale prioriteit",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy hoge prioriteit",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "Algemeen",
|
||||
"form.feed.fieldset.rules": "Regels",
|
||||
"form.feed.fieldset.network_settings": "Netwerk Instellingen",
|
||||
"form.feed.fieldset.integration": "Diensten van derden",
|
||||
"form.category.label.title": "Titel",
|
||||
"form.category.hide_globally": "Verberg artikelen in de globale ongelezen lijst",
|
||||
"form.user.label.username": "Gebruikersnaam",
|
||||
"form.user.label.password": "Wachtwoord",
|
||||
"form.user.label.confirmation": "Bevestig wachtwoord",
|
||||
"form.user.label.admin": "Administrator",
|
||||
"form.user.label.admin": "Beheerder",
|
||||
"form.prefs.label.language": "Taal",
|
||||
"form.prefs.label.timezone": "Tijdzone",
|
||||
"form.prefs.label.theme": "Skin",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van items",
|
||||
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
|
||||
"form.prefs.label.theme": "Thema",
|
||||
"form.prefs.label.entry_sorting": "Volgorde van artikelen",
|
||||
"form.prefs.label.entries_per_page": "Artikelen per pagina",
|
||||
"form.prefs.label.default_reading_speed": "Leessnelheid voor andere talen (woorden per minuut)",
|
||||
"form.prefs.label.cjk_reading_speed": "Leessnelheid voor Chinees, Koreaans en Japans (tekens per minuut)",
|
||||
"form.prefs.label.display_mode": "Weergavemodus Progressive Web App (PWA).",
|
||||
"form.prefs.select.older_first": "Oudere items eerst",
|
||||
"form.prefs.select.recent_first": "Recente items eerst",
|
||||
"form.prefs.select.older_first": "Oudere artikelen eerst",
|
||||
"form.prefs.select.recent_first": "Recente artikelen eerst",
|
||||
"form.prefs.select.fullscreen": "Volledig scherm",
|
||||
"form.prefs.select.standalone": "Standalone",
|
||||
"form.prefs.select.minimal_ui": "Minimaal",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.publish_time": "Tijd van binnenkomst",
|
||||
"form.prefs.select.created_time": "Tijdstip van binnenkomst",
|
||||
"form.prefs.select.publish_time": "Tijdstip van publiceren artikel",
|
||||
"form.prefs.select.created_time": "Tijdstip van aanmaken artikel",
|
||||
"form.prefs.select.alphabetical": "Alfabetisch",
|
||||
"form.prefs.select.unread_count": "Ongelezen tellen",
|
||||
"form.prefs.select.unread_count": "Aantal ongelezen artikelen",
|
||||
"form.prefs.select.none": "Geen",
|
||||
"form.prefs.select.tap": "Dubbeltik",
|
||||
"form.prefs.select.swipe": "Vegen",
|
||||
"form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
|
||||
"form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
|
||||
"form.prefs.label.keyboard_shortcuts": "Sneltoetsen inschakelen",
|
||||
"form.prefs.label.entry_swipe": "Vegen tussen artikelen inschakelen op aanraakschermen",
|
||||
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
|
||||
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
|
||||
"form.prefs.label.custom_css": "Aangepaste CSS",
|
||||
"form.prefs.label.entry_order": "Ingang Sorteerkolom",
|
||||
"form.prefs.label.default_home_page": "Standaard startpagina",
|
||||
"form.prefs.label.categories_sorting_order": "Categorieën sorteren",
|
||||
"form.prefs.label.mark_read_on_view": "Items automatisch markeren als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.label.custom_js": "Aangepaste JavaScript",
|
||||
"form.prefs.label.entry_order": "Artikelen sorteren",
|
||||
"form.prefs.label.default_home_page": "Startpagina",
|
||||
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
|
||||
"form.prefs.label.mark_read_on_view": "Markeer artikelen automatisch als gelezen wanneer ze worden bekeken",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Markeer artikelen als gelezen wanneer ze worden bekeken. Voor audio/video, markeer als gelezen bij 90%% voltooiing",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Markeer artikelen alleen als gelezen wanneer het afspelen van audio/video 90%% heeft bereikt",
|
||||
"form.prefs.label.mark_read_manually": "Markeer artikelen handmatig als gelezen",
|
||||
"form.prefs.fieldset.application_settings": "Applicatie Instellingen",
|
||||
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
|
||||
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML-bestand",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Artikelen opslaan in Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Activeer Fever API",
|
||||
"form.integration.fever_username": "Fever gebruikersnaam",
|
||||
"form.integration.fever_password": "Fever wachtwoord",
|
||||
@@ -387,91 +421,102 @@
|
||||
"form.integration.googlereader_activate": "Activeer Google Reader API",
|
||||
"form.integration.googlereader_username": "Google Reader gebruikersnaam",
|
||||
"form.integration.googlereader_password": "Google Reader wachtwoord",
|
||||
"form.integration.googlereader_endpoint": "Google Reader URL:",
|
||||
"form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API-endpoint:",
|
||||
"form.integration.pinboard_activate": "Artikelen opslaan in Pinboard",
|
||||
"form.integration.pinboard_token": "Pinboard API token",
|
||||
"form.integration.pinboard_tags": "Pinboard tags",
|
||||
"form.integration.pinboard_bookmark": "Markeer bookmark als gelezen",
|
||||
"form.integration.instapaper_activate": "Artikelen opstaan naar Instapaper",
|
||||
"form.integration.pinboard_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
|
||||
"form.integration.instapaper_username": "Instapaper gebruikersnaam",
|
||||
"form.integration.instapaper_password": "Instapaper wachtwoord",
|
||||
"form.integration.pocket_activate": "Bewaar artikelen in Pocket",
|
||||
"form.integration.pocket_activate": "Artikelen opslaan in Pocket",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pocket_access_token": "Pocket Access Token",
|
||||
"form.integration.pocket_connect_link": "Verbind je Pocket-account",
|
||||
"form.integration.wallabag_activate": "Opslaan naar Wallabag",
|
||||
"form.integration.wallabag_activate": "Artikelen opslaan in Wallabag",
|
||||
"form.integration.wallabag_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client-Secret",
|
||||
"form.integration.wallabag_username": "Wallabag gebruikersnaam",
|
||||
"form.integration.wallabag_password": "Wallabag wachtwoord",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_activate": "Artikelen opslaan in Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_activate": "Artikelen opslaan in Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
|
||||
"form.integration.apprise_services_url": "Door komma's gescheiden lijst van Apprise service URL's",
|
||||
"form.integration.nunux_keeper_activate": "Artikelen opslaan in Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
|
||||
"form.integration.omnivore_activate": "Opslaan naar Omnivore",
|
||||
"form.integration.omnivore_activate": "Artikelen opslaan in Omnivore",
|
||||
"form.integration.omnivore_url": "Omnivore URL",
|
||||
"form.integration.omnivore_api_key": "Omnivore API-sleutel",
|
||||
"form.integration.espial_activate": "Opslaan naar Espial",
|
||||
"form.integration.espial_activate": "Artikelen opslaan in Espial",
|
||||
"form.integration.espial_endpoint": "Espial URL",
|
||||
"form.integration.espial_api_key": "Espial API-sleutel",
|
||||
"form.integration.espial_tags": "Espial tags",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_activate": "Artikelen opslaan in Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.telegram_bot_activate": "Push nieuwe artikelen naar Telegram-chat",
|
||||
"form.integration.readwise_api_key_link": "Readwise Access Token ophalen",
|
||||
"form.integration.telegram_bot_activate": "Stuur nieuwe artikelen naar Telegram",
|
||||
"form.integration.telegram_bot_token": "Bot token",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Webpaginavoorbeeld uitschakelen",
|
||||
"form.integration.telegram_bot_disable_notification": "Notificatie uitschakelen",
|
||||
"form.integration.telegram_bot_disable_buttons": "Knoppen uitschakelen",
|
||||
"form.integration.linkace_activate": "Artikelen opslaan in LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkding_activate": "Opslaan naar Linkding",
|
||||
"form.integration.linkace_api_key": "LinkAce API-sleutel",
|
||||
"form.integration.linkace_tags": "LinkAce tags",
|
||||
"form.integration.linkace_is_private": "Koppeling als privé markeren",
|
||||
"form.integration.linkace_check_disabled": "Koppelingcontrole uitschakelen",
|
||||
"form.integration.linkding_activate": "Artikelen opslaan in Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_api_key": "Linkding API-sleutel",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_bookmark": "Markeer bookmark als gelezen",
|
||||
"form.integration.linkwarden_activate": "Opslaan naar Linkwarden",
|
||||
"form.integration.linkding_tags": "Linkding tags",
|
||||
"form.integration.linkding_bookmark": "Markeer favoriet als ongelezen",
|
||||
"form.integration.linkwarden_activate": "Artikelen opslaan in Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden URL",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API-sleutel",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen overbrengen naar Matrix",
|
||||
"form.integration.matrix_bot_user": "Gebruikersnaam voor Matrix",
|
||||
"form.integration.matrix_bot_activate": "Nieuwe artikelen opslaan in Matrix",
|
||||
"form.integration.matrix_bot_user": "Matrix gebruikersnaam",
|
||||
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
||||
"form.integration.matrix_bot_url": "URL van de Matrix-server",
|
||||
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
||||
"form.integration.readeck_activate": "Opslaan naar Readeck",
|
||||
"form.integration.raindrop_activate": "Artikelen opslaan in Raindrop",
|
||||
"form.integration.raindrop_token": "Raindrop Token",
|
||||
"form.integration.raindrop_collection_id": "Collectie ID",
|
||||
"form.integration.raindrop_tags": "Tags (commagescheiden)",
|
||||
"form.integration.readeck_activate": "Artikelen opslaan in Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.shiori_activate": "Opslaan naar Shiori",
|
||||
"form.integration.shiori_activate": "Artikelen opslaan in Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.shiori_username": "Shiori gebruikersnaam",
|
||||
"form.integration.shiori_password": "Shiori wachtwoord",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_activate": "Artikelen opslaan in Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_activate": "Webhook activeren",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_activate": "Controleer RSS-Bridge bij het toevoegen van abonnementen",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.api_key.label.description": "API-sleutellabel",
|
||||
"form.integration.ntfy_activate": "Stuur artikelen naar ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optioneel, standaard is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optioneel)",
|
||||
"form.integration.ntfy_username": "Ntfy gebruikersnaam (optioneel)",
|
||||
"form.integration.ntfy_password": "Ntfy wachtwoord (optioneel)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optioneel)",
|
||||
"form.api_key.label.description": "API-sleutel omschrijving",
|
||||
"form.submit.loading": "Laden...",
|
||||
"form.submit.saving": "Opslaag...",
|
||||
"time_elapsed.not_yet": "in de toekomst",
|
||||
"form.submit.saving": "Opslaan...",
|
||||
"time_elapsed.not_yet": "nog niet",
|
||||
"time_elapsed.yesterday": "gisteren",
|
||||
"time_elapsed.now": "minder dan een minuut geleden",
|
||||
"time_elapsed.minutes": [
|
||||
@@ -499,34 +544,43 @@
|
||||
"%d jaar geleden"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuut voor opnieuw proberen.",
|
||||
"Je hebt te veel feed-vernieuwingen getriggered. Wacht aub %d minuten voor opnieuw proberen."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "Alle feeds worden op de achtergrond vernieuwd. Je kunt Miniflux blijven gebruiker terwijl dit proces draait.",
|
||||
"error.http_response_too_large": "De HTTP-respons is te groot. Je kunt de limiet voor de HTTP-responsgrootte verhogen in de globale instellingen (server herstart noodzakelijk)",
|
||||
"error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
|
||||
"error.http_empty_response_body": "De HTTP-respons body is leeg.",
|
||||
"error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
|
||||
"error.tls_error": "TLS fout: %q. Als je wilt, kun je TLS-verificatie uitschakelen in de feed-instellingen.",
|
||||
"error.network_operation": "Miniflux kan deze website niet bereiken vanwege een netwerkfout: %v.",
|
||||
"error.network_timeout": "Deze website is te traag en de aanvraag gaf timeout: %v",
|
||||
"error.http_client_error": "HTTP-client-fout: %v.",
|
||||
"error.http_not_authorized": "Toegang tot deze website is niet geautoriseerd. Het kan een foute gebruikersnaam of wachtwoord zijn.",
|
||||
"error.http_too_many_requests": "Miniflux heeft te veel aanvragen gegenereerd voor deze website. Probeer het later nog eens of wijzig de applicatieconfiguratie.",
|
||||
"error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",
|
||||
"error.http_resource_not_found": "De gevraagde bron is niet gevonden. Controleer de URL.",
|
||||
"error.http_internal_server_error": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_service_unavailable": "De website is momenteel niet beschikbaar vanwege een interne-server-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_gateway_timeout": "De website is momenteel niet beschikbaar vanwege een timeout bij de gateway. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.http_unexpected_status_code": "De website is momenteel niet beschikbaar vanwege een onverwachte HTTP-statuscode: %d. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
|
||||
"error.database_error": "Database fout: %v.",
|
||||
"error.category_not_found": "Deze categorie bestaat niet of hoort niet bij deze gebruiker.",
|
||||
"error.duplicated_feed": "Deze feed bestaat al.",
|
||||
"error.unable_to_parse_feed": "Kan deze feed niet verwerken: %v.",
|
||||
"error.feed_not_found": "Deze feed bestaat niet of is niet van deze gebruiker.",
|
||||
"error.unable_to_detect_rssbridge": "Kan feed niet detecteren met RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Feed-formaat kan niet worden gedetecteerd: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
|
||||
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik"
|
||||
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
|
||||
"enclosure_media_controls.seek" : "Vooruit/terug:",
|
||||
"enclosure_media_controls.seek.title" : " Vooruit/terug met %s seconden",
|
||||
"enclosure_media_controls.speed" : "Snelheid:",
|
||||
"enclosure_media_controls.speed.faster" : "Versnel",
|
||||
"enclosure_media_controls.speed.faster.title" : "Versnel met %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Vertraag",
|
||||
"enclosure_media_controls.speed.slower.title" : "Vertraag met %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset snelheid naar 1x"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"confirm.question": "Czy jesteś pewny?",
|
||||
"confirm.question.refresh": "Czy chcesz wymusić odświeżenie?",
|
||||
"skip_to_content": "Przejdź do treści",
|
||||
"confirm.question": "Czy na pewno?",
|
||||
"confirm.question.refresh": "Czy na pewno chcesz wymusić odświeżenie?",
|
||||
"confirm.yes": "tak",
|
||||
"confirm.no": "nie",
|
||||
"confirm.loading": "W toku...",
|
||||
"confirm.loading": "W toku…",
|
||||
"action.subscribe": "Subskrypcja",
|
||||
"action.save": "Zapisz",
|
||||
"action.or": "lub",
|
||||
@@ -20,7 +20,7 @@
|
||||
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
|
||||
"tooltip.logged_user": "Zalogowany jako %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.home_page": "Strona główna",
|
||||
"menu.unread": "Nieprzeczytane",
|
||||
"menu.starred": "Ulubione",
|
||||
"menu.history": "Historia",
|
||||
@@ -32,136 +32,139 @@
|
||||
"menu.integrations": "Usługi",
|
||||
"menu.sessions": "Sesje",
|
||||
"menu.users": "Użytkownicy",
|
||||
"menu.about": "O stronie",
|
||||
"menu.about": "O czytniku",
|
||||
"menu.export": "Eksportuj",
|
||||
"menu.import": "Importuj",
|
||||
"menu.search": "Szukaj",
|
||||
"menu.create_category": "Utwórz kategorię",
|
||||
"menu.mark_page_as_read": "Oznacz jako przeczytane",
|
||||
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
|
||||
"menu.show_all_entries": "Pokaż wszystkie artykuły",
|
||||
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane artykuły",
|
||||
"menu.mark_all_as_read": "Oznacz wszystkie jako przeczytane",
|
||||
"menu.show_all_entries": "Pokaż wszystkie wpisy",
|
||||
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane wpisy",
|
||||
"menu.show_only_starred_entries": "Pokaż tylko ulubione wpisy",
|
||||
"menu.refresh_feed": "Odśwież",
|
||||
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
|
||||
"menu.refresh_all_feeds": "Odśwież w tle wszystkie subskrypcje",
|
||||
"menu.edit_feed": "Edytuj",
|
||||
"menu.edit_category": "Edytuj",
|
||||
"menu.add_feed": "Dodaj subskrypcję",
|
||||
"menu.add_feed": "Dodaj kanał",
|
||||
"menu.add_user": "Dodaj użytkownika",
|
||||
"menu.flush_history": "Usuń historię",
|
||||
"menu.feed_entries": "Artykuły",
|
||||
"menu.feed_entries": "Wpisy",
|
||||
"menu.api_keys": "Klucze API",
|
||||
"menu.create_api_key": "Utwórz nowy klucz API",
|
||||
"menu.shared_entries": "Udostępnione wpisy",
|
||||
"search.label": "Szukaj",
|
||||
"search.placeholder": "Szukaj...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Następny",
|
||||
"pagination.previous": "Poprzedni",
|
||||
"entry.status.unread": "Nieprzeczytane",
|
||||
"entry.status.read": "Przeczytane",
|
||||
"entry.status.toast.unread": "Oznaczone jako nieprzeczytane",
|
||||
"entry.status.toast.read": "Oznaczone jako przeczytane",
|
||||
"entry.status.title": "Zmień status artykułu",
|
||||
"entry.bookmark.toggle.on": "Oznacz gwiazdką",
|
||||
"entry.bookmark.toggle.off": "Usuń gwiazdkę",
|
||||
"entry.bookmark.toast.on": "Oznaczone gwiazdką",
|
||||
"entry.bookmark.toast.off": "Bez gwiazdek",
|
||||
"entry.state.saving": "Zapisywanie...",
|
||||
"entry.state.loading": "Ładowanie...",
|
||||
"search.placeholder": "Szukaj…",
|
||||
"search.submit": "Szukaj",
|
||||
"pagination.last": "Ostatnia",
|
||||
"pagination.next": "Następna",
|
||||
"pagination.first": "Pierwsza",
|
||||
"pagination.previous": "Poprzednia",
|
||||
"entry.status.unread": "Nieprzeczytany",
|
||||
"entry.status.read": "Przeczytany",
|
||||
"entry.status.toast.unread": "Oznaczono jako nieprzeczytany",
|
||||
"entry.status.toast.read": "Oznaczono jako przeczytany",
|
||||
"entry.status.title": "Zmień status wpisu",
|
||||
"entry.bookmark.toggle.on": "Dodaj do ulubionych",
|
||||
"entry.bookmark.toggle.off": "Usuń z ulubionych",
|
||||
"entry.bookmark.toast.on": "Dodano do ulubionych",
|
||||
"entry.bookmark.toast.off": "Usunięto z ulubionych",
|
||||
"entry.state.saving": "Zapisywanie…",
|
||||
"entry.state.loading": "Ładowanie…",
|
||||
"entry.save.label": "Zapisz",
|
||||
"entry.save.title": "Zapisz ten artykuł",
|
||||
"entry.save.title": "Zapisz ten wpis",
|
||||
"entry.save.completed": "Gotowe!",
|
||||
"entry.save.toast.completed": "Artykuł zapisany",
|
||||
"entry.scraper.label": "Ściągnij",
|
||||
"entry.save.toast.completed": "Zapisano wpis",
|
||||
"entry.scraper.label": "Pobierz treść",
|
||||
"entry.scraper.title": "Pobierz oryginalną treść",
|
||||
"entry.scraper.completed": "Gotowe!",
|
||||
"entry.external_link.label": "Link zewnętrzny",
|
||||
"entry.external_link.label": "Łącze zewnętrzne",
|
||||
"entry.comments.label": "Komentarze",
|
||||
"entry.comments.title": "Zobacz komentarze",
|
||||
"entry.share.label": "Podzielić się",
|
||||
"entry.share.title": "Podzielić się ten artykuł",
|
||||
"entry.unshare.label": "Unshare",
|
||||
"entry.shared_entry.title": "Otwórz publiczny link",
|
||||
"entry.shared_entry.label": "Udostępnianie",
|
||||
"entry.share.label": "Udostępnij",
|
||||
"entry.share.title": "Udostępnij ten wpis",
|
||||
"entry.unshare.label": "Cofnij udostępnianie",
|
||||
"entry.shared_entry.title": "Otwórz publiczne łącze",
|
||||
"entry.shared_entry.label": "Udostępnij",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d minuta czytania",
|
||||
"%d minuty czytania",
|
||||
"%d minut czytania"
|
||||
],
|
||||
"entry.tags.label": "Tagi:",
|
||||
"entry.tags.label": "Znaczniki:",
|
||||
"page.shared_entries.title": "Udostępnione wpisy",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d udostępniony wpis",
|
||||
"%d udostępnione wpisy",
|
||||
"%d udostępnionych wpisów"
|
||||
],
|
||||
"page.unread.title": "Nieprzeczytane",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d nieprzeczytany wpis",
|
||||
"%d nieprzeczytane wpisy",
|
||||
"%d nieprzeczytanych wpisów"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d wpis łącznie",
|
||||
"%d wpisy łącznie",
|
||||
"%d wpisów łącznie"
|
||||
],
|
||||
"page.starred.title": "Oznaczone gwiazdką",
|
||||
"page.starred.title": "Ulubione",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d ulubiony wpis",
|
||||
"%d ulubione wpisy",
|
||||
"%d ulubionych wpisów"
|
||||
],
|
||||
"page.categories.title": "Kategorie",
|
||||
"page.categories.no_feed": "Brak kanałów.",
|
||||
"page.categories.entries": "Artykuły",
|
||||
"page.categories.feeds": "Subskrypcje",
|
||||
"page.categories.entries": "Wpisy",
|
||||
"page.categories.feeds": "Kanały",
|
||||
"page.categories.feed_count": [
|
||||
"Jest %d kanał.",
|
||||
"Są %d kanały.",
|
||||
"Jest %d kanałów."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d kategoria",
|
||||
"%d kategorie",
|
||||
"%d kategorii"
|
||||
],
|
||||
"page.new_category.title": "Nowa kategoria",
|
||||
"page.new_user.title": "Nowy użytkownik",
|
||||
"page.edit_category.title": "Edycja Kategorii: %s",
|
||||
"page.edit_category.title": "Edytuj kategorię: %s",
|
||||
"page.edit_user.title": "Edytuj użytkownika: %s",
|
||||
"page.feeds.title": "Kanały",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Kategoria: %s",
|
||||
"page.feeds.last_check": "Ostatnia aktualizacja:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Następna aktualizacja:",
|
||||
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
|
||||
"page.feeds.error_count": [
|
||||
"%d błąd",
|
||||
"%d błąd",
|
||||
"%d błędy",
|
||||
"%d błędów"
|
||||
],
|
||||
"page.history.title": "Historia",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d przeczytany wpis",
|
||||
"%d przeczytane wpisy",
|
||||
"%d przeczytanych wpisów"
|
||||
],
|
||||
"page.import.title": "Importuj",
|
||||
"page.search.title": "Wyniki wyszukiwania",
|
||||
"page.about.title": "O",
|
||||
"page.about.title": "O stronie",
|
||||
"page.about.credits": "Prawa autorskie",
|
||||
"page.about.version": "Wersja:",
|
||||
"page.about.build_date": "Data opracowania:",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.license": "Licencja:",
|
||||
"page.about.postgres_version": "Postgres wersja:",
|
||||
"page.about.go_version": "Go wersja:",
|
||||
"page.about.global_config_options": "globalne opcje konfiguracji",
|
||||
"page.about.postgres_version": "Wersja PostgreSQL:",
|
||||
"page.about.go_version": "Wersja Go:",
|
||||
"page.about.global_config_options": "Globalne opcje konfiguracji",
|
||||
"page.add_feed.title": "Nowa subskrypcja",
|
||||
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.label.url": "Adres URL",
|
||||
"page.add_feed.submit": "Znajdź subskrypcję",
|
||||
"page.add_feed.legend.advanced_options": "Zaawansowane opcje",
|
||||
"page.add_feed.legend.advanced_options": "Opcje zaawansowane",
|
||||
"page.add_feed.choose_feed": "Wybierz subskrypcję",
|
||||
"page.edit_feed.title": "Edytuj kanał: %s",
|
||||
"page.edit_feed.last_check": "Ostatnia aktualizacja:",
|
||||
@@ -172,39 +175,39 @@
|
||||
"page.entry.attachments": "Załączniki",
|
||||
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między artykułami",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między elementami",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Działania",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych artykułów",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Przejdź do zakładek",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Przejdź do ulubionych",
|
||||
"page.keyboard_shortcuts.go_to_history": "Przejdź do historii",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Przejdź do kanałów",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Przejdź do kategorii",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Przejdź do ustawień",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Pokaż listę skrótów klawiszowych",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego artykułu",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego punktu artykułu",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego elementu",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego elementu",
|
||||
"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_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_original": "Otwórz oryginalny artykuł",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.open_comments": "Otwórz link do komentarzy",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz link do komentarzy w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Oznacz jako przeczytane/nieprzeczytane, skup się dalej",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Oznacz jako przeczytane/nieprzeczytane, skup poprzednie",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do górnego elementu",
|
||||
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony element",
|
||||
"page.keyboard_shortcuts.open_original": "Otwórz oryginalne łącze",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalne łącze w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.open_comments": "Otwórz łącze do komentarzy",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz łącze do komentarzy w bieżącej karcie",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Przełącz przeczytane/nieprzeczytane, przejdź dalej",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Przełącz przeczytane/nieprzeczytane, przejdź wstecz",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież w tle wszystkie kanały",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Zaznacz aktualną stronę jako przeczytaną",
|
||||
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną zawartość",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Dodaj/usuń zakładki",
|
||||
"page.keyboard_shortcuts.save_article": "Zapisz artykuł",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń artykuł do góry",
|
||||
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną treść",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Przełącz dodanie do ulubionych",
|
||||
"page.keyboard_shortcuts.save_article": "Zapisz wpis",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń element do góry",
|
||||
"page.keyboard_shortcuts.remove_feed": "Usuń ten kanał",
|
||||
"page.keyboard_shortcuts.go_to_search": "Ustaw fokus na formularzu wyszukiwania",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
|
||||
"page.keyboard_shortcuts.close_modal": "Zamknij listę skrótów klawiszowych",
|
||||
"page.users.title": "Użytkownicy",
|
||||
"page.users.username": "Nazwa użytkownika",
|
||||
@@ -217,35 +220,36 @@
|
||||
"page.settings.title": "Ustawienia",
|
||||
"page.settings.link_google_account": "Połącz z moim kontem Google",
|
||||
"page.settings.unlink_google_account": "Odłącz moje konto Google",
|
||||
"page.settings.link_oidc_account": "Połącz z moim kontem OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Odłącz moje konto OpenID Connect",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
"page.settings.webauthn.added_on": "Added On",
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.link_oidc_account": "Połącz z moim kontem %s",
|
||||
"page.settings.unlink_oidc_account": "Odłącz moje konto %s",
|
||||
"page.settings.webauthn.passkeys": "Klucze dostępu",
|
||||
"page.settings.webauthn.actions": "Działania",
|
||||
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
|
||||
"page.settings.webauthn.added_on": "Dodano",
|
||||
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
|
||||
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
|
||||
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
|
||||
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Usuń %d klucz dostępu",
|
||||
"Usuń %d klucze dostępu",
|
||||
"Usuń %d klucze dostępu"
|
||||
"Usuń %d kluczy dostępu"
|
||||
],
|
||||
"page.login.title": "Zaloguj się",
|
||||
"page.login.google_signin": "Zaloguj przez Google",
|
||||
"page.login.oidc_signin": "Zaloguj przez OpenID Connect",
|
||||
"page.login.webauthn_login": "Zaloguj się za pomocą hasła",
|
||||
"page.login.google_signin": "Zaloguj się przez Google",
|
||||
"page.login.oidc_signin": "Zaloguj się przez %s",
|
||||
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
|
||||
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
|
||||
"page.integrations.title": "Usługi",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api": "API Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Punkt końcowy API",
|
||||
"page.integration.miniflux_api_username": "Nazwa Użytkownika",
|
||||
"page.integration.miniflux_api_username": "Nazwa użytkownika",
|
||||
"page.integration.miniflux_api_password": "Hasło",
|
||||
"page.integration.miniflux_api_password_value": "Hasło konta",
|
||||
"page.integration.bookmarklet": "Bookmarklet",
|
||||
"page.integration.miniflux_api_password_value": "Hasło do konta",
|
||||
"page.integration.bookmarklet": "Skryptozakładka",
|
||||
"page.integration.bookmarklet.name": "Dodaj do Miniflux",
|
||||
"page.integration.bookmarklet.instructions": "Przeciągnij i upuść to łącze do zakładek.",
|
||||
"page.integration.bookmarklet.help": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
|
||||
"page.integration.bookmarklet.help": "To łącze umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
|
||||
"page.sessions.title": "Sesje",
|
||||
"page.sessions.table.date": "Data",
|
||||
"page.sessions.table.ip": "Adres IP",
|
||||
@@ -254,7 +258,7 @@
|
||||
"page.sessions.table.current_session": "Bieżąca sesja",
|
||||
"page.api_keys.title": "Klucze API",
|
||||
"page.api_keys.table.description": "Opis",
|
||||
"page.api_keys.table.token": "Znak",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.table.last_used_at": "Ostatnio używane",
|
||||
"page.api_keys.table.created_at": "Data utworzenia",
|
||||
"page.api_keys.table.actions": "Działania",
|
||||
@@ -263,18 +267,19 @@
|
||||
"page.offline.title": "Tryb offline",
|
||||
"page.offline.message": "Jesteś odłączony od sieci",
|
||||
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
|
||||
"page.webauthn_rename.title": "Rename Passkey",
|
||||
"alert.no_shared_entry": "Brak wspólnego wpisu.",
|
||||
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
|
||||
"alert.no_category": "Nie ma żadnej kategorii!",
|
||||
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
|
||||
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
|
||||
"page.webauthn_rename.title": "Zmień nazwę klucza dostępu",
|
||||
"alert.no_shared_entry": "Brak udostępnionego wpisu.",
|
||||
"alert.no_bookmark": "Brak ulubionych w tej chwili.",
|
||||
"alert.no_category": "Brak kategorii!",
|
||||
"alert.no_category_entry": "Brak wpisów w tej kategorii",
|
||||
"alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.",
|
||||
"alert.no_feed_entry": "Brak wpisów tego kanału.",
|
||||
"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 tej kategorii.",
|
||||
"alert.no_history": "Obecnie nie ma żadnej historii.",
|
||||
"alert.feed_error": "Z tym kanałem jest problem",
|
||||
"alert.no_search_result": "Brak wyników dla tego wyszukiwania.",
|
||||
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych artykułów.",
|
||||
"alert.no_search_result": "Brak wyników tego wyszukiwania.",
|
||||
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.",
|
||||
"alert.no_user": "Jesteś jedynym użytkownikiem.",
|
||||
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
|
||||
"alert.account_linked": "Twoje konto zewnętrzne jest teraz połączone!",
|
||||
@@ -283,7 +288,7 @@
|
||||
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
|
||||
"error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
|
||||
"error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
|
||||
"error.duplicate_googlereader_username": "Już ktoś inny używa tej nazwy użytkownika Google Reader!",
|
||||
"error.duplicate_googlereader_username": "Istnieje już ktoś inny z tą samą nazwą użytkownika Google Reader!",
|
||||
"error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
|
||||
"error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
|
||||
"error.category_already_exists": "Ta kategoria już istnieje.",
|
||||
@@ -293,7 +298,7 @@
|
||||
"error.unable_to_create_user": "Nie można utworzyć tego użytkownika.",
|
||||
"error.unable_to_update_user": "Nie można zaktualizować tego użytkownika.",
|
||||
"error.unable_to_update_feed": "Nie można zaktualizować tego kanału.",
|
||||
"error.subscription_not_found": "Nie znaleziono żadnych subskrypcji.",
|
||||
"error.subscription_not_found": "Nie znaleziono żadnych kanałów.",
|
||||
"error.empty_file": "Ten plik jest pusty.",
|
||||
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
|
||||
"error.fields_mandatory": "Wszystkie pola są obowiązkowe.",
|
||||
@@ -301,9 +306,17 @@
|
||||
"error.different_passwords": "Hasła nie są identyczne.",
|
||||
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
|
||||
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
|
||||
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
|
||||
"error.settings_reading_speed_is_positive": "Szybkości czytania muszą być dodatnimi liczbami całkowitymi.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
|
||||
"error.settings_block_rule_separator_required": "Nieprawidłowa reguła blokowania: wzór reguły #%d musi być oddzielony znakiem '='",
|
||||
"error.settings_block_rule_regex_required": "Nieprawidłowa reguła blokowania: nie podano wzorca reguły #%d",
|
||||
"error.settings_block_rule_invalid_regex": "Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Nieprawidłowa reguła utrzymywania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d musi być oddzielony znakiem '='",
|
||||
"error.settings_keep_rule_regex_required": "Nieprawidłowa reguła utrzymywania nie podano wzorca reguły #%d",
|
||||
"error.settings_keep_rule_invalid_regex": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
|
||||
"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": "Adres URL i kategoria są obowiązkowe.",
|
||||
"error.feed_already_exists": "Ten kanał już istnieje.",
|
||||
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
|
||||
"error.invalid_site_url": "Nieprawidłowy adres URL witryny.",
|
||||
@@ -314,41 +327,51 @@
|
||||
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
|
||||
"error.feed_invalid_keeplist_rule": "Reguła listy zachowania jest nieprawidłowa.",
|
||||
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
|
||||
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
|
||||
"error.api_key_already_exists": "Ten klucz API już istnieje.",
|
||||
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
|
||||
"error.invalid_theme": "Nieprawidłowy motyw.",
|
||||
"error.invalid_language": "Nieprawidłowy język.",
|
||||
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
|
||||
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
|
||||
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
|
||||
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji sieciowej.",
|
||||
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
|
||||
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
|
||||
"form.feed.label.title": "Tytuł",
|
||||
"form.feed.label.site_url": "URL strony",
|
||||
"form.feed.label.feed_url": "URL kanału",
|
||||
"form.feed.label.site_url": "Adres URL strony",
|
||||
"form.feed.label.feed_url": "Adres URL kanału",
|
||||
"form.feed.label.description": "Opis",
|
||||
"form.feed.label.category": "Kategoria",
|
||||
"form.feed.label.crawler": "Pobierz oryginalną treść",
|
||||
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
|
||||
"form.feed.label.feed_password": "Subskrypcję Hasło",
|
||||
"form.feed.label.feed_username": "Nazwa użytkownika subskrypcji",
|
||||
"form.feed.label.feed_password": "Hasło do subskrypcji",
|
||||
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
|
||||
"form.feed.label.cookie": "Ustawianie ciasteczek",
|
||||
"form.feed.label.scraper_rules": "Zasady ekstrakcji",
|
||||
"form.feed.label.cookie": "Ustaw ciasteczka",
|
||||
"form.feed.label.scraper_rules": "Reguły ekstrakcji",
|
||||
"form.feed.label.rewrite_rules": "Reguły zapisu",
|
||||
"form.feed.label.blocklist_rules": "Zasady blokowania",
|
||||
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
|
||||
"form.feed.label.urlrewrite_rules": "Zasady przepisywania adresów URL",
|
||||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.blocklist_rules": "Reguły blokowania",
|
||||
"form.feed.label.keeplist_rules": "Reguły utrzymywania",
|
||||
"form.feed.label.urlrewrite_rules": "Reguły przepisywania adresów URL",
|
||||
"form.feed.label.apprise_service_urls": "Rozdzielana przecinkami lista adresów URL usług Appprise",
|
||||
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty",
|
||||
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
|
||||
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
|
||||
"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.disabled": "Nie aktualizuj tego kanału",
|
||||
"form.feed.label.no_media_player": "Brak odtwarzacza multimedialnego (audio i wideo)",
|
||||
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
"form.feed.fieldset.integration": "Third-Party Services",
|
||||
"form.feed.label.ntfy_activate": "Prześlij wpisy do ntfy",
|
||||
"form.feed.label.ntfy_priority": "Priorytet ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Maksymalny priorytet ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Wysoki priorytet ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Domyślny priorytet ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Niski priorytet ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Minimalny priorytet ntfy",
|
||||
"form.integration.cubox_activate": "Zapisuj wpisy w Cubox",
|
||||
"form.integration.cubox_api_link": "Łącze API Cubox",
|
||||
"form.feed.fieldset.general": "Ogólne",
|
||||
"form.feed.fieldset.rules": "Reguły",
|
||||
"form.feed.fieldset.network_settings": "Ustawienia sieci",
|
||||
"form.feed.fieldset.integration": "Usługi dostawców zewnętrznych",
|
||||
"form.category.label.title": "Tytuł",
|
||||
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
|
||||
"form.user.label.username": "Nazwa użytkownika",
|
||||
@@ -358,129 +381,151 @@
|
||||
"form.prefs.label.language": "Język",
|
||||
"form.prefs.label.timezone": "Strefa czasowa",
|
||||
"form.prefs.label.theme": "Wygląd",
|
||||
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
|
||||
"form.prefs.label.entries_per_page": "Wpisy na stronie",
|
||||
"form.prefs.label.default_reading_speed": "Tryb wyświetlania Progressive Web App (PWA).",
|
||||
"form.prefs.label.cjk_reading_speed": "Prędkość czytania dla języka chińskiego, koreańskiego i japońskiego (znaki na minutę)",
|
||||
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
|
||||
"form.prefs.label.entry_sorting": "Sortowanie wpisów",
|
||||
"form.prefs.label.entries_per_page": "Wpisy na stronę",
|
||||
"form.prefs.label.default_reading_speed": "Szybkość czytania w innych językach (słowa na minutę)",
|
||||
"form.prefs.label.cjk_reading_speed": "Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)",
|
||||
"form.prefs.label.display_mode": "Tryb wyświetlania progresywnej aplikacji sieciowej (PWA)",
|
||||
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
|
||||
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
|
||||
"form.prefs.label.entry_swipe": "Włącz machnięcie wpisu na ekranach dotykowych",
|
||||
"form.prefs.label.gesture_nav": "Gest, aby poruszać się między wpisami",
|
||||
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
|
||||
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiszowe",
|
||||
"form.prefs.label.entry_swipe": "Włącz przesuwanie wpisów na ekranach dotykowych",
|
||||
"form.prefs.label.gesture_nav": "Gest do poruszania się między wpisami",
|
||||
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
|
||||
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
|
||||
"form.prefs.select.fullscreen": "Pełny ekran",
|
||||
"form.prefs.select.fullscreen": "Pełnoekranowy",
|
||||
"form.prefs.select.standalone": "Samodzielny",
|
||||
"form.prefs.select.minimal_ui": "Minimalny",
|
||||
"form.prefs.select.browser": "Przeglądarka",
|
||||
"form.prefs.select.browser": "Przeglądarkowy",
|
||||
"form.prefs.select.publish_time": "Czas publikacji wpisu",
|
||||
"form.prefs.select.created_time": "Czas utworzenia wpisu",
|
||||
"form.prefs.select.alphabetical": "Alfabetycznie",
|
||||
"form.prefs.select.unread_count": "Liczba nieprzeczytanych",
|
||||
"form.prefs.select.none": "Nic",
|
||||
"form.prefs.select.tap": "Podwójne wciśnięcie",
|
||||
"form.prefs.select.swipe": "Trzepnąć",
|
||||
"form.prefs.select.none": "Brak",
|
||||
"form.prefs.select.tap": "Podwójne stuknięcie",
|
||||
"form.prefs.select.swipe": "Przesuwanie",
|
||||
"form.prefs.label.custom_css": "Niestandardowy CSS",
|
||||
"form.prefs.label.custom_js": "Niestandardowy JavaScript",
|
||||
"form.prefs.label.entry_order": "Kolumna sortowania wpisów",
|
||||
"form.prefs.label.default_home_page": "Domyślna strona główna",
|
||||
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
|
||||
"form.prefs.label.mark_read_on_view": "Automatycznie oznaczaj wpisy jako przeczytane podczas przeglądania",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Oznacz jako przeczytane dopiero wtedy, gdy odtwarzanie audio i wideo osiągnie 90%% ukończenia",
|
||||
"form.prefs.label.mark_read_manually": "Oznacz wpisy jako przeczytane ręcznie",
|
||||
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
|
||||
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
|
||||
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
|
||||
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
|
||||
"form.prefs.label.external_font_hosts": "Hosty zewnętrznych czcionek",
|
||||
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
|
||||
"error.settings_invalid_domain_list": "Nieprawidłowa lista domen. Podaj listę domen rozdzielonych spacjami.",
|
||||
"form.import.label.file": "Plik OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.fever_activate": "Aktywuj Fever API",
|
||||
"form.import.label.url": "Adres URL",
|
||||
"form.integration.betula_activate": "Zapisuj wpisy w Betula",
|
||||
"form.integration.betula_url": "Adres URL serwera Betula",
|
||||
"form.integration.betula_token": "Token do Betula",
|
||||
"form.integration.fever_activate": "Aktywuj API Fever",
|
||||
"form.integration.fever_username": "Login do Fever",
|
||||
"form.integration.fever_password": "Hasło do Fever",
|
||||
"form.integration.fever_endpoint": "Punkt końcowy API gorączka:",
|
||||
"form.integration.googlereader_activate": "Aktywuj Google Reader API",
|
||||
"form.integration.fever_endpoint": "Punkt końcowy API Fever:",
|
||||
"form.integration.googlereader_activate": "Aktywuj API Google Reader",
|
||||
"form.integration.googlereader_username": "Login do Google Reader",
|
||||
"form.integration.googlereader_password": "Hasło do Google Reader",
|
||||
"form.integration.googlereader_endpoint": "Punkt końcowy API gorączka:",
|
||||
"form.integration.pinboard_activate": "Zapisz artykuł w Pinboard",
|
||||
"form.integration.pinboard_token": "Token Pinboard API",
|
||||
"form.integration.pinboard_tags": "Pinboard Tags",
|
||||
"form.integration.googlereader_endpoint": "Punkt końcowy API Google Reader:",
|
||||
"form.integration.pinboard_activate": "Zapisuj wpisy w Pinboard",
|
||||
"form.integration.pinboard_token": "Token API do Pinboard",
|
||||
"form.integration.pinboard_tags": "Znaczniki Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.instapaper_activate": "Zapisz artykuł w Instapaper",
|
||||
"form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
|
||||
"form.integration.instapaper_username": "Login do Instapaper",
|
||||
"form.integration.instapaper_password": "Hasło do Instapaper",
|
||||
"form.integration.pocket_activate": "Zapisz artykuły w Pocket",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
|
||||
"form.integration.pocket_access_token": "Token dostępu kieszeń",
|
||||
"form.integration.pocket_activate": "Zapisuj wpisy w Pocket",
|
||||
"form.integration.pocket_consumer_key": "Klucz klienta do Pocket",
|
||||
"form.integration.pocket_access_token": "Token dostępu do Pocket",
|
||||
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
|
||||
"form.integration.wallabag_activate": "Zapisz artykuły do Wallabag",
|
||||
"form.integration.wallabag_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag Client-ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
|
||||
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
|
||||
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.wallabag_endpoint": "Punkt końcowy API Wallabag",
|
||||
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
|
||||
"form.integration.wallabag_username": "Login do Wallabag",
|
||||
"form.integration.wallabag_password": "Hasło do Wallabag",
|
||||
"form.integration.notion_activate": "Save entries to Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
|
||||
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
|
||||
"form.integration.omnivore_activate": "Zapisz artykuly do Omnivore",
|
||||
"form.integration.omnivore_url": "Omnivore URL",
|
||||
"form.integration.omnivore_api_key": "Omnivore API key",
|
||||
"form.integration.espial_activate": "Zapisz artykuly do Espial",
|
||||
"form.integration.espial_endpoint": "Espial URL",
|
||||
"form.integration.espial_api_key": "Espial API key",
|
||||
"form.integration.espial_tags": "Espial Tags",
|
||||
"form.integration.readwise_activate": "Save entries to Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
|
||||
"form.integration.telegram_bot_activate": "Przesyłaj nowe artykuły do czatu Telegram",
|
||||
"form.integration.telegram_bot_token": "Token bota",
|
||||
"form.integration.notion_activate": "Zapisuj wpisy w Notion",
|
||||
"form.integration.notion_page_id": "Identyfikator strony Notion",
|
||||
"form.integration.notion_token": "Tajny token do Notion",
|
||||
"form.integration.apprise_activate": "Przesyłaj wpisy do Apprise",
|
||||
"form.integration.apprise_url": "Adres URL API Apprise",
|
||||
"form.integration.apprise_services_url": "Oddzielona przecinkami lista adresów URL usługi Apprise",
|
||||
"form.integration.nunux_keeper_activate": "Zapisuj wpisy w Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Punkt końcowy API Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Klucz API do Nunux Keeper",
|
||||
"form.integration.omnivore_activate": "Zapisuj wpisy w Omnivore",
|
||||
"form.integration.omnivore_url": "Punkt końcowy API Omnivore",
|
||||
"form.integration.omnivore_api_key": "Klucz API do Omnivore",
|
||||
"form.integration.espial_activate": "Zapisuj wpisy w Espial",
|
||||
"form.integration.espial_endpoint": "Punkt końcowy API Espial",
|
||||
"form.integration.espial_api_key": "Klucz API do Espial",
|
||||
"form.integration.espial_tags": "Znaczniki Espial",
|
||||
"form.integration.readwise_activate": "Zapisuj wpisy w czytniku Readwise",
|
||||
"form.integration.readwise_api_key": "Token dostępu do czytnika Readwise",
|
||||
"form.integration.readwise_api_key_link": "Zdobądź token dostępu Readwise",
|
||||
"form.integration.telegram_bot_activate": "Przesyłaj nowe wpisy do czatu Telegram",
|
||||
"form.integration.telegram_bot_token": "Token do bota",
|
||||
"form.integration.telegram_chat_id": "Identyfikator czatu",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
|
||||
"form.integration.telegram_bot_disable_notification": "Disable notification",
|
||||
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkding_activate": "Zapisz artykuły do Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding URL",
|
||||
"form.integration.linkding_api_key": "Linkding API key",
|
||||
"form.integration.linkding_tags": "Linkding Tags",
|
||||
"form.integration.linkding_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.linkwarden_activate": "Zapisz artykuły do Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden URL",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API key",
|
||||
"form.integration.matrix_bot_activate": "Przenieś nowe artykuły do Matrix",
|
||||
"form.integration.matrix_bot_user": "Nazwa użytkownika dla Matrix",
|
||||
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
||||
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
||||
"form.integration.telegram_topic_id": "Identyfikator tematu",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Wyłącz podgląd strony internetowej",
|
||||
"form.integration.telegram_bot_disable_notification": "Wyłącz powiadomienie",
|
||||
"form.integration.telegram_bot_disable_buttons": "Wyłącz przyciski",
|
||||
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
|
||||
"form.integration.linkace_endpoint": "Punkt końcowy API LinkAce",
|
||||
"form.integration.linkace_api_key": "Klucz API do LinkAce",
|
||||
"form.integration.linkace_tags": "Znaczniki LinkAce",
|
||||
"form.integration.linkace_is_private": "Oznacz łącze jako prywatne",
|
||||
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
|
||||
"form.integration.linkding_activate": "Zapisuj wpisy w Linkding",
|
||||
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
|
||||
"form.integration.linkding_api_key": "Klucz API do Linkding",
|
||||
"form.integration.linkding_tags": "Znaczniki Linkding",
|
||||
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
|
||||
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Punkt końcowy API Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
|
||||
"form.integration.matrix_bot_user": "Login do Matrix",
|
||||
"form.integration.matrix_bot_password": "Hasło do Matrix",
|
||||
"form.integration.matrix_bot_url": "Adres URL serwera Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
||||
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.raindrop_activate": "Zapisuj wpisy do Raindrop",
|
||||
"form.integration.raindrop_token": "Token (testowy)",
|
||||
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
|
||||
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
|
||||
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
|
||||
"form.integration.readeck_endpoint": "Punkt końcowy API Readeck",
|
||||
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
|
||||
"form.integration.readeck_labels": "Etykiety Readeck",
|
||||
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.shiori_activate": "Zapisuj artykuły w Shiori",
|
||||
"form.integration.shiori_endpoint": "Punkt końcowy API Shiori",
|
||||
"form.integration.shiori_username": "Login do Shiori",
|
||||
"form.integration.shiori_password": "Hasło do Shiori",
|
||||
"form.integration.shaarli_activate": "Save articles to Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.webhook_activate": "Enable Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.shaarli_activate": "Zapisuj artykuły w Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Adres URL Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Tajny klucz API do Shaarli",
|
||||
"form.integration.webhook_activate": "Włącz Webhook",
|
||||
"form.integration.webhook_url": "Adres URL Webhook",
|
||||
"form.integration.webhook_secret": "Tajny klucz do Webhook",
|
||||
"form.integration.rssbridge_activate": "Sprawdź RSS-Bridge podczas dodawania subskrypcji",
|
||||
"form.integration.rssbridge_url": "Adres URL serwera RSS-Bridge",
|
||||
"form.integration.ntfy_activate": "Przesyłaj wpisy do ntfy",
|
||||
"form.integration.ntfy_topic": "Temay ntfy",
|
||||
"form.integration.ntfy_url": "Adres URL ntfy (opcjonalny, domyślny to ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Token API ntfy (opcjonalny)",
|
||||
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
|
||||
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
|
||||
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
|
||||
"form.api_key.label.description": "Etykieta klucza API",
|
||||
"form.submit.loading": "Ładowanie...",
|
||||
"form.submit.saving": "Zapisywanie...",
|
||||
"form.submit.loading": "Ładowanie…",
|
||||
"form.submit.saving": "Zapisywanie…",
|
||||
"time_elapsed.not_yet": "jeszcze nie",
|
||||
"time_elapsed.yesterday": "wczoraj",
|
||||
"time_elapsed.now": "przed chwilą",
|
||||
@@ -501,12 +546,12 @@
|
||||
],
|
||||
"time_elapsed.weeks": [
|
||||
"%d tydzień temu",
|
||||
"%d tygodni temu",
|
||||
"%d tygodnie temu",
|
||||
"%d tygodni temu"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d miesiąc temu",
|
||||
"%d miesięcy temu",
|
||||
"%d miesiące temu",
|
||||
"%d miesięcy temu"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
@@ -515,35 +560,44 @@
|
||||
"%d lat temu"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.",
|
||||
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minuty przed ponowną próbą.",
|
||||
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minut przed ponowną próbą."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
|
||||
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem"
|
||||
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
|
||||
"error.http_response_too_large": "Odpowiedź HTTP jest za duża. Możesz zwiększyć limit rozmiaru odpowiedzi HTTP w ustawieniach globalnych (wymaga ponownego uruchomienia serwera).",
|
||||
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
|
||||
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
|
||||
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
|
||||
"error.tls_error": "Błąd TLS: %q. Jeśli chcesz, możesz wyłączyć weryfikację TLS w ustawieniach kanału.",
|
||||
"error.network_operation": "Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.",
|
||||
"error.network_timeout": "Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v",
|
||||
"error.http_client_error": "Błąd klienta HTTP: %v.",
|
||||
"error.http_not_authorized": "Dostęp do tej witryny nie jest autoryzowany. Może to być błędna nazwa użytkownika lub hasło.",
|
||||
"error.http_too_many_requests": "Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.",
|
||||
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
|
||||
"error.http_resource_not_found": "Nie znaleziono żądanego zasobu. Sprawdź adres URL.",
|
||||
"error.http_internal_server_error": "Strona jest w tej chwili niedostępna z powodu błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_service_unavailable": "Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_gateway_timeout": "Strona internetowa jest w tej chwili niedostępna z powodu błędu przekroczenia limitu czasu bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.http_unexpected_status_code": "Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
|
||||
"error.database_error": "Błąd bazy danych: %v.",
|
||||
"error.category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
|
||||
"error.duplicated_feed": "Ten kanał już istnieje.",
|
||||
"error.unable_to_parse_feed": "Nie można przeanalizować tego kanału: %v.",
|
||||
"error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.",
|
||||
"error.unable_to_detect_rssbridge": "Nie można wykryć kanału za pomocą RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
|
||||
"error.settings_media_playback_rate_range": "Szybkość odtwarzania jest poza zakresem",
|
||||
"enclosure_media_controls.seek" : "Przewiń:",
|
||||
"enclosure_media_controls.seek.title" : "Przewiń o %s sek.",
|
||||
"enclosure_media_controls.speed" : "Szybkość:",
|
||||
"enclosure_media_controls.speed.faster" : "Szybciej",
|
||||
"enclosure_media_controls.speed.faster.title" : "Szybciej o %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Wolniej",
|
||||
"enclosure_media_controls.speed.slower.title" : "Wolniej o %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Przywróć",
|
||||
"enclosure_media_controls.speed.reset.title" : "Przywróć szybkość do 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Marcar todos como lido",
|
||||
"menu.show_all_entries": "Mostrar todas os itens",
|
||||
"menu.show_only_unread_entries": "Mostrar apenas itens não lidos",
|
||||
"menu.show_only_starred_entries": "Mostrar apenas os favoritos",
|
||||
"menu.refresh_feed": "Atualizar",
|
||||
"menu.refresh_all_feeds": "Atualizar todas as fontes",
|
||||
"menu.edit_feed": "Editar",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Buscar",
|
||||
"search.placeholder": "Buscar por...",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Próximo",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Anterior",
|
||||
"entry.status.unread": "Não lido",
|
||||
"entry.status.read": "Lido",
|
||||
@@ -208,8 +211,8 @@
|
||||
"page.settings.title": "Ajustes",
|
||||
"page.settings.link_google_account": "Vincular minha conta do Google",
|
||||
"page.settings.unlink_google_account": "Desvincular minha conta do Google",
|
||||
"page.settings.link_oidc_account": "Vincular minha conta do OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Desvincular minha conta do OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Vincular minha conta do %s",
|
||||
"page.settings.unlink_oidc_account": "Desvincular minha conta do %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -217,13 +220,14 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Registrar senha",
|
||||
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Remover %d senha",
|
||||
"Remover %d senhas"
|
||||
],
|
||||
"page.login.title": "Iniciar Sessão",
|
||||
"page.login.google_signin": "Iniciar Sessão com sua conta do Google",
|
||||
"page.login.oidc_signin": "Iniciar Sessão com sua conta do OpenID Connect",
|
||||
"page.login.oidc_signin": "Iniciar Sessão com sua conta do %s",
|
||||
"page.login.webauthn_login": "Entrar com senha",
|
||||
"page.login.webauthn_login.error": "Não é possível fazer login com senha",
|
||||
"page.integrations.title": "Integrações",
|
||||
@@ -258,6 +262,7 @@
|
||||
"alert.no_bookmark": "Não há favorito neste momento.",
|
||||
"alert.no_category": "Não há categoria.",
|
||||
"alert.no_category_entry": "Não há itens nesta categoria.",
|
||||
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
|
||||
"alert.no_feed_entry": "Não há itens nessa fonte.",
|
||||
"alert.no_feed": "Não há inscrições.",
|
||||
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
|
||||
@@ -292,6 +297,14 @@
|
||||
"error.password_min_length": "A senha deve ter no mínimo 6 caracteres.",
|
||||
"error.settings_mandatory_fields": "Os campos de nome de usuário, tema, idioma e fuso horário são obrigatórios.",
|
||||
"error.settings_reading_speed_is_positive": "As velocidades de leitura devem ser inteiros positivos.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "O número de itens por página é inválido.",
|
||||
"error.feed_mandatory_fields": "O campo de URL e categoria são obrigatórios.",
|
||||
"error.feed_already_exists": "Este feed já existe.",
|
||||
@@ -316,6 +329,7 @@
|
||||
"form.feed.label.title": "Título",
|
||||
"form.feed.label.site_url": "URL do site",
|
||||
"form.feed.label.feed_url": "URL da fonte",
|
||||
"form.feed.label.description": "Descrição",
|
||||
"form.feed.label.category": "Categoria",
|
||||
"form.feed.label.crawler": "Obter conteúdo original",
|
||||
"form.feed.label.feed_username": "Nome de usuário da fonte",
|
||||
@@ -335,6 +349,15 @@
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
|
||||
"form.feed.label.hide_globally": "Ocultar entradas na lista global não lida",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -371,15 +394,26 @@
|
||||
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
|
||||
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
|
||||
"form.prefs.label.custom_css": "CSS customizado",
|
||||
"form.prefs.label.custom_js": "JavaScript customizado",
|
||||
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
|
||||
"form.prefs.label.default_home_page": "Página inicial predefinida",
|
||||
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
|
||||
"form.prefs.label.mark_read_on_view": "Marcar automaticamente as entradas como lidas quando visualizadas",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Arquivo OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Ativar API do Fever",
|
||||
"form.integration.fever_username": "Nome de usuário do Fever",
|
||||
"form.integration.fever_password": "Senha do Fever",
|
||||
@@ -451,6 +485,10 @@
|
||||
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Salvar itens no Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
|
||||
"form.integration.readeck_api_key": "Chave de API do Readeck",
|
||||
@@ -468,6 +506,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Etiqueta da chave de API",
|
||||
"form.submit.loading": "Carregando...",
|
||||
"form.submit.saving": "Salvando...",
|
||||
@@ -528,5 +573,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
|
||||
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo"
|
||||
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Отметить всё как прочитанное",
|
||||
"menu.show_all_entries": "Показать все статьи",
|
||||
"menu.show_only_unread_entries": "Показывать только непрочитанные статьи",
|
||||
"menu.show_only_starred_entries": "Показывать только избранные статьи",
|
||||
"menu.refresh_feed": "Обновить",
|
||||
"menu.refresh_all_feeds": "Обновить все подписки в фоне",
|
||||
"menu.edit_feed": "Изменить",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "Поиск",
|
||||
"search.placeholder": "Поиск…",
|
||||
"search.submit": "Search",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "Следующая",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "Предыдущая",
|
||||
"entry.status.unread": "Не прочитано",
|
||||
"entry.status.read": "Прочитано",
|
||||
@@ -217,8 +220,8 @@
|
||||
"page.settings.title": "Настройки",
|
||||
"page.settings.link_google_account": "Привязать мой Google аккаунт",
|
||||
"page.settings.unlink_google_account": "Отвязать мой Google аккаунт",
|
||||
"page.settings.link_oidc_account": "Привязать мой OpenID Connect аккаунт",
|
||||
"page.settings.unlink_oidc_account": "Отвязать мой OpenID Connect аккаунт",
|
||||
"page.settings.link_oidc_account": "Привязать мой %s аккаунт",
|
||||
"page.settings.unlink_oidc_account": "Отвязать мой %s аккаунт",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -226,6 +229,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Зарегистрировать пароль",
|
||||
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Удалить %d пароль",
|
||||
"Удалить %d пароля",
|
||||
@@ -233,7 +237,7 @@
|
||||
],
|
||||
"page.login.title": "Войти",
|
||||
"page.login.google_signin": "Войти с помощью Google",
|
||||
"page.login.oidc_signin": "Войти с помощью OpenID Connect",
|
||||
"page.login.oidc_signin": "Войти с помощью %s",
|
||||
"page.login.webauthn_login": "Войти с паролем",
|
||||
"page.login.webauthn_login.error": "Невозможно войти с паролем",
|
||||
"page.integrations.title": "Интеграции",
|
||||
@@ -268,6 +272,7 @@
|
||||
"alert.no_bookmark": "Избранное отсутствует.",
|
||||
"alert.no_category": "Категории отсутствуют.",
|
||||
"alert.no_category_entry": "В этой категории нет статей.",
|
||||
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
|
||||
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
|
||||
"alert.no_feed": "У вас нет ни одной подписки.",
|
||||
"alert.no_feed_in_category": "Для этой категории нет подписки.",
|
||||
@@ -302,6 +307,14 @@
|
||||
"error.password_min_length": "Вы должны использовать минимум 6 символов.",
|
||||
"error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
|
||||
"error.settings_reading_speed_is_positive": "Скорость чтения должна быть целым положительным числом.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Недопустимое значение количества записей на странице.",
|
||||
"error.feed_mandatory_fields": "Ссылка и категория обязательны.",
|
||||
"error.feed_already_exists": "Эта подписка уже существует.",
|
||||
@@ -326,6 +339,7 @@
|
||||
"form.feed.label.title": "Название",
|
||||
"form.feed.label.site_url": "Адрес сайта",
|
||||
"form.feed.label.feed_url": "Адрес подписки",
|
||||
"form.feed.label.description": "Описание",
|
||||
"form.feed.label.category": "Категория",
|
||||
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
|
||||
"form.feed.label.feed_username": "Имя пользователя подписки",
|
||||
@@ -345,6 +359,15 @@
|
||||
"form.feed.label.disabled": "Не обновлять эту подписку",
|
||||
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
|
||||
"form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.rules": "Rules",
|
||||
"form.feed.fieldset.network_settings": "Network Settings",
|
||||
@@ -381,15 +404,26 @@
|
||||
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
|
||||
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
|
||||
"form.prefs.label.custom_css": "Пользовательский CSS",
|
||||
"form.prefs.label.custom_js": "Пользовательский JavaScript",
|
||||
"form.prefs.label.entry_order": "Столбец сортировки статей",
|
||||
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
|
||||
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
|
||||
"form.prefs.label.mark_read_on_view": "Автоматически отмечать записи как прочитанные при просмотре",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML файл",
|
||||
"form.import.label.url": "Ссылка",
|
||||
"form.integration.betula_activate": "Сохранять статьи в Бетулу",
|
||||
"form.integration.betula_url": "Адрес сервера Бетулы",
|
||||
"form.integration.betula_token": "Токен Бетулы",
|
||||
"form.integration.fever_activate": "Активировать Fever API",
|
||||
"form.integration.fever_username": "Имя пользователя Fever",
|
||||
"form.integration.fever_password": "Пароль Fever",
|
||||
@@ -461,6 +495,10 @@
|
||||
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
||||
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
|
||||
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
|
||||
"form.integration.readeck_api_key": "API-ключ Readeck",
|
||||
@@ -478,6 +516,13 @@
|
||||
"form.integration.webhook_secret": "Секретный ключ для вебхуков",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Описание API-ключа",
|
||||
"form.submit.loading": "Загрузка…",
|
||||
"form.submit.saving": "Сохранение…",
|
||||
@@ -545,5 +590,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
|
||||
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона"
|
||||
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"alert.no_bookmark": "Yıldızlanmış makale yok.",
|
||||
"alert.no_category": "Hiç kategori yok.",
|
||||
"alert.no_category_entry": "Bu kategoride hiç makele yok.",
|
||||
"alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
|
||||
"alert.no_feed": "Hiç beslemeniz yok.",
|
||||
"alert.no_feed_entry": "Bu besleme için makele yok.",
|
||||
"alert.no_feed_in_category": "Bu kategori için besleme yok.",
|
||||
@@ -121,6 +122,14 @@
|
||||
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
|
||||
"error.settings_media_playback_rate_range": "Oynatma hızı aralık dışında",
|
||||
"error.settings_reading_speed_is_positive": "Okuma hızları pozitif tam sayılar olmalıdır.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Geçersiz Engelleme kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
|
||||
"error.settings_block_rule_separator_required": "Geçersiz Engelleme kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
|
||||
"error.settings_block_rule_regex_required": "Geçersiz Engelleme kuralı: #%d kuralı modeli sağlanmadı",
|
||||
"error.settings_block_rule_invalid_regex": "Geçersiz Engelleme kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Geçersiz Koruma kuralı: #%d kuralında geçerli bir alan adı eksik (Seçenekler: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Geçersiz Koruma kuralı: #%d kuralı modelinin '=' ile ayrılması gerekiyor",
|
||||
"error.settings_keep_rule_regex_required": "Geçersiz Koruma kuralı: #%d kuralı modeli sağlanmadı",
|
||||
"error.settings_keep_rule_invalid_regex": "Geçersiz Koruma kuralı: #%d kuralı modeli geçerli bir düzenli ifade değil",
|
||||
"error.site_url_not_empty": "Site URL'si boş olamaz.",
|
||||
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
|
||||
"error.title_required": "Başlık zorunlu.",
|
||||
@@ -153,6 +162,7 @@
|
||||
"form.feed.label.disabled": "Bu beslemeyi yenileme",
|
||||
"form.feed.label.feed_password": "Besleme Parolası",
|
||||
"form.feed.label.feed_url": "Besleme URL'si",
|
||||
"form.feed.label.description": "Açıklama",
|
||||
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
|
||||
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
|
||||
"form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
|
||||
@@ -167,7 +177,10 @@
|
||||
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
|
||||
"form.import.label.file": "OPML dosyası",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Push entries to Apprise",
|
||||
"form.integration.betula_activate": "Makaleleri Betula'ya kaydet",
|
||||
"form.integration.betula_url": "Betula sunucu URLsi",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.apprise_activate": "Makaleleri Apprise'a gönder",
|
||||
"form.integration.apprise_services_url": "Apprise hizmet URL'lerinin virgülle ayrılmış listesi",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.espial_activate": "Makaleleri Espial'e kaydet",
|
||||
@@ -221,6 +234,10 @@
|
||||
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
|
||||
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
|
||||
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
|
||||
"form.integration.raindrop_activate": "Makaleleri Raindrop'a kaydet",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Koleksiyon ID",
|
||||
"form.integration.raindrop_tags": "Etiketler (virgülle ayrılmış)",
|
||||
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
|
||||
"form.integration.readeck_api_key": "Readeck API Anahtarı",
|
||||
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
|
||||
@@ -255,12 +272,30 @@
|
||||
"form.integration.webhook_activate": "Webhook'u etkinleştir",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
|
||||
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
|
||||
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
|
||||
"form.prefs.fieldset.global_feed_settings": "Genel Besleme Ayarları",
|
||||
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
|
||||
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
|
||||
"form.prefs.label.custom_css": "Özel CSS",
|
||||
"form.prefs.label.custom_js": "Özel JavaScript",
|
||||
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
|
||||
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
|
||||
"form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
|
||||
@@ -272,6 +307,9 @@
|
||||
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
|
||||
"form.prefs.label.language": "Dil",
|
||||
"form.prefs.label.mark_read_on_view": "Makaleler görüntülendiğinde otomatik olarak okundu olarak işaretle",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
|
||||
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
|
||||
"form.prefs.label.theme": "Tema",
|
||||
@@ -289,6 +327,9 @@
|
||||
"form.prefs.select.swipe": "Kaydırma",
|
||||
"form.prefs.select.tap": "Çift dokunma",
|
||||
"form.prefs.select.unread_count": "Okunmamış sayısı",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.submit.loading": "Yükleniyor...",
|
||||
"form.submit.saving": "Kaydediliyor...",
|
||||
"form.user.label.admin": "Yönetici",
|
||||
@@ -324,6 +365,7 @@
|
||||
"menu.shared_entries": "Paylaşılan makaleler",
|
||||
"menu.show_all_entries": "Tüm makaleleri göster",
|
||||
"menu.show_only_unread_entries": "Sadece okunmamış makaleleri göster",
|
||||
"menu.show_only_starred_entries": "Sadece yıldızlanmış makaleleri göster",
|
||||
"menu.starred": "Yıldız",
|
||||
"menu.title": "Menü",
|
||||
"menu.unread": "Okunmadı",
|
||||
@@ -420,10 +462,11 @@
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Okundu/okunmadı arasında geçiş yap, sonrakine odaklan",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Okundu/okunmadı arasında geçiş yap, öncekine odaklan",
|
||||
"page.login.google_signin": "Google ile oturum aç",
|
||||
"page.login.oidc_signin": "OpenID Connect ile oturum aç",
|
||||
"page.login.oidc_signin": "%s ile oturum aç",
|
||||
"page.login.title": "Oturum aç",
|
||||
"page.login.webauthn_login": "Passkey ile giriş yap",
|
||||
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.new_api_key.title": "Yeni API Anahtarı",
|
||||
"page.new_category.title": "Yeni Kategori",
|
||||
"page.new_user.title": "Yeni Kullanıcı",
|
||||
@@ -439,10 +482,10 @@
|
||||
"page.sessions.table.user_agent": "User Agent",
|
||||
"page.sessions.title": "Oturumlar",
|
||||
"page.settings.link_google_account": "Google hesabımı bağla",
|
||||
"page.settings.link_oidc_account": "OpenID Connect hesabımı bağla",
|
||||
"page.settings.link_oidc_account": "%s hesabımı bağla",
|
||||
"page.settings.title": "Ayarlar",
|
||||
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
|
||||
"page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır",
|
||||
"page.settings.unlink_oidc_account": "%s hesabımın bağlantısını kaldır",
|
||||
"page.settings.webauthn.actions": "Eylemler",
|
||||
"page.settings.webauthn.added_on": "Eklendi",
|
||||
"page.settings.webauthn.delete": [
|
||||
@@ -479,7 +522,9 @@
|
||||
"page.users.title": "Kullanıcılar",
|
||||
"page.users.username": "Kullanıcı adı",
|
||||
"page.webauthn_rename.title": "Passkey'i Yeniden Adlandır",
|
||||
"pagination.last": "Son",
|
||||
"pagination.next": "Sonraki",
|
||||
"pagination.first": "İlk",
|
||||
"pagination.previous": "Önceki",
|
||||
"search.label": "Ara",
|
||||
"search.placeholder": "Ara...",
|
||||
@@ -495,5 +540,14 @@
|
||||
"time_elapsed.years": ["%d yıl önce", "%d yıl önce"],
|
||||
"time_elapsed.yesterday": "dün",
|
||||
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
|
||||
"tooltip.logged_user": "%s olarak giriş yapıldı"
|
||||
"tooltip.logged_user": "%s olarak giriş yapıldı",
|
||||
"enclosure_media_controls.seek" : "Sar:",
|
||||
"enclosure_media_controls.seek.title" : "%s saniye sar",
|
||||
"enclosure_media_controls.speed" : "Hız:",
|
||||
"enclosure_media_controls.speed.faster" : "Daha hızlı",
|
||||
"enclosure_media_controls.speed.faster.title" : "%sx kat daha hızlı",
|
||||
"enclosure_media_controls.speed.slower" : "Daha yavaş",
|
||||
"enclosure_media_controls.speed.slower.title" : "%sx kat daha yavaş",
|
||||
"enclosure_media_controls.speed.reset" : "Sıfırla",
|
||||
"enclosure_media_controls.speed.reset.title" : "Hızı 1x'e sıfırla"
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"action.home_screen": "Додати до головного екрану",
|
||||
"tooltip.keyboard_shortcuts": "Комбінація клавіш: %s",
|
||||
"tooltip.logged_user": "Здійснено вхід як %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "Меню",
|
||||
"menu.home_page": "Головна сторінка",
|
||||
"menu.unread": "Непрочитане",
|
||||
"menu.starred": "З зірочкою",
|
||||
"menu.history": "Історія",
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "Відмітити все як прочитане",
|
||||
"menu.show_all_entries": "Показати всі записи",
|
||||
"menu.show_only_unread_entries": "Показати тільки непрочитані записи",
|
||||
"menu.show_only_starred_entries": "Показати тільки записи з зірочкою",
|
||||
"menu.refresh_feed": "Оновити",
|
||||
"menu.refresh_all_feeds": "Оновити всі стрічки у фоновому режимі",
|
||||
"menu.edit_feed": "Редагувати",
|
||||
@@ -54,9 +55,11 @@
|
||||
"menu.shared_entries": "Спільні записи",
|
||||
"search.label": "Пошук",
|
||||
"search.placeholder": "Шукати...",
|
||||
"search.submit": "Search",
|
||||
"pagination.next": "Вперед",
|
||||
"pagination.previous": "Назад",
|
||||
"search.submit": "Знайти",
|
||||
"pagination.last": "Остання",
|
||||
"pagination.next": "Наступна",
|
||||
"pagination.first": "Перша",
|
||||
"pagination.previous": "Попередня",
|
||||
"entry.status.unread": "Непрочитане",
|
||||
"entry.status.read": "Прочитане",
|
||||
"entry.status.toast.unread": "Відмічено непрочитаним",
|
||||
@@ -89,7 +92,7 @@
|
||||
"читати %d хвилин"
|
||||
],
|
||||
"entry.tags.label": "Теги:",
|
||||
"page.shared_entries.title": "Спильні записи",
|
||||
"page.shared_entries.title": "Спільні записи",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries",
|
||||
@@ -131,9 +134,9 @@
|
||||
"page.edit_category.title": "Редагування категорії: %s",
|
||||
"page.edit_user.title": "Редагування користувача: %s",
|
||||
"page.feeds.title": "Стрічки",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "Категорія: %s",
|
||||
"page.feeds.last_check": "Остання перевірка:",
|
||||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.next_check": "Наступна перевірка:",
|
||||
"page.feeds.read_counter": "Кількість прочитаних записів",
|
||||
"page.feeds.error_count": [
|
||||
"%d помилка",
|
||||
@@ -217,8 +220,8 @@
|
||||
"page.settings.title": "Налаштування ",
|
||||
"page.settings.link_google_account": "Підключити мій обліковий запис Google",
|
||||
"page.settings.unlink_google_account": "Відключити мій обліковий запис Google",
|
||||
"page.settings.link_oidc_account": "Підключити мій обліковий запис OpenID Connect",
|
||||
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис OpenID Connect",
|
||||
"page.settings.link_oidc_account": "Підключити мій обліковий запис %s",
|
||||
"page.settings.unlink_oidc_account": "Відключити мій обліковий запис %s",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "Actions",
|
||||
"page.settings.webauthn.passkey_name": "Passkey Name",
|
||||
@@ -226,6 +229,7 @@
|
||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||
"page.settings.webauthn.register": "Зареєструвати пароль",
|
||||
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Видалити %d ключ доступу",
|
||||
"Видаліть %d ключа доступу",
|
||||
@@ -233,7 +237,7 @@
|
||||
],
|
||||
"page.login.title": "Вхід",
|
||||
"page.login.google_signin": "Увійти через Google",
|
||||
"page.login.oidc_signin": "Увійти через OpenID Connect",
|
||||
"page.login.oidc_signin": "Увійти через %s",
|
||||
"page.login.webauthn_login": "Увійти за допомогою пароля",
|
||||
"page.login.webauthn_login.error": "Неможливо ввійти за допомогою ключа доступу",
|
||||
"page.integrations.title": "Інтеграції",
|
||||
@@ -268,6 +272,7 @@
|
||||
"alert.no_bookmark": "Наразі закладки відсутні.",
|
||||
"alert.no_category": "Немає категорії.",
|
||||
"alert.no_category_entry": "У цій категорії немає записів.",
|
||||
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
|
||||
"alert.no_feed_entry": "У цій стрічці немає записів.",
|
||||
"alert.no_feed": "У вас немає підписок.",
|
||||
"alert.no_feed_in_category": "У цій категорії немає підписок.",
|
||||
@@ -309,6 +314,14 @@
|
||||
"error.password_min_length": "Пароль має складати щонайменше 6 символів.",
|
||||
"error.settings_mandatory_fields": "Поля імені, теми, мови та часового поясу є обов’язковими.",
|
||||
"error.settings_reading_speed_is_positive": "Швидкість читання має бути додатнім цілим числом.",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "Число записів на сторінку недійсне.",
|
||||
"error.feed_mandatory_fields": "URL та категорія є обов’язковими.",
|
||||
"error.feed_already_exists": "Така стрічка вже існує.",
|
||||
@@ -326,6 +339,7 @@
|
||||
"form.feed.label.title": "Назва",
|
||||
"form.feed.label.site_url": "URL-адреса сайту",
|
||||
"form.feed.label.feed_url": "URL-адреса стрічки",
|
||||
"form.feed.label.description": "Опис",
|
||||
"form.feed.label.category": "Категорія",
|
||||
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
|
||||
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
|
||||
@@ -345,6 +359,15 @@
|
||||
"form.feed.label.disabled": "Не оновлювати цю стрічку",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.category.label.title": "Назва",
|
||||
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
|
||||
"form.feed.fieldset.general": "General",
|
||||
@@ -381,15 +404,26 @@
|
||||
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
|
||||
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
|
||||
"form.prefs.label.custom_css": "Спеціальний CSS",
|
||||
"form.prefs.label.custom_js": "Спеціальний JavaScript",
|
||||
"form.prefs.label.entry_order": "Стовпець сортування записів",
|
||||
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
|
||||
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
|
||||
"form.prefs.label.mark_read_on_view": "Автоматично позначати записи як прочитані під час перегляду",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "Application Settings",
|
||||
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
|
||||
"form.prefs.fieldset.reader_settings": "Reader Settings",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "Файл OPML",
|
||||
"form.import.label.url": "URL-адреса",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "Увімкнути API Fever",
|
||||
"form.integration.fever_username": "Ім’я користувача Fever",
|
||||
"form.integration.fever_password": "Пароль Fever",
|
||||
@@ -461,8 +495,12 @@
|
||||
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
||||
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
|
||||
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
|
||||
@@ -478,6 +516,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge server URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "Назва ключа API",
|
||||
"form.submit.loading": "Завантаження...",
|
||||
"form.submit.saving": "Зберігаю...",
|
||||
@@ -545,5 +590,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
|
||||
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону"
|
||||
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_content": "跳转至内容",
|
||||
"confirm.question": "您确认吗?",
|
||||
"confirm.question.refresh": "您是否要强制刷新?",
|
||||
"confirm.yes": "是",
|
||||
@@ -19,8 +19,8 @@
|
||||
"action.home_screen": "添加到主屏幕",
|
||||
"tooltip.keyboard_shortcuts": "快捷键: %s",
|
||||
"tooltip.logged_user": "当前登录 %s",
|
||||
"menu.title": "Menu",
|
||||
"menu.home_page": "Home page",
|
||||
"menu.title": "菜单",
|
||||
"menu.home_page": "首页",
|
||||
"menu.unread": "未读",
|
||||
"menu.starred": "收藏",
|
||||
"menu.history": "历史",
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "全部标为已读",
|
||||
"menu.show_all_entries": "显示所有文章",
|
||||
"menu.show_only_unread_entries": "仅显示未读文章",
|
||||
"menu.show_only_starred_entries": "仅显示已收藏文章",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "在后台更新全部源",
|
||||
"menu.edit_feed": "编辑",
|
||||
@@ -54,8 +55,10 @@
|
||||
"menu.shared_entries": "已分享的文章",
|
||||
"search.label": "搜索",
|
||||
"search.placeholder": "搜索…",
|
||||
"search.submit": "Search",
|
||||
"search.submit": "查找",
|
||||
"pagination.last": "最后一页",
|
||||
"pagination.next": "下一页",
|
||||
"pagination.first": "第一页",
|
||||
"pagination.previous": "上一页",
|
||||
"entry.status.unread": "标为未读",
|
||||
"entry.status.read": "标为已读",
|
||||
@@ -89,18 +92,18 @@
|
||||
"entry.tags.label": "标签:",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry"
|
||||
"%d 已分享的文章"
|
||||
],
|
||||
"page.unread.title": "未读",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry"
|
||||
"%d 未读的文章"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total"
|
||||
"%d 文章总数"
|
||||
],
|
||||
"page.starred.title": "收藏",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry"
|
||||
"%d 收藏的文章"
|
||||
],
|
||||
"page.categories.title": "分类",
|
||||
"page.categories.no_feed": "没有源",
|
||||
@@ -110,14 +113,14 @@
|
||||
"有 %d 个源"
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category"
|
||||
"%d 分类"
|
||||
],
|
||||
"page.new_category.title": "新分类",
|
||||
"page.new_user.title": "新用户",
|
||||
"page.edit_category.title": "编辑分类 : %s",
|
||||
"page.edit_user.title": "编辑用户 : %s",
|
||||
"page.feeds.title": "源",
|
||||
"page.category_label": "Category: %s",
|
||||
"page.category_label": "分类: %s",
|
||||
"page.feeds.last_check": "最后检查时间:",
|
||||
"page.feeds.next_check": "下次检查时间:",
|
||||
"page.feeds.read_counter": "已读文章数",
|
||||
@@ -126,7 +129,7 @@
|
||||
],
|
||||
"page.history.title": "历史",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry"
|
||||
"%d 阅读文章"
|
||||
],
|
||||
"page.import.title": "导入",
|
||||
"page.search.title": "搜索结果",
|
||||
@@ -199,8 +202,8 @@
|
||||
"page.settings.title": "设置",
|
||||
"page.settings.link_google_account": "关联我的 Google 账户",
|
||||
"page.settings.unlink_google_account": "解除 Google 账号关联",
|
||||
"page.settings.link_oidc_account": "关联我的 OpenID Connect 账户",
|
||||
"page.settings.unlink_oidc_account": "解除 OpenID Connect 账号关联",
|
||||
"page.settings.link_oidc_account": "关联我的 %s 账户",
|
||||
"page.settings.unlink_oidc_account": "解除 %s 账号关联",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "操作",
|
||||
"page.settings.webauthn.passkey_name": "Passkey 名称",
|
||||
@@ -208,12 +211,13 @@
|
||||
"page.settings.webauthn.last_seen_on": "最后使用时间",
|
||||
"page.settings.webauthn.register": "注册 Passkey",
|
||||
"page.settings.webauthn.register.error": "无法注册 Passkey",
|
||||
"page.login.webauthn_login.help": "如果您正在使用安全密钥,请输入您的用户名。如果您正在使用通行密钥(可发现凭证),则无需输入用户名。",
|
||||
"page.settings.webauthn.delete": [
|
||||
"删除 %d 个 Passkey"
|
||||
],
|
||||
"page.login.title": "登录",
|
||||
"page.login.google_signin": "使用 Google 登录",
|
||||
"page.login.oidc_signin": "使用 OpenID Connect 登录",
|
||||
"page.login.oidc_signin": "使用 %s 登录",
|
||||
"page.login.webauthn_login": "使用密码登录",
|
||||
"page.login.webauthn_login.error": "无法使用密码登录",
|
||||
"page.integrations.title": "集成",
|
||||
@@ -248,6 +252,7 @@
|
||||
"alert.no_bookmark": "目前没有收藏",
|
||||
"alert.no_category": "目前没有分类",
|
||||
"alert.no_category_entry": "该分类下没有文章",
|
||||
"alert.no_tag_entry": "没有与此标签匹配的条目。",
|
||||
"alert.no_feed_entry": "该源中没有文章",
|
||||
"alert.no_feed": "目前没有源",
|
||||
"alert.no_history": "目前没有历史",
|
||||
@@ -290,6 +295,14 @@
|
||||
"error.site_url_not_empty": "源网站的网址不能为空。",
|
||||
"error.feed_title_not_empty": "订阅源的标题不能为空。",
|
||||
"error.settings_reading_speed_is_positive": "阅读速度必须是正整数。",
|
||||
"error.settings_block_rule_fieldname_invalid": "无效的阻止规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
|
||||
"error.settings_block_rule_separator_required": "无效的阻止规则: 规则 #%d 的模式字符必须用‘=’分开。",
|
||||
"error.settings_block_rule_regex_required": "无效的阻止规则: 规则 #%d 的模式字符没有提供。",
|
||||
"error.settings_block_rule_invalid_regex": "无效的阻止规则: 规则 #%d 的模式字符不是合法的正则表达式。",
|
||||
"error.settings_keep_rule_fieldname_invalid": "无效的保留规则: 规则 #%d 缺少合法的字段名 (可选: %s)",
|
||||
"error.settings_keep_rule_separator_required": "无效的保留规则: 规则 #%d 的模式字符必须用‘=’分开。",
|
||||
"error.settings_keep_rule_regex_required": "无效的保留规则: 规则 #%d 的模式字符没有提供。",
|
||||
"error.settings_keep_rule_invalid_regex": "无效的保留规则: 规则 #%d 的模式字符不是合法的正则表达式。",
|
||||
"error.feed_category_not_found": "此类别不存在或不属于该用户。",
|
||||
"error.feed_invalid_blocklist_rule": "阻止列表规则无效。",
|
||||
"error.feed_invalid_keeplist_rule": "保留列表规则无效。",
|
||||
@@ -306,6 +319,7 @@
|
||||
"form.feed.label.title": "标题",
|
||||
"form.feed.label.site_url": "源网站 URL",
|
||||
"form.feed.label.feed_url": "订阅源 URL",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.category": "类别",
|
||||
"form.feed.label.crawler": "抓取全文内容",
|
||||
"form.feed.label.feed_username": "源用户名",
|
||||
@@ -320,11 +334,20 @@
|
||||
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
|
||||
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disable_http2": "关闭 HTTP/2 避免记录指纹",
|
||||
"form.feed.label.fetch_via_proxy": "通过代理获取",
|
||||
"form.feed.label.disabled": "请勿刷新此源",
|
||||
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
|
||||
"form.feed.label.hide_globally": "隐藏全局未读列表中的文章",
|
||||
"form.feed.label.ntfy_activate": "推送条目到ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy优先级",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy最高优先级",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy高优先级",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy默认优先级",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy低优先级",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy最低优先级",
|
||||
"form.integration.cubox_activate": "保存文章到 Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API 链接",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.rules": "规则",
|
||||
"form.feed.fieldset.network_settings": "网络设置",
|
||||
@@ -361,15 +384,26 @@
|
||||
"form.prefs.label.gesture_nav": "在条目之间导航的手势",
|
||||
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
|
||||
"form.prefs.label.custom_css": "自定义 CSS",
|
||||
"form.prefs.label.custom_js": "自定义 JavaScript",
|
||||
"form.prefs.label.entry_order": "文章排序依据",
|
||||
"form.prefs.label.default_home_page": "默认主页",
|
||||
"form.prefs.label.categories_sorting_order": "分类排序",
|
||||
"form.prefs.label.mark_read_on_view": "查看时自动将条目标记为已读",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "当浏览时标记条目为已读。对于音频/视频,当播放完成90%%时标记为已读",
|
||||
"form.prefs.label.mark_read_on_media_completion": "仅当音频/视频播放完成90%%时标记为已读",
|
||||
"form.prefs.label.mark_read_manually": "手动标记条目为已读",
|
||||
"form.prefs.fieldset.application_settings": "应用设置",
|
||||
"form.prefs.fieldset.authentication_settings": "用户认证设置",
|
||||
"form.prefs.fieldset.reader_settings": "阅读器设置",
|
||||
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
|
||||
"form.prefs.label.external_font_hosts": "外部字体托管",
|
||||
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
|
||||
"error.settings_invalid_domain_list": "域名列表无效。请提供一个用空格分隔的域名列表。",
|
||||
"form.import.label.file": "OPML 文件",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "保存文章到 Betula",
|
||||
"form.integration.betula_url": "Betula 服务地址",
|
||||
"form.integration.betula_token": "Betula 密钥",
|
||||
"form.integration.fever_activate": "启用 Fever API",
|
||||
"form.integration.fever_username": "Fever 用户名",
|
||||
"form.integration.fever_password": "Fever 密码",
|
||||
@@ -379,7 +413,7 @@
|
||||
"form.integration.googlereader_password": "Google Reader 密码",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API 端点:",
|
||||
"form.integration.pinboard_activate": "保存文章到 Pinboard",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pinboard_token": "Pinboard API 密钥",
|
||||
"form.integration.pinboard_tags": "Pinboard 标签",
|
||||
"form.integration.pinboard_bookmark": "标记为未读",
|
||||
"form.integration.instapaper_activate": "保存文章到 Instapaper",
|
||||
@@ -393,12 +427,12 @@
|
||||
"form.integration.wallabag_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.wallabag_endpoint": "Wallabag URL",
|
||||
"form.integration.wallabag_client_id": "Wallabag 客户端 ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端 Secret",
|
||||
"form.integration.wallabag_client_secret": "Wallabag 客户端 密钥",
|
||||
"form.integration.wallabag_username": "Wallabag 用户名",
|
||||
"form.integration.wallabag_password": "Wallabag 密码",
|
||||
"form.integration.notion_activate": "保存文章到 Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.notion_page_id": "Notion 页面ID",
|
||||
"form.integration.notion_token": "Notion 密钥",
|
||||
"form.integration.apprise_activate": "将新文章推送到 Apprise",
|
||||
"form.integration.apprise_url": "Apprise API URL",
|
||||
"form.integration.apprise_services_url": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
@@ -413,8 +447,8 @@
|
||||
"form.integration.espial_api_key": "Espial API 密钥",
|
||||
"form.integration.espial_tags": "Espial 标签",
|
||||
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||
"form.integration.readwise_api_key_link": "获取你的 Readwise Access Token",
|
||||
"form.integration.readwise_api_key": "Readwise Reader 密钥",
|
||||
"form.integration.readwise_api_key_link": "获取你的 Readwise 密钥",
|
||||
"form.integration.telegram_bot_activate": "将新文章推送到 Telegram",
|
||||
"form.integration.telegram_bot_token": "机器人令牌",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
@@ -422,12 +456,12 @@
|
||||
"form.integration.telegram_bot_disable_notification": "禁用通知",
|
||||
"form.integration.telegram_bot_disable_buttons": "不展示按钮",
|
||||
"form.integration.telegram_chat_id": "聊天ID",
|
||||
"form.integration.linkace_activate": "Save entries to LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
|
||||
"form.integration.linkace_api_key": "LinkAce API key",
|
||||
"form.integration.linkace_tags": "LinkAce Tags",
|
||||
"form.integration.linkace_is_private": "Mark link as private",
|
||||
"form.integration.linkace_check_disabled": "Disable link check",
|
||||
"form.integration.linkace_activate": "保存文章到 LinkAce",
|
||||
"form.integration.linkace_endpoint": "LinkAce API URL",
|
||||
"form.integration.linkace_api_key": "LinkAce API 密钥",
|
||||
"form.integration.linkace_tags": "LinkAce 标签",
|
||||
"form.integration.linkace_is_private": "将链接标记为私有",
|
||||
"form.integration.linkace_check_disabled": "关闭链接检查",
|
||||
"form.integration.linkding_activate": "保存文章到 Linkding",
|
||||
"form.integration.linkding_endpoint": "Linkding API 端点",
|
||||
"form.integration.linkding_api_key": "Linkding API 密钥",
|
||||
@@ -441,6 +475,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
||||
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
||||
"form.integration.raindrop_activate": "保存文章到 Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) 密钥",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "保存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端点",
|
||||
"form.integration.readeck_api_key": "Readeck API 密钥",
|
||||
@@ -452,12 +490,19 @@
|
||||
"form.integration.shiori_password": "Shiori 密码",
|
||||
"form.integration.shaarli_activate": "保存文章到 Shaarli",
|
||||
"form.integration.shaarli_endpoint": "Shaarli URL",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API Secret",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API 密钥",
|
||||
"form.integration.webhook_activate": "启用 Webhook",
|
||||
"form.integration.webhook_url": "Webhook URL",
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.webhook_secret": "Webhook 密钥",
|
||||
"form.integration.rssbridge_activate": "添加订阅时检查 RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge 服务器 URL",
|
||||
"form.integration.ntfy_activate": "推送条目到ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy主题",
|
||||
"form.integration.ntfy_url": "Ntfy URL(可选,默认为ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API令牌(可选)",
|
||||
"form.integration.ntfy_username": "Ntfy用户名(可选)",
|
||||
"form.integration.ntfy_password": "Ntfy密码(可选)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy图标URL(可选)",
|
||||
"form.api_key.label.description": "API密钥标签",
|
||||
"form.submit.loading": "载入中…",
|
||||
"form.submit.saving": "保存中…",
|
||||
@@ -483,33 +528,42 @@
|
||||
"%d 年前"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
"多次触发订阅源更新,请等待 %d 分钟后重试。"
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
|
||||
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||
"error.http_client_error": "HTTP client error: %v.",
|
||||
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
|
||||
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
|
||||
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
|
||||
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
|
||||
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
|
||||
"error.database_error": "Database error: %v.",
|
||||
"error.category_not_found": "This category does not exist or does not belong to this user.",
|
||||
"error.duplicated_feed": "This feed already exists.",
|
||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"alert.background_feed_refresh": "所有的订阅源都在后台刷新中。您可以继续使用Miniflux,同时此过程正在运行。",
|
||||
"error.http_response_too_large": "HTTP响应内容过大,您可以在全局设置中增加HTTP响应大小限制(需要服务器重新启动)。",
|
||||
"error.http_body_read": "无法读取HTTP主体: %v。",
|
||||
"error.http_empty_response_body": "HTTP响应主体为空。",
|
||||
"error.http_empty_response": "HTTP响应内容为空,该网站可能正在使用机器人保护机制。",
|
||||
"error.tls_error": "TLS 错误: %q。如果您愿意的话可以在订阅源设置里关闭TLS验证。",
|
||||
"error.network_operation": "Miniflux无法访问该网站由于网络错误: %v。",
|
||||
"error.network_timeout": "该网站响应过慢,请求超时: %v",
|
||||
"error.http_client_error": "HTTP 客户端错误r: %v。",
|
||||
"error.http_not_authorized": "该网站访问未授权,可能用户名和密码错误。",
|
||||
"error.http_too_many_requests": "Miniflux 对该网站请求过多次数,请稍后重试或修改应用配置项。",
|
||||
"error.http_forbidden": "该网站被禁止访问,网站可能有机器人保护机制?",
|
||||
"error.http_resource_not_found": "请求资源无法找到,请检查URL。",
|
||||
"error.http_internal_server_error": "当前由于服务器错误导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_bad_gateway": "当前由于错误的网关导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_service_unavailable": "当前由于服务器内部错误导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_gateway_timeout": "当前由于网关超时导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.http_unexpected_status_code": "当前由于意外的HTTP状态码:%d 导致该网站无法访问,问题不在Miniflux,请稍后重试。",
|
||||
"error.database_error": "数据库错误: %v。",
|
||||
"error.category_not_found": "该分类不存在或不属于该用户。",
|
||||
"error.duplicated_feed": "该订阅源已经存在。",
|
||||
"error.unable_to_parse_feed": "无法解析该订阅源: %v。",
|
||||
"error.feed_not_found": "该订阅源不存在或不属于该用户。",
|
||||
"error.unable_to_detect_rssbridge": "无法使用RSS-Bridge去检测订阅源: %v。",
|
||||
"error.feed_format_not_detected": "无法解析订阅源格式: %v。",
|
||||
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
|
||||
"error.settings_media_playback_rate_range": "播放速度超出范围"
|
||||
"error.settings_media_playback_rate_range": "播放速度超出范围",
|
||||
"enclosure_media_controls.seek" : "查找:",
|
||||
"enclosure_media_controls.seek.title" : "查找 %s 秒",
|
||||
"enclosure_media_controls.speed" : "速度:",
|
||||
"enclosure_media_controls.speed.faster" : "快进",
|
||||
"enclosure_media_controls.speed.faster.title" : "速度快进到 %sx",
|
||||
"enclosure_media_controls.speed.slower" : "减慢",
|
||||
"enclosure_media_controls.speed.slower.title" : "速度减慢到 %sx",
|
||||
"enclosure_media_controls.speed.reset" : "重置",
|
||||
"enclosure_media_controls.speed.reset.title" : "重置速度到 1x"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.mark_all_as_read": "全部標為已讀",
|
||||
"menu.show_all_entries": "顯示所有文章",
|
||||
"menu.show_only_unread_entries": "僅顯示未讀文章",
|
||||
"menu.show_only_starred_entries": "Show only starred entries",
|
||||
"menu.refresh_feed": "更新",
|
||||
"menu.refresh_all_feeds": "背景更新全部Feeds",
|
||||
"menu.edit_feed": "編輯",
|
||||
@@ -55,7 +56,9 @@
|
||||
"search.label": "搜尋",
|
||||
"search.placeholder": "搜尋…",
|
||||
"search.submit": "送出",
|
||||
"pagination.last": "Last",
|
||||
"pagination.next": "下一頁",
|
||||
"pagination.first": "First",
|
||||
"pagination.previous": "上一頁",
|
||||
"entry.status.unread": "標為未讀",
|
||||
"entry.status.read": "標為已讀",
|
||||
@@ -199,8 +202,8 @@
|
||||
"page.settings.title": "設定",
|
||||
"page.settings.link_google_account": "關聯我的 Google 賬戶",
|
||||
"page.settings.unlink_google_account": "解除 Google 帳號關聯",
|
||||
"page.settings.link_oidc_account": "關聯我的 OpenID Connect 賬戶",
|
||||
"page.settings.unlink_oidc_account": "解除 OpenID Connect 帳號關聯",
|
||||
"page.settings.link_oidc_account": "關聯我的 %s 賬戶",
|
||||
"page.settings.unlink_oidc_account": "解除 %s 帳號關聯",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.actions": "操作",
|
||||
"page.settings.webauthn.passkey_name": "Passkey 名稱",
|
||||
@@ -208,12 +211,13 @@
|
||||
"page.settings.webauthn.last_seen_on": "最後使用時間",
|
||||
"page.settings.webauthn.register": "註冊 Passkey",
|
||||
"page.settings.webauthn.register.error": "無法註冊 Passkey",
|
||||
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
|
||||
"page.settings.webauthn.delete": [
|
||||
"刪除 %d 個 Passkey"
|
||||
],
|
||||
"page.login.title": "登入",
|
||||
"page.login.google_signin": "使用 Google 登入",
|
||||
"page.login.oidc_signin": "使用 OpenID Connect 登入",
|
||||
"page.login.oidc_signin": "使用 %s 登入",
|
||||
"page.login.webauthn_login": "使用密碼登錄",
|
||||
"page.login.webauthn_login.error": "無法使用密碼登錄",
|
||||
"page.integrations.title": "整合",
|
||||
@@ -248,6 +252,7 @@
|
||||
"alert.no_bookmark": "目前沒有收藏",
|
||||
"alert.no_category": "目前沒有分類",
|
||||
"alert.no_category_entry": "該分類下沒有文章",
|
||||
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
|
||||
"alert.no_feed_entry": "該Feed中沒有文章",
|
||||
"alert.no_feed": "目前沒有Feed",
|
||||
"alert.no_history": "目前沒有歷史",
|
||||
@@ -282,6 +287,14 @@
|
||||
"error.password_min_length": "請至少輸入 6 個字元",
|
||||
"error.settings_mandatory_fields": "必須填寫使用者名稱、主題、語言以及時區",
|
||||
"error.settings_reading_speed_is_positive": "閱讀速度必須是正整數。",
|
||||
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
|
||||
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
|
||||
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
|
||||
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
|
||||
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
|
||||
"error.entries_per_page_invalid": "每頁的文章數無效。",
|
||||
"error.feed_mandatory_fields": "必須填寫網址和分類",
|
||||
"error.feed_already_exists": "此Feed已存在。",
|
||||
@@ -306,6 +319,7 @@
|
||||
"form.feed.label.title": "標題",
|
||||
"form.feed.label.site_url": "網站 URL",
|
||||
"form.feed.label.feed_url": "訂閱 Feed URL",
|
||||
"form.feed.label.description": "描述",
|
||||
"form.feed.label.category": "類別",
|
||||
"form.feed.label.crawler": "下載原文內容",
|
||||
"form.feed.label.feed_username": "Feed 使用者名稱",
|
||||
@@ -325,6 +339,15 @@
|
||||
"form.feed.label.disabled": "請勿更新此 Feed",
|
||||
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
|
||||
"form.feed.label.hide_globally": "隱藏全域性未讀列表中的文章",
|
||||
"form.feed.label.ntfy_activate": "Push entries to ntfy",
|
||||
"form.feed.label.ntfy_priority": "Ntfy priority",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
|
||||
"form.integration.cubox_activate": "Save entries to Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API link",
|
||||
"form.feed.fieldset.general": "通用",
|
||||
"form.feed.fieldset.rules": "規則",
|
||||
"form.feed.fieldset.network_settings": "網路設定",
|
||||
@@ -361,15 +384,26 @@
|
||||
"form.prefs.label.gesture_nav": "在條目之間導航的手勢",
|
||||
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
|
||||
"form.prefs.label.custom_css": "自定義 CSS",
|
||||
"form.prefs.label.custom_js": "自定義 JavaScript",
|
||||
"form.prefs.label.entry_order": "文章排序依據",
|
||||
"form.prefs.label.default_home_page": "預設主頁",
|
||||
"form.prefs.label.categories_sorting_order": "分類排序",
|
||||
"form.prefs.label.mark_read_on_view": "查看時自動將條目標記為已讀",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
|
||||
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
|
||||
"form.prefs.fieldset.application_settings": "應用程式設定",
|
||||
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
|
||||
"form.prefs.fieldset.reader_settings": "閱讀器設定",
|
||||
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
|
||||
"form.prefs.label.external_font_hosts": "External font hosts",
|
||||
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
|
||||
"form.import.label.file": "OPML 檔案",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.betula_activate": "Save entries to Betula",
|
||||
"form.integration.betula_url": "Betula server URL",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.fever_activate": "啟用 Fever API",
|
||||
"form.integration.fever_username": "Fever 使用者名稱",
|
||||
"form.integration.fever_password": "Fever 密碼",
|
||||
@@ -441,6 +475,10 @@
|
||||
"form.integration.matrix_bot_password": "Matrix 的密碼",
|
||||
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
||||
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||
"form.integration.readeck_activate": "儲存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端點",
|
||||
"form.integration.readeck_api_key": "Readeck API 金鑰",
|
||||
@@ -458,6 +496,13 @@
|
||||
"form.integration.webhook_secret": "Webhook Secret",
|
||||
"form.integration.rssbridge_activate": "新增訂閱時檢查 RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge 伺服器的 URL",
|
||||
"form.integration.ntfy_activate": "Push entries to ntfy",
|
||||
"form.integration.ntfy_topic": "Ntfy topic",
|
||||
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
|
||||
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
|
||||
"form.integration.ntfy_username": "Ntfy Username (optional)",
|
||||
"form.integration.ntfy_password": "Ntfy Password (optional)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
|
||||
"form.api_key.label.description": "API金鑰標籤",
|
||||
"form.submit.loading": "載入中…",
|
||||
"form.submit.saving": "儲存中…",
|
||||
@@ -511,5 +556,14 @@
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
|
||||
"error.settings_media_playback_rate_range": "播放速度超出範圍"
|
||||
"error.settings_media_playback_rate_range": "播放速度超出範圍",
|
||||
"enclosure_media_controls.seek" : "Seek:",
|
||||
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||
"enclosure_media_controls.speed" : "Speed:",
|
||||
"enclosure_media_controls.speed.faster" : "Faster",
|
||||
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||
"enclosure_media_controls.speed.slower" : "Slower",
|
||||
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||
"enclosure_media_controls.speed.reset" : "Reset",
|
||||
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
expected := `<p><img src="http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
@@ -182,12 +182,10 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteProxyFilterWithHttpsScheme(t *testing.T) {
|
||||
func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "image")
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||
os.Setenv("HTTPS", "1")
|
||||
os.Setenv("BASE_URL", "http://example.org:88/folder/")
|
||||
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
@@ -196,12 +194,25 @@ func TestAbsoluteProxyFilterWithHttpsScheme(t *testing.T) {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
if config.Opts.BaseURL() != "http://example.org:88/folder" {
|
||||
t.Fatalf(`Unexpected base URL, got "%s"`, config.Opts.BaseURL())
|
||||
}
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
expected := `<p><img src="https://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||
if config.Opts.RootURL() != "http://example.org:88" {
|
||||
t.Fatalf(`Unexpected root URL, got "%s"`, config.Opts.RootURL())
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
|
||||
if config.Opts.BasePath() != "" {
|
||||
router = router.PathPrefix(config.Opts.BasePath()).Subrouter()
|
||||
}
|
||||
|
||||
router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(router, input)
|
||||
expected := `<p><img src="http://example.org:88/folder/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||
@@ -225,7 +236,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<audio src="https://website/folder/audio.mp3"></audio>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
expected := `<audio src="http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM="></audio>`
|
||||
|
||||
if expected != output {
|
||||
@@ -300,7 +311,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||
output := RewriteDocumentWithAbsoluteProxyURL(r, input)
|
||||
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||
|
||||
if expected != output {
|
||||
@@ -553,3 +564,28 @@ func TestProxyFilterVideoPoster(t *testing.T) {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFilterVideoPosterOnce(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "image,video")
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
||||
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
|
||||
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -20,11 +21,8 @@ func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string
|
||||
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument)
|
||||
}
|
||||
|
||||
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, host, htmlDocument string) string {
|
||||
proxifyFunction := func(router *mux.Router, url string) string {
|
||||
return ProxifyAbsoluteURL(router, host, url)
|
||||
}
|
||||
return genericProxyRewriter(router, proxifyFunction, htmlDocument)
|
||||
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, htmlDocument string) string {
|
||||
return genericProxyRewriter(router, ProxifyAbsoluteURL, htmlDocument)
|
||||
}
|
||||
|
||||
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string {
|
||||
@@ -53,13 +51,15 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
}
|
||||
})
|
||||
|
||||
doc.Find("video").Each(func(i int, video *goquery.Selection) {
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
|
||||
if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") {
|
||||
doc.Find("video").Each(func(i int, video *goquery.Selection) {
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
case "audio":
|
||||
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
|
||||
@@ -87,7 +87,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||
}
|
||||
}
|
||||
|
||||
output, err := doc.Find("body").First().Html()
|
||||
output, err := doc.FindMatcher(goquery.Single("body")).Html()
|
||||
if err != nil {
|
||||
return htmlDocument
|
||||
}
|
||||
|
||||
@@ -9,13 +9,11 @@ import (
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
)
|
||||
|
||||
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
|
||||
@@ -33,7 +31,7 @@ func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
|
||||
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
}
|
||||
|
||||
func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
|
||||
func ProxifyAbsoluteURL(router *mux.Router, mediaURL string) string {
|
||||
if mediaURL == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -42,13 +40,14 @@ func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
|
||||
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||
}
|
||||
|
||||
// Note that the proxyified URL is relative to the root URL.
|
||||
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
|
||||
scheme := "http"
|
||||
if config.Opts.HTTPS {
|
||||
scheme = "https"
|
||||
absoluteURL, err := url.JoinPath(config.Opts.RootURL(), proxifiedUrl)
|
||||
if err != nil {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
return scheme + "://" + host + proxifiedUrl
|
||||
return absoluteURL
|
||||
}
|
||||
|
||||
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
@@ -56,7 +55,7 @@ func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
proxyUrl, err := url.Parse(customProxyURL)
|
||||
absoluteURL, err := url.JoinPath(customProxyURL, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
if err != nil {
|
||||
slog.Error("Incorrect custom media proxy URL",
|
||||
slog.String("custom_proxy_url", customProxyURL),
|
||||
@@ -65,6 +64,5 @@ func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||
return mediaURL
|
||||
}
|
||||
|
||||
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||
return proxyUrl.String()
|
||||
return absoluteURL
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
|
||||
package model // import "miniflux.app/v2/internal/model"
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/mediaproxy"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// Enclosure represents an attachment.
|
||||
type Enclosure struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -14,6 +23,10 @@ type Enclosure struct {
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
type EnclosureUpdateRequest struct {
|
||||
MediaProgression int64 `json:"media_progression"`
|
||||
}
|
||||
|
||||
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
|
||||
func (e Enclosure) Html5MimeType() string {
|
||||
if e.MimeType == "video/m4v" {
|
||||
@@ -22,5 +35,69 @@ func (e Enclosure) Html5MimeType() string {
|
||||
return e.MimeType
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsAudio() bool {
|
||||
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsVideo() bool {
|
||||
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
|
||||
}
|
||||
|
||||
func (e *Enclosure) IsImage() bool {
|
||||
mimeType := strings.ToLower(e.MimeType)
|
||||
mediaURL := strings.ToLower(e.URL)
|
||||
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
|
||||
}
|
||||
|
||||
// EnclosureList represents a list of attachments.
|
||||
type EnclosureList []*Enclosure
|
||||
|
||||
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
|
||||
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
|
||||
for _, enclosure := range el {
|
||||
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
return enclosure
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
for _, enclosure := range el {
|
||||
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" {
|
||||
for i := range el {
|
||||
if urllib.IsHTTPS(el[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(el[i].MimeType, mediaType+"/") {
|
||||
el[i].URL = mediaproxy.ProxifyAbsoluteURL(router, el[i].URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(e.URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
if strings.HasPrefix(e.MimeType, mediaType+"/") {
|
||||
e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,22 @@ func NewEntry() *Entry {
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldMarkAsReadOnView Return whether the entry should be marked as viewed considering all user settings and entry state.
|
||||
func (e *Entry) ShouldMarkAsReadOnView(user *User) bool {
|
||||
// Already read, no need to mark as read again. Removed entries are not marked as read
|
||||
if e.Status != EntryStatusUnread {
|
||||
return false
|
||||
}
|
||||
|
||||
// There is an enclosure, markAsRead will happen at enclosure completion time, no need to mark as read on view
|
||||
if user.MarkReadOnMediaPlayerCompletion && e.Enclosures.ContainsAudioOrVideo() {
|
||||
return false
|
||||
}
|
||||
|
||||
// The user wants to mark as read on view
|
||||
return user.MarkReadOnView
|
||||
}
|
||||
|
||||
// Entries represents a list of entries.
|
||||
type Entries []*Entry
|
||||
|
||||
|
||||
+24
-14
@@ -28,6 +28,7 @@ type Feed struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
NextCheckAt time.Time `json:"next_check_at"`
|
||||
EtagHeader string `json:"etag_header"`
|
||||
@@ -50,14 +51,17 @@ type Feed struct {
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
|
||||
// Non persisted attributes
|
||||
// Non-persisted attributes
|
||||
Category *Category `json:"category,omitempty"`
|
||||
Icon *FeedIcon `json:"icon"`
|
||||
Entries Entries `json:"entries,omitempty"`
|
||||
|
||||
// Internal attributes (not exposed in the API and not persisted in the database)
|
||||
TTL int `json:"-"`
|
||||
IconURL string `json:"-"`
|
||||
UnreadCount int `json:"-"`
|
||||
@@ -108,26 +112,27 @@ func (f *Feed) CheckedNow() {
|
||||
}
|
||||
|
||||
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
|
||||
func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) {
|
||||
f.TTL = newTTL
|
||||
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
|
||||
f.TTL = refreshDelayInMinutes
|
||||
|
||||
// Default to the global config Polling Frequency.
|
||||
var intervalMinutes int
|
||||
switch config.Opts.PollingScheduler() {
|
||||
case SchedulerEntryFrequency:
|
||||
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
|
||||
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
|
||||
if weeklyCount <= 0 {
|
||||
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
|
||||
} else {
|
||||
intervalMinutes = int(math.Round(float64(7*24*60) / float64(weeklyCount*config.Opts.SchedulerEntryFrequencyFactor())))
|
||||
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
|
||||
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
|
||||
intervalMinutes = min(intervalMinutes, config.Opts.SchedulerEntryFrequencyMaxInterval())
|
||||
intervalMinutes = max(intervalMinutes, config.Opts.SchedulerEntryFrequencyMinInterval())
|
||||
}
|
||||
default:
|
||||
intervalMinutes = config.Opts.SchedulerRoundRobinMinInterval()
|
||||
}
|
||||
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
|
||||
if newTTL > intervalMinutes && newTTL > 0 {
|
||||
intervalMinutes = newTTL
|
||||
|
||||
// If the feed has a TTL or a Retry-After defined, we use it to make sure we don't check it too often.
|
||||
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes {
|
||||
intervalMinutes = refreshDelayInMinutes
|
||||
}
|
||||
|
||||
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
|
||||
}
|
||||
|
||||
@@ -167,6 +172,7 @@ type FeedModificationRequest struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
BlocklistRules *string `json:"blocklist_rules"`
|
||||
@@ -201,6 +207,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
|
||||
feed.Title = *f.Title
|
||||
}
|
||||
|
||||
if f.Description != nil && *f.Description != "" {
|
||||
feed.Description = *f.Description
|
||||
}
|
||||
|
||||
if f.ScraperRules != nil {
|
||||
feed.ScraperRules = *f.ScraperRules
|
||||
}
|
||||
|
||||
+57
-13
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
largeWeeklyCount = 10080
|
||||
noNewTTL = 0
|
||||
noRefreshDelay = 0
|
||||
)
|
||||
|
||||
func TestFeedCategorySetter(t *testing.T) {
|
||||
@@ -76,7 +76,7 @@ func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefor
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckDefault(t *testing.T) {
|
||||
func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
@@ -88,15 +88,60 @@ func TestFeedScheduleNextCheckDefault(t *testing.T) {
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
weeklyCount := 10
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(0, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "default SchedulerRoundRobinMinInterval")
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinDefault")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
|
||||
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
|
||||
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval()
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
|
||||
@@ -114,15 +159,14 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
|
||||
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
weeklyCount := 100
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(0, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
}
|
||||
|
||||
targetInterval := minInterval
|
||||
checkTargetInterval(t, feed, targetInterval, timeBefore, "round robin min interval")
|
||||
expectedInterval := minInterval
|
||||
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
|
||||
}
|
||||
|
||||
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
|
||||
@@ -144,7 +188,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
|
||||
feed := &Feed{}
|
||||
// Use a very small weekly count to trigger the max interval
|
||||
weeklyCount := 1
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
@@ -173,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
|
||||
feed := &Feed{}
|
||||
// Use a very small weekly count to trigger the max interval
|
||||
weeklyCount := 0
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
@@ -202,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
|
||||
feed := &Feed{}
|
||||
// Use a very large weekly count to trigger the min interval
|
||||
weeklyCount := largeWeeklyCount
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
@@ -228,7 +272,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
|
||||
timeBefore := time.Now()
|
||||
feed := &Feed{}
|
||||
weeklyCount := 7
|
||||
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
|
||||
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
|
||||
|
||||
if feed.NextCheckAt.IsZero() {
|
||||
t.Error(`The next_check_at must be set`)
|
||||
|
||||
@@ -6,6 +6,9 @@ package model // import "miniflux.app/v2/internal/model"
|
||||
// Integration represents user integration settings.
|
||||
type Integration struct {
|
||||
UserID int64
|
||||
BetulaEnabled bool
|
||||
BetulaURL string
|
||||
BetulaToken string
|
||||
PinboardEnabled bool
|
||||
PinboardToken string
|
||||
PinboardTags string
|
||||
@@ -90,4 +93,17 @@ type Integration struct {
|
||||
OmnivoreEnabled bool
|
||||
OmnivoreAPIKey string
|
||||
OmnivoreURL string
|
||||
RaindropEnabled bool
|
||||
RaindropToken string
|
||||
RaindropCollectionID string
|
||||
RaindropTags string
|
||||
NtfyEnabled bool
|
||||
NtfyTopic string
|
||||
NtfyURL string
|
||||
NtfyAPIToken string
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyIconURL string
|
||||
CuboxEnabled bool
|
||||
CuboxAPILink string
|
||||
}
|
||||
|
||||
+78
-48
@@ -11,31 +11,36 @@ import (
|
||||
|
||||
// User represents a user in the system.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"-"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime bool `json:"show_reading_time"`
|
||||
EntrySwipe bool `json:"entry_swipe"`
|
||||
GestureNav string `json:"gesture_nav"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
DefaultReadingSpeed int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"-"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
EntryDirection string `json:"entry_sorting_direction"`
|
||||
EntryOrder string `json:"entry_sorting_order"`
|
||||
Stylesheet string `json:"stylesheet"`
|
||||
CustomJS string `json:"custom_js"`
|
||||
ExternalFontHosts string `json:"external_font_hosts"`
|
||||
GoogleID string `json:"google_id"`
|
||||
OpenIDConnectID string `json:"openid_connect_id"`
|
||||
EntriesPerPage int `json:"entries_per_page"`
|
||||
KeyboardShortcuts bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime bool `json:"show_reading_time"`
|
||||
EntrySwipe bool `json:"entry_swipe"`
|
||||
GestureNav string `json:"gesture_nav"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
DefaultReadingSpeed int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage string `json:"default_home_page"`
|
||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||
MarkReadOnMediaPlayerCompletion bool `json:"mark_read_on_media_player_completion"`
|
||||
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
|
||||
}
|
||||
|
||||
// UserCreationRequest represents the request to create a user.
|
||||
@@ -49,29 +54,34 @@ type UserCreationRequest struct {
|
||||
|
||||
// UserModificationRequest represents the request to update a user.
|
||||
type UserModificationRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
EntrySwipe *bool `json:"entry_swipe"`
|
||||
GestureNav *string `json:"gesture_nav"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
EntryOrder *string `json:"entry_sorting_order"`
|
||||
Stylesheet *string `json:"stylesheet"`
|
||||
CustomJS *string `json:"custom_js"`
|
||||
ExternalFontHosts *string `json:"external_font_hosts"`
|
||||
GoogleID *string `json:"google_id"`
|
||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||
EntriesPerPage *int `json:"entries_per_page"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||
ShowReadingTime *bool `json:"show_reading_time"`
|
||||
EntrySwipe *bool `json:"entry_swipe"`
|
||||
GestureNav *string `json:"gesture_nav"`
|
||||
DisplayMode *string `json:"display_mode"`
|
||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||
DefaultHomePage *string `json:"default_home_page"`
|
||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||
MarkReadOnMediaPlayerCompletion *bool `json:"mark_read_on_media_player_completion"`
|
||||
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
|
||||
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
|
||||
}
|
||||
|
||||
// Patch updates the User object with the modification request.
|
||||
@@ -112,6 +122,14 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
user.Stylesheet = *u.Stylesheet
|
||||
}
|
||||
|
||||
if u.CustomJS != nil {
|
||||
user.CustomJS = *u.CustomJS
|
||||
}
|
||||
|
||||
if u.ExternalFontHosts != nil {
|
||||
user.ExternalFontHosts = *u.ExternalFontHosts
|
||||
}
|
||||
|
||||
if u.GoogleID != nil {
|
||||
user.GoogleID = *u.GoogleID
|
||||
}
|
||||
@@ -164,9 +182,21 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||
user.MarkReadOnView = *u.MarkReadOnView
|
||||
}
|
||||
|
||||
if u.MarkReadOnMediaPlayerCompletion != nil {
|
||||
user.MarkReadOnMediaPlayerCompletion = *u.MarkReadOnMediaPlayerCompletion
|
||||
}
|
||||
|
||||
if u.MediaPlaybackRate != nil {
|
||||
user.MediaPlaybackRate = *u.MediaPlaybackRate
|
||||
}
|
||||
|
||||
if u.BlockFilterEntryRules != nil {
|
||||
user.BlockFilterEntryRules = *u.BlockFilterEntryRules
|
||||
}
|
||||
|
||||
if u.KeepFilterEntryRules != nil {
|
||||
user.KeepFilterEntryRules = *u.KeepFilterEntryRules
|
||||
}
|
||||
}
|
||||
|
||||
// UseTimezone converts last login date to the given timezone.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
type brotliReadCloser struct {
|
||||
body io.ReadCloser
|
||||
brotliReader io.Reader
|
||||
}
|
||||
|
||||
func NewBrotliReadCloser(body io.ReadCloser) *brotliReadCloser {
|
||||
return &brotliReadCloser{
|
||||
body: body,
|
||||
brotliReader: brotli.NewReader(body),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *brotliReadCloser) Read(p []byte) (n int, err error) {
|
||||
return b.brotliReader.Read(p)
|
||||
}
|
||||
|
||||
func (b *brotliReadCloser) Close() error {
|
||||
return b.body.Close()
|
||||
}
|
||||
|
||||
type gzipReadCloser struct {
|
||||
body io.ReadCloser
|
||||
gzipReader io.Reader
|
||||
gzipErr error
|
||||
}
|
||||
|
||||
func NewGzipReadCloser(body io.ReadCloser) *gzipReadCloser {
|
||||
return &gzipReadCloser{body: body}
|
||||
}
|
||||
|
||||
func (gz *gzipReadCloser) Read(p []byte) (n int, err error) {
|
||||
if gz.gzipReader == nil {
|
||||
if gz.gzipErr == nil {
|
||||
gz.gzipReader, gz.gzipErr = gzip.NewReader(gz.body)
|
||||
}
|
||||
if gz.gzipErr != nil {
|
||||
return 0, gz.gzipErr
|
||||
}
|
||||
}
|
||||
|
||||
return gz.gzipReader.Read(p)
|
||||
}
|
||||
|
||||
func (gz *gzipReadCloser) Close() error {
|
||||
return gz.body.Close()
|
||||
}
|
||||
@@ -109,6 +109,16 @@ func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
|
||||
// We get the safe ciphers
|
||||
ciphers := tls.CipherSuites()
|
||||
if r.ignoreTLSErrors {
|
||||
// and the insecure ones if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
|
||||
ciphers = append(ciphers, tls.InsecureCipherSuites()...)
|
||||
}
|
||||
cipherSuites := []uint16{}
|
||||
for _, cipher := range ciphers {
|
||||
cipherSuites = append(cipherSuites, cipher.ID)
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
|
||||
@@ -128,6 +138,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
IdleConnTimeout: 10 * time.Second,
|
||||
|
||||
TLSClientConfig: &tls.Config{
|
||||
CipherSuites: cipherSuites,
|
||||
InsecureSkipVerify: r.ignoreTLSErrors,
|
||||
},
|
||||
}
|
||||
@@ -169,6 +180,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||
}
|
||||
|
||||
req.Header = r.headers
|
||||
req.Header.Set("Accept-Encoding", "br, gzip")
|
||||
req.Header.Set("Accept", defaultAcceptHeader)
|
||||
req.Header.Set("Connection", "close")
|
||||
|
||||
|
||||
@@ -8,10 +8,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/locale"
|
||||
)
|
||||
@@ -49,17 +53,37 @@ func (r *ResponseHandler) ETag() string {
|
||||
return r.httpResponse.Header.Get("ETag")
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) ParseRetryDelay() int {
|
||||
retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After")
|
||||
if retryAfterHeaderValue != "" {
|
||||
// First, try to parse as an integer (number of seconds)
|
||||
if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {
|
||||
return seconds
|
||||
}
|
||||
|
||||
// If not an integer, try to parse as an HTTP-date
|
||||
if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {
|
||||
return int(time.Until(t).Seconds())
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) IsRateLimited() bool {
|
||||
return r.httpResponse != nil && r.httpResponse.StatusCode == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bool {
|
||||
if r.httpResponse.StatusCode == http.StatusNotModified {
|
||||
return false
|
||||
}
|
||||
|
||||
if r.ETag() != "" && r.ETag() == lastEtagValue {
|
||||
return false
|
||||
if r.ETag() != "" {
|
||||
return r.ETag() != lastEtagValue
|
||||
}
|
||||
|
||||
if r.LastModified() != "" && r.LastModified() == lastModifiedValue {
|
||||
return false
|
||||
if r.LastModified() != "" {
|
||||
return r.LastModified() != lastModifiedValue
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -71,12 +95,31 @@ func (r *ResponseHandler) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {
|
||||
contentEncoding := strings.ToLower(r.httpResponse.Header.Get("Content-Encoding"))
|
||||
slog.Debug("Request response",
|
||||
slog.String("effective_url", r.EffectiveURL()),
|
||||
slog.String("content_length", r.httpResponse.Header.Get("Content-Length")),
|
||||
slog.String("content_encoding", contentEncoding),
|
||||
slog.String("content_type", r.httpResponse.Header.Get("Content-Type")),
|
||||
)
|
||||
|
||||
reader := r.httpResponse.Body
|
||||
switch contentEncoding {
|
||||
case "br":
|
||||
reader = NewBrotliReadCloser(r.httpResponse.Body)
|
||||
case "gzip":
|
||||
reader = NewGzipReadCloser(r.httpResponse.Body)
|
||||
}
|
||||
return http.MaxBytesReader(nil, reader, maxBodySize)
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
|
||||
return http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
|
||||
return r.getReader(maxBodySize)
|
||||
}
|
||||
|
||||
func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.LocalizedErrorWrapper) {
|
||||
limitedReader := http.MaxBytesReader(nil, r.httpResponse.Body, maxBodySize)
|
||||
limitedReader := r.getReader(maxBodySize)
|
||||
|
||||
buffer, err := io.ReadAll(limitedReader)
|
||||
if err != nil && err != io.EOF {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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"
|
||||
"time"
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
// ETag takes precedence per RFC9110 8.8.1.
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelay(t *testing.T) {
|
||||
var testCases = map[string]struct {
|
||||
RetryAfterHeader string
|
||||
ExpectedDelay int
|
||||
}{
|
||||
"Empty header": {
|
||||
RetryAfterHeader: "",
|
||||
ExpectedDelay: 0,
|
||||
},
|
||||
"Integer value": {
|
||||
RetryAfterHeader: "42",
|
||||
ExpectedDelay: 42,
|
||||
},
|
||||
"HTTP-date": {
|
||||
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
|
||||
ExpectedDelay: 41,
|
||||
},
|
||||
}
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(tt *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Add("Retry-After", tc.RetryAfterHeader)
|
||||
rh := ResponseHandler{
|
||||
httpResponse: &http.Response{
|
||||
Header: header,
|
||||
},
|
||||
}
|
||||
if tc.ExpectedDelay != rh.ParseRetryDelay() {
|
||||
tt.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedDelay, rh.ParseRetryDelay(), name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,6 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
slog.String("feed_url", feedCreationRequest.FeedURL),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
@@ -71,7 +66,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
processor.ProcessFeedEntries(store, subscription, user, true)
|
||||
processor.ProcessFeedEntries(store, subscription, userID, true)
|
||||
|
||||
if storeErr := store.CreateFeed(subscription); storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -93,13 +88,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
||||
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
requestBuilder,
|
||||
subscription.ID,
|
||||
subscription.SiteURL,
|
||||
subscription.IconURL,
|
||||
)
|
||||
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
|
||||
|
||||
return subscription, nil
|
||||
}
|
||||
@@ -111,11 +100,6 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
slog.String("feed_url", feedCreationRequest.FeedURL),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) {
|
||||
return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found")
|
||||
}
|
||||
@@ -169,13 +153,14 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
subscription.BlocklistRules = feedCreationRequest.BlocklistRules
|
||||
subscription.KeeplistRules = feedCreationRequest.KeeplistRules
|
||||
subscription.UrlRewriteRules = feedCreationRequest.UrlRewriteRules
|
||||
subscription.HideGlobally = feedCreationRequest.HideGlobally
|
||||
subscription.EtagHeader = responseHandler.ETag()
|
||||
subscription.LastModifiedHeader = responseHandler.LastModified()
|
||||
subscription.FeedURL = responseHandler.EffectiveURL()
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
processor.ProcessFeedEntries(store, subscription, user, true)
|
||||
processor.ProcessFeedEntries(store, subscription, userID, true)
|
||||
|
||||
if storeErr := store.CreateFeed(subscription); storeErr != nil {
|
||||
return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -187,13 +172,8 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
||||
slog.String("feed_url", subscription.FeedURL),
|
||||
)
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
requestBuilder,
|
||||
subscription.ID,
|
||||
subscription.SiteURL,
|
||||
subscription.IconURL,
|
||||
)
|
||||
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
|
||||
|
||||
return subscription, nil
|
||||
}
|
||||
|
||||
@@ -205,11 +185,6 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.Bool("force_refresh", forceRefresh),
|
||||
)
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed, storeErr := store.FeedByID(userID, feedID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
@@ -220,7 +195,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
weeklyEntryCount := 0
|
||||
newTTL := 0
|
||||
refreshDelayInMinutes := 0
|
||||
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
|
||||
var weeklyCountErr error
|
||||
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
|
||||
@@ -230,7 +205,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
originalFeed.CheckedNow()
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
||||
@@ -251,8 +226,25 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if responseHandler.IsRateLimited() {
|
||||
retryDelayInSeconds := responseHandler.ParseRetryDelay()
|
||||
refreshDelayInMinutes = retryDelayInSeconds / 60
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
|
||||
slog.Warn("Feed is rate limited",
|
||||
slog.String("feed_url", originalFeed.FeedURL),
|
||||
slog.Int("retry_delay_in_seconds", retryDelayInSeconds),
|
||||
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
}
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch feed", slog.String("feed_url", originalFeed.FeedURL), slog.Any("error", localizedError.Error()))
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -260,6 +252,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
if store.AnotherFeedURLExists(userID, originalFeed.ID, responseHandler.EffectiveURL()) {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(ErrDuplicatedFeed, "error.duplicated_feed")
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -269,6 +265,8 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
slog.Debug("Feed modified",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("etag_header", originalFeed.EtagHeader),
|
||||
slog.String("last_modified_header", originalFeed.LastModifiedHeader),
|
||||
)
|
||||
|
||||
responseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())
|
||||
@@ -284,6 +282,10 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
if errors.Is(parseErr, parser.ErrFeedFormatNotDetected) {
|
||||
localizedError = locale.NewLocalizedErrorWrapper(parseErr, "error.feed_format_not_detected", parseErr)
|
||||
}
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
@@ -291,24 +293,30 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
}
|
||||
|
||||
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
|
||||
newTTL = updatedFeed.TTL
|
||||
refreshDelayInMinutes = updatedFeed.TTL
|
||||
|
||||
// Set the next check at with updated arguments.
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
|
||||
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
|
||||
|
||||
slog.Debug("Updated next check date",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.Int("ttl", newTTL),
|
||||
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
|
||||
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
|
||||
)
|
||||
|
||||
originalFeed.Entries = updatedFeed.Entries
|
||||
processor.ProcessFeedEntries(store, originalFeed, user, forceRefresh)
|
||||
processor.ProcessFeedEntries(store, originalFeed, userID, forceRefresh)
|
||||
|
||||
// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
|
||||
updateExistingEntries := forceRefresh || !originalFeed.Crawler
|
||||
newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
|
||||
if storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -325,29 +333,37 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
go integration.PushEntries(originalFeed, newEntries, userIntegrations)
|
||||
}
|
||||
|
||||
// We update caching headers only if the feed has been modified,
|
||||
// because some websites don't return the same headers when replying with a 304.
|
||||
originalFeed.EtagHeader = responseHandler.ETag()
|
||||
originalFeed.LastModifiedHeader = responseHandler.LastModified()
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
requestBuilder,
|
||||
originalFeed.ID,
|
||||
originalFeed.SiteURL,
|
||||
updatedFeed.IconURL,
|
||||
)
|
||||
originalFeed.IconURL = updatedFeed.IconURL
|
||||
iconChecker := icon.NewIconChecker(store, originalFeed)
|
||||
if forceRefresh {
|
||||
iconChecker.UpdateOrCreateFeedIcon()
|
||||
} else {
|
||||
iconChecker.CreateFeedIconIfMissing()
|
||||
}
|
||||
} else {
|
||||
slog.Debug("Feed not modified",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int64("feed_id", feedID),
|
||||
)
|
||||
|
||||
// Last-Modified may be updated even if ETag is not. In this case, per
|
||||
// RFC9111 sections 3.2 and 4.3.4, the stored response must be updated.
|
||||
if responseHandler.LastModified() != "" {
|
||||
originalFeed.LastModifiedHeader = responseHandler.LastModified()
|
||||
}
|
||||
}
|
||||
|
||||
originalFeed.ResetErrorCounter()
|
||||
|
||||
if storeErr := store.UpdateFeed(originalFeed); storeErr != nil {
|
||||
localizedError := locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
return locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr)
|
||||
}
|
||||
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
|
||||
store.UpdateFeedError(originalFeed)
|
||||
return localizedError
|
||||
@@ -355,32 +371,3 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkFeedIcon(store *storage.Storage, requestBuilder *fetcher.RequestBuilder, feedID int64, websiteURL, feedIconURL string) {
|
||||
if !store.HasIcon(feedID) {
|
||||
iconFinder := icon.NewIconFinder(requestBuilder, websiteURL, feedIconURL)
|
||||
if icon, err := iconFinder.FindIcon(); err != nil {
|
||||
slog.Debug("Unable to find feed icon",
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("feed_icon_url", feedIconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon == nil {
|
||||
slog.Debug("No icon found",
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("feed_icon_url", feedIconURL),
|
||||
)
|
||||
} else {
|
||||
if err := store.CreateFeedIcon(feedID, icon); err != nil {
|
||||
slog.Error("Unable to store feed icon",
|
||||
slog.Int64("feed_id", feedID),
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("feed_icon_url", feedIconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
type IconChecker struct {
|
||||
store *storage.Storage
|
||||
feed *model.Feed
|
||||
}
|
||||
|
||||
func NewIconChecker(store *storage.Storage, feed *model.Feed) *IconChecker {
|
||||
return &IconChecker{
|
||||
store: store,
|
||||
feed: feed,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IconChecker) fetchAndStoreIcon() {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
requestBuilder.WithCookie(c.feed.Cookie)
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(c.feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
|
||||
|
||||
iconFinder := NewIconFinder(requestBuilder, c.feed.SiteURL, c.feed.IconURL)
|
||||
if icon, err := iconFinder.FindIcon(); err != nil {
|
||||
slog.Debug("Unable to find feed icon",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if icon == nil {
|
||||
slog.Debug("No icon found",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
)
|
||||
} else {
|
||||
if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
|
||||
slog.Error("Unable to store feed icon",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Feed icon stored",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
slog.String("website_url", c.feed.SiteURL),
|
||||
slog.String("feed_icon_url", c.feed.IconURL),
|
||||
slog.Int64("icon_id", icon.ID),
|
||||
slog.String("icon_hash", icon.Hash),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IconChecker) CreateFeedIconIfMissing() {
|
||||
if c.store.HasFeedIcon(c.feed.ID) {
|
||||
slog.Debug("Feed icon already exists",
|
||||
slog.Int64("feed_id", c.feed.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
c.fetchAndStoreIcon()
|
||||
}
|
||||
|
||||
func (c *IconChecker) UpdateOrCreateFeedIcon() {
|
||||
c.fetchAndStoreIcon()
|
||||
}
|
||||
@@ -4,12 +4,18 @@
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
@@ -19,6 +25,7 @@ import (
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
@@ -180,9 +187,59 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) {
|
||||
Content: responseBody,
|
||||
}
|
||||
|
||||
icon = resizeIcon(icon)
|
||||
|
||||
return icon, nil
|
||||
}
|
||||
|
||||
func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
r := bytes.NewReader(icon.Content)
|
||||
|
||||
if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif"}, icon.MimeType) {
|
||||
slog.Info("icon isn't a png/gif/jpeg/ico, can't resize", slog.String("mimetype", icon.MimeType))
|
||||
return icon
|
||||
}
|
||||
|
||||
// Don't resize icons that we can't decode, or that already have the right size.
|
||||
config, _, err := image.DecodeConfig(r)
|
||||
if err != nil {
|
||||
slog.Warn("unable to decode the metadata of the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
if config.Height <= 32 && config.Width <= 32 {
|
||||
slog.Debug("icon don't need to be rescaled", slog.Int("height", config.Height), slog.Int("width", config.Width))
|
||||
return icon
|
||||
}
|
||||
|
||||
r.Seek(0, io.SeekStart)
|
||||
|
||||
var src image.Image
|
||||
switch icon.MimeType {
|
||||
case "image/jpeg":
|
||||
src, err = jpeg.Decode(r)
|
||||
case "image/png":
|
||||
src, err = png.Decode(r)
|
||||
case "image/gif":
|
||||
src, err = gif.Decode(r)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("unable to decode the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, 32, 32))
|
||||
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
|
||||
|
||||
var b bytes.Buffer
|
||||
if err = png.Encode(io.Writer(&b), dst); err != nil {
|
||||
slog.Warn("unable to encode the new icon", slog.Any("error", err))
|
||||
}
|
||||
|
||||
icon.Content = b.Bytes()
|
||||
icon.MimeType = "image/png"
|
||||
return icon
|
||||
}
|
||||
|
||||
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
|
||||
queries := []string{
|
||||
"link[rel='icon' i]",
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
package icon // import "miniflux.app/v2/internal/reader/icon"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func TestParseImageDataURL(t *testing.T) {
|
||||
@@ -125,3 +130,52 @@ func TestParseDocumentWithWhitespaceIconURL(t *testing.T) {
|
||||
t.Errorf(`Invalid icon URL, got %q`, iconURLs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeIconSmallGif(t *testing.T) {
|
||||
data, err := base64.StdEncoding.DecodeString("R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := model.Icon{
|
||||
Content: data,
|
||||
MimeType: "image/gif",
|
||||
}
|
||||
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
|
||||
t.Fatalf("Converted gif smaller than 16x16")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeIconPng(t *testing.T) {
|
||||
data, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAACEAAAAhCAYAAABX5MJvAAAALUlEQVR42u3OMQEAAAgDoJnc6BpjDyRgcrcpGwkJCQkJCQkJCQkJCQkJCYmyB7NfUj/Kk4FkAAAAAElFTkSuQmCC")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
icon := model.Icon{
|
||||
Content: data,
|
||||
MimeType: "image/png",
|
||||
}
|
||||
resizedIcon := resizeIcon(&icon)
|
||||
|
||||
if bytes.Equal(data, resizedIcon.Content) {
|
||||
t.Fatalf("Didn't convert png of 33x33")
|
||||
}
|
||||
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(resizedIcon.Content))
|
||||
if err != nil {
|
||||
t.Fatalf("Couln't decode resulting png: %v", err)
|
||||
}
|
||||
|
||||
if config.Height != 32 || config.Width != 32 {
|
||||
t.Fatalf("Was expecting an image of 16x16, got %dx%d", config.Width, config.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeInvalidImage(t *testing.T) {
|
||||
icon := model.Icon{
|
||||
Content: []byte("invalid data"),
|
||||
MimeType: "image/gif",
|
||||
}
|
||||
if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) {
|
||||
t.Fatalf("Tried to convert an invalid image")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func (h *Handler) Export(userID int64) (string, error) {
|
||||
Title: feed.Title,
|
||||
FeedURL: feed.FeedURL,
|
||||
SiteURL: feed.SiteURL,
|
||||
Description: feed.Description,
|
||||
CategoryName: feed.Category.Title,
|
||||
})
|
||||
}
|
||||
@@ -68,11 +69,12 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
|
||||
}
|
||||
|
||||
feed := &model.Feed{
|
||||
UserID: userID,
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Category: category,
|
||||
UserID: userID,
|
||||
Title: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
h.store.CreateFeed(feed)
|
||||
|
||||
@@ -27,11 +27,12 @@ type opmlHeader struct {
|
||||
}
|
||||
|
||||
type opmlOutline struct {
|
||||
Title string `xml:"title,attr,omitempty"`
|
||||
Text string `xml:"text,attr"`
|
||||
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
Title string `xml:"title,attr,omitempty"`
|
||||
Text string `xml:"text,attr"`
|
||||
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Description string `xml:"description,attr,omitempty"`
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
|
||||
@@ -34,6 +34,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
|
||||
Title: outline.GetTitle(),
|
||||
FeedURL: outline.FeedURL,
|
||||
SiteURL: outline.GetSiteURL(),
|
||||
Description: outline.Description,
|
||||
CategoryName: category,
|
||||
})
|
||||
} else if outline.Outlines.HasChildren() {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
|
||||
`
|
||||
|
||||
var expected SubcriptionList
|
||||
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/"})
|
||||
expected = append(expected, &Subcription{Title: "CNET News.com", FeedURL: "http://news.com.com/2547-1_3-0-5.xml", SiteURL: "http://news.com.com/", Description: "Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media."})
|
||||
|
||||
subscriptions, err := Parse(bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
|
||||
@@ -48,10 +48,11 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
|
||||
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
|
||||
for _, subscription := range groupedSubs[categoryName] {
|
||||
category.Outlines = append(category.Outlines, opmlOutline{
|
||||
Title: subscription.Title,
|
||||
Text: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Title: subscription.Title,
|
||||
Text: subscription.Title,
|
||||
FeedURL: subscription.FeedURL,
|
||||
SiteURL: subscription.SiteURL,
|
||||
Description: subscription.Description,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ type Subcription struct {
|
||||
SiteURL string
|
||||
FeedURL string
|
||||
CategoryName string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Equals compare two subscriptions.
|
||||
func (s Subcription) Equals(subscription *Subcription) bool {
|
||||
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
|
||||
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName
|
||||
s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
|
||||
s.Description == subscription.Description
|
||||
}
|
||||
|
||||
// SubcriptionList is a list of subscriptions.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var (
|
||||
bilibiliURLRegex = regexp.MustCompile(`bilibili\.com/video/(.*)$`)
|
||||
bilibiliVideoIdRegex = regexp.MustCompile(`/video/(?:av(\d+)|BV([a-zA-Z0-9]+))`)
|
||||
)
|
||||
|
||||
func shouldFetchBilibiliWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchBilibiliWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := bilibiliURLRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesBilibiliPattern := len(matches) == 2
|
||||
return urlMatchesBilibiliPattern
|
||||
}
|
||||
|
||||
func extractBilibiliVideoID(websiteURL string) (string, string, error) {
|
||||
matches := bilibiliVideoIdRegex.FindStringSubmatch(websiteURL)
|
||||
if matches == nil {
|
||||
return "", "", fmt.Errorf("no video ID found in URL: %s", websiteURL)
|
||||
}
|
||||
if matches[1] != "" {
|
||||
return "aid", matches[1], nil
|
||||
}
|
||||
if matches[2] != "" {
|
||||
return "bvid", matches[2], nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unexpected regex match result for URL: %s", websiteURL)
|
||||
}
|
||||
|
||||
func fetchBilibiliWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
idType, videoID, extractErr := extractBilibiliVideoID(websiteURL)
|
||||
if extractErr != nil {
|
||||
return 0, extractErr
|
||||
}
|
||||
bilibiliApiURL := fmt.Sprintf("https://api.bilibili.com/x/web-interface/view?%s=%s", idType, videoID)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(bilibiliApiURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Bilibili API",
|
||||
slog.String("website_url", websiteURL),
|
||||
slog.String("api_url", bilibiliApiURL),
|
||||
slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
doc := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr := doc.Decode(&result); docErr != nil {
|
||||
return 0, fmt.Errorf("failed to decode API response: %v", docErr)
|
||||
}
|
||||
|
||||
if code, ok := result["code"].(float64); !ok || code != 0 {
|
||||
return 0, fmt.Errorf("API returned error code: %v", result["code"])
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("data field not found or not an object")
|
||||
}
|
||||
|
||||
duration, ok := data["duration"].(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("duration not found or not a number")
|
||||
}
|
||||
intDuration := int(duration)
|
||||
durationMin := intDuration / 60
|
||||
if intDuration%60 != 0 {
|
||||
durationMin++
|
||||
}
|
||||
return durationMin, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchNebulaWatchTime() {
|
||||
return false
|
||||
}
|
||||
|
||||
u, err := url.Parse(entry.URL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Hostname() == "nebula.tv"
|
||||
}
|
||||
|
||||
func fetchNebulaWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="video:duration"]`)).Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := strconv.ParseInt(durs, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur / 60), nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
}
|
||||
|
||||
u, err := url.Parse(entry.URL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Hostname() == "odysee.com"
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Odysee watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[property="og:video:duration"]`)).Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := strconv.ParseInt(durs, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur / 60), nil
|
||||
}
|
||||
@@ -4,14 +4,15 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/metric"
|
||||
"miniflux.app/v2/internal/model"
|
||||
@@ -20,46 +21,57 @@ import (
|
||||
"miniflux.app/v2/internal/reader/rewrite"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/reader/scraper"
|
||||
"miniflux.app/v2/internal/reader/urlcleaner"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
|
||||
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
)
|
||||
var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("([^"]+)"\|"([^"]+)"\)`)
|
||||
|
||||
// ProcessFeedEntries downloads original web page for entries and apply filters.
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
|
||||
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64, forceRefresh bool) {
|
||||
var filteredEntries model.Entries
|
||||
|
||||
user, storeErr := store.UserByID(userID)
|
||||
if storeErr != nil {
|
||||
slog.Error("Database error", slog.Any("error", storeErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Process older entries first
|
||||
for i := len(feed.Entries) - 1; i >= 0; i-- {
|
||||
entry := feed.Entries[i]
|
||||
|
||||
slog.Debug("Processing entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("entry_hash", entry.Hash),
|
||||
slog.String("entry_title", entry.Title),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
)
|
||||
if isBlockedEntry(feed, entry) || !isAllowedEntry(feed, entry) || !isRecentEntry(entry) {
|
||||
if isBlockedEntry(feed, entry, user) || !isAllowedEntry(feed, entry, user) || !isRecentEntry(entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
websiteURL := getUrlFromEntry(feed, entry)
|
||||
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL)
|
||||
if cleanedURL, err := urlcleaner.RemoveTrackingParameters(entry.URL); err == nil {
|
||||
entry.URL = cleanedURL
|
||||
}
|
||||
|
||||
pageBaseURL := ""
|
||||
rewrittenURL := rewriteEntryURL(feed, entry)
|
||||
entry.URL = rewrittenURL
|
||||
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
|
||||
if feed.Crawler && (entryIsNew || forceRefresh) {
|
||||
slog.Debug("Scraping entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.String("entry_hash", entry.Hash),
|
||||
slog.String("entry_title", entry.Title),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Bool("entry_is_new", entryIsNew),
|
||||
slog.Bool("force_refresh", forceRefresh),
|
||||
slog.String("rewritten_url", rewrittenURL),
|
||||
)
|
||||
|
||||
startTime := time.Now()
|
||||
@@ -73,12 +85,16 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
content, scraperErr := scraper.ScrapeWebsite(
|
||||
scrapedPageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
websiteURL,
|
||||
rewrittenURL,
|
||||
feed.ScraperRules,
|
||||
)
|
||||
|
||||
if scrapedPageBaseURL != "" {
|
||||
pageBaseURL = scrapedPageBaseURL
|
||||
}
|
||||
|
||||
if config.Opts.HasMetricsCollector() {
|
||||
status := "success"
|
||||
if scraperErr != nil {
|
||||
@@ -90,22 +106,25 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
if scraperErr != nil {
|
||||
slog.Warn("Unable to scrape entry",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", scraperErr),
|
||||
)
|
||||
} else if content != "" {
|
||||
} else if extractedContent != "" {
|
||||
// We replace the entry content only if the scraper doesn't return any error.
|
||||
entry.Content = content
|
||||
entry.Content = minifyEntryContent(extractedContent)
|
||||
}
|
||||
}
|
||||
|
||||
rewrite.Rewriter(websiteURL, entry, feed.RewriteRules)
|
||||
rewrite.Rewriter(rewrittenURL, entry, feed.RewriteRules)
|
||||
|
||||
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered.
|
||||
entry.Content = sanitizer.Sanitize(websiteURL, entry.Content)
|
||||
if pageBaseURL == "" {
|
||||
pageBaseURL = rewrittenURL
|
||||
}
|
||||
|
||||
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered out.
|
||||
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
|
||||
|
||||
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
@@ -114,7 +133,49 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
||||
feed.Entries = filteredEntries
|
||||
}
|
||||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.BlockFilterEntryRules != "" {
|
||||
rules := strings.Split(user.BlockFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if feed.BlocklistRules == "" {
|
||||
return false
|
||||
}
|
||||
@@ -134,7 +195,6 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
|
||||
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
@@ -146,7 +206,50 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.KeepFilterEntryRules != "" {
|
||||
rules := strings.Split(user.KeepFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Allowing entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if feed.KeeplistRules == "" {
|
||||
return true
|
||||
}
|
||||
@@ -165,7 +268,6 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
|
||||
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
@@ -179,7 +281,7 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
|
||||
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
|
||||
startTime := time.Now()
|
||||
websiteURL := getUrlFromEntry(feed, entry)
|
||||
rewrittenEntryURL := rewriteEntryURL(feed, entry)
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||
@@ -190,9 +292,9 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
content, scraperErr := scraper.ScrapeWebsite(
|
||||
pageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
websiteURL,
|
||||
rewrittenEntryURL,
|
||||
feed.ScraperRules,
|
||||
)
|
||||
|
||||
@@ -208,49 +310,60 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
||||
return scraperErr
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
entry.Content = content
|
||||
if extractedContent != "" {
|
||||
entry.Content = minifyEntryContent(extractedContent)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
rewrite.Rewriter(websiteURL, entry, entry.Feed.RewriteRules)
|
||||
entry.Content = sanitizer.Sanitize(websiteURL, entry.Content)
|
||||
rewrite.Rewriter(rewrittenEntryURL, entry, entry.Feed.RewriteRules)
|
||||
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUrlFromEntry(feed *model.Feed, entry *model.Entry) string {
|
||||
var url = entry.URL
|
||||
func rewriteEntryURL(feed *model.Feed, entry *model.Entry) string {
|
||||
var rewrittenURL = entry.URL
|
||||
if feed.UrlRewriteRules != "" {
|
||||
parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
|
||||
|
||||
if len(parts) >= 3 {
|
||||
re := regexp.MustCompile(parts[1])
|
||||
url = re.ReplaceAllString(entry.URL, parts[2])
|
||||
re, err := regexp.Compile(parts[1])
|
||||
if err != nil {
|
||||
slog.Error("Failed on regexp compilation",
|
||||
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return rewrittenURL
|
||||
}
|
||||
rewrittenURL = re.ReplaceAllString(entry.URL, parts[2])
|
||||
slog.Debug("Rewriting entry URL",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("original_entry_url", entry.URL),
|
||||
slog.String("rewritten_entry_url", url),
|
||||
slog.String("rewritten_entry_url", rewrittenURL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Cannot find search and replace terms for replace rule",
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("original_entry_url", entry.URL),
|
||||
slog.String("rewritten_entry_url", url),
|
||||
slog.String("rewritten_entry_url", rewrittenURL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("url_rewrite_rules", feed.UrlRewriteRules),
|
||||
)
|
||||
}
|
||||
}
|
||||
return url
|
||||
|
||||
return rewrittenURL
|
||||
}
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldFetchYouTubeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchYouTubeWatchTime(entry.URL)
|
||||
@@ -266,7 +379,26 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(entry, feed)
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchNebulaWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchNebulaWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,137 +417,95 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(entry, feed)
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchBilibiliWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchBilibiliWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Bilibili watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YT error case and non-YT entries.
|
||||
if entry.ReadingTime == 0 {
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchOdyseeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := odyseeRegex.FindStringSubmatch(entry.URL)
|
||||
return matches != nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch YouTube page", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchOdyseeWatchTime(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch Odysee watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.Find(`meta[property="og:video:duration"]`).First().Attr("content")
|
||||
// durs contains video watch time in seconds
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := strconv.ParseInt(durs, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur / 60), nil
|
||||
}
|
||||
|
||||
// parseISO8601 parses an ISO 8601 duration string.
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
part := match[i]
|
||||
if i == 0 || name == "" || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func isRecentEntry(entry *model.Entry) bool {
|
||||
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func minifyEntryContent(entryContent string) string {
|
||||
m := minify.New()
|
||||
|
||||
// Options required to avoid breaking the HTML content.
|
||||
m.Add("text/html", &html.Minifier{
|
||||
KeepEndTags: true,
|
||||
KeepQuotes: true,
|
||||
})
|
||||
|
||||
if minifiedHTML, err := m.String("text/html", entryContent); err == nil {
|
||||
entryContent = minifiedHTML
|
||||
}
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
|
||||
if pattern == "future" {
|
||||
return entryDate.After(time.Now())
|
||||
}
|
||||
|
||||
parts := strings.SplitN(pattern, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
operator := parts[0]
|
||||
dateStr := parts[1]
|
||||
|
||||
switch operator {
|
||||
case "before":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.Before(targetDate)
|
||||
case "after":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(targetDate)
|
||||
case "between":
|
||||
dates := strings.Split(dateStr, ",")
|
||||
if len(dates) != 2 {
|
||||
return false
|
||||
}
|
||||
startDate, err1 := time.Parse("2006-01-02", dates[0])
|
||||
endDate, err2 := time.Parse("2006-01-02", dates[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(startDate) && entryDate.Before(endDate)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -15,23 +15,33 @@ func TestBlockingEntries(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
feed *model.Feed
|
||||
entry *model.Entry
|
||||
user *model.User
|
||||
expected bool
|
||||
}{
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://example.com"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://different.com"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"something different", "something else"}}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://example.com"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{URL: "https://different.com"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"something different", "something else"}}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://example.com", Title: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Test"}, &model.User{BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://example.com", Content: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Test"}, &model.User{BlockFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Example"}, &model.User{BlockFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result := isBlockedEntry(tc.feed, tc.entry)
|
||||
result := isBlockedEntry(tc.feed, tc.entry, tc.user)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for entry %q`, result, tc.entry.Title)
|
||||
}
|
||||
@@ -42,58 +52,45 @@ func TestAllowEntries(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
feed *model.Feed
|
||||
entry *model.Entry
|
||||
user *model.User
|
||||
expected bool
|
||||
}{
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://example.com"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://different.com"}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something more", Tags: []string{"something different", "something else"}}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://example.com"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "https://different.com"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Some Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1}, &model.Entry{Title: "No rule defined"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"example", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Example", Tags: []string{"something different", "something else"}}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something more", Tags: []string{"something different", "something else"}}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Example"}, &model.User{}, true},
|
||||
{&model.Feed{ID: 1, KeeplistRules: "(?i)example"}, &model.Entry{Title: "Something different", Author: "Something different"}, &model.User{}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://example.com", Title: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Test"}, &model.User{KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{URL: "https://different.com", Title: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://example.com", Content: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Test"}, &model.User{KeepFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{CommentsURL: "https://different.com", Content: "Some Example"}, &model.User{KeepFilterEntryRules: "EntryCommentsURL=(?i)example\nEntryContent=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(-24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, false},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=before:2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 16, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=after:2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, true},
|
||||
{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result := isAllowedEntry(tc.feed, tc.entry)
|
||||
result := isAllowedEntry(tc.feed, tc.entry, tc.user)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for entry %q`, result, tc.entry.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseISO8601(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
duration string
|
||||
expected time.Duration
|
||||
}{
|
||||
// Live streams and radio.
|
||||
{"PT0M0S", 0},
|
||||
// https://www.youtube.com/watch?v=HLrqNhgdiC0
|
||||
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=LZa5KKfqHtA
|
||||
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=yIxEEgEuhT4
|
||||
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=bpHf1XcoiFs
|
||||
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result, err := parseISO8601(tc.duration)
|
||||
if err != nil {
|
||||
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
|
||||
}
|
||||
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRecentEntry(t *testing.T) {
|
||||
parser := config.NewParser()
|
||||
var err error
|
||||
@@ -117,3 +114,12 @@ func TestIsRecentEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinifyEntryContent(t *testing.T) {
|
||||
input := `<p> Some text with a <a href="http://example.org/"> link </a> </p>`
|
||||
expected := `<p>Some text with a <a href="http://example.org/">link</a></p>`
|
||||
result := minifyEntryContent(input)
|
||||
if expected != result {
|
||||
t.Errorf(`Unexpected result, got %q`, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
|
||||
)
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
if config.Opts.YouTubeApiKey() == "" {
|
||||
return fetchYouTubeWatchTimeFromWebsite(websiteURL)
|
||||
} else {
|
||||
return fetchYouTubeWatchTimeFromApi(websiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch YouTube page", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
|
||||
if docErr != nil {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse URL: %v", err)
|
||||
}
|
||||
|
||||
apiQuery := url.Values{}
|
||||
apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
|
||||
apiQuery.Set("key", config.Opts.YouTubeApiKey())
|
||||
apiQuery.Set("part", "contentDetails")
|
||||
|
||||
apiURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.googleapis.com",
|
||||
Path: "youtube/v3/videos",
|
||||
RawQuery: apiQuery.Encode(),
|
||||
}
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var videos struct {
|
||||
Items []struct {
|
||||
ContentDetails struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"contentDetails"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
|
||||
return 0, fmt.Errorf("unable to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if n := len(videos.Items); n != 1 {
|
||||
return 0, fmt.Errorf("invalid items length: %d", n)
|
||||
}
|
||||
|
||||
durs := videos.Items[0].ContentDetails.Duration
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
}
|
||||
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
var match []string
|
||||
var d time.Duration
|
||||
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
part := match[i]
|
||||
if i == 0 || name == "" || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseISO8601(t *testing.T) {
|
||||
var scenarios = []struct {
|
||||
duration string
|
||||
expected time.Duration
|
||||
}{
|
||||
// Live streams and radio.
|
||||
{"PT0M0S", 0},
|
||||
// https://www.youtube.com/watch?v=HLrqNhgdiC0
|
||||
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=LZa5KKfqHtA
|
||||
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=yIxEEgEuhT4
|
||||
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
|
||||
// https://www.youtube.com/watch?v=bpHf1XcoiFs
|
||||
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
|
||||
}
|
||||
|
||||
for _, tc := range scenarios {
|
||||
result, err := parseISO8601(tc.duration)
|
||||
if err != nil {
|
||||
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
|
||||
}
|
||||
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user