mirror of https://github.com/miniflux/v2.git
Compare commits
143 Commits
Author | SHA1 | Date |
---|---|---|
dependabot[bot] | f0e8323f19 | |
dependabot[bot] | a0106c9ffc | |
emv33 | f98d5de484 | |
JohnnyJayJay | ee5e18ea9f | |
dependabot[bot] | 3ef2522c62 | |
dependabot[bot] | 839c4ad044 | |
Ztec | 9f3a8e7f1b | |
Ztec | e54825bf02 | |
Ztec | 07f6d397d4 | |
Ztec | f33e76eb8c | |
dependabot[bot] | 84e97826d8 | |
x | 839fc3843a | |
x | 0bab8fac8e | |
dependabot[bot] | 0cf1a40276 | |
dependabot[bot] | 91479bc0ee | |
dependabot[bot] | 251821289c | |
dependabot[bot] | cac0bc682f | |
dependabot[bot] | a733c14c61 | |
Ankit Pandey | b68b05c64c | |
Frédéric Guillot | 5ce3f24838 | |
dependabot[bot] | 48ddc02ba8 | |
dependabot[bot] | fe9f1bba16 | |
Krish Mamtora | 740fa4a5d2 | |
dependabot[bot] | 8a38f54ef5 | |
Zhizhen He | ae432bc9c6 | |
dependabot[bot] | 96f7e8bae0 | |
rootknight | 1f35ed1675 | |
dependabot[bot] | d6deac1810 | |
Frédéric Guillot | b692768730 | |
dependabot[bot] | 2178580a75 | |
dependabot[bot] | b52f61cc77 | |
dependabot[bot] | 3388f8e376 | |
dependabot[bot] | 83ceb20c1c | |
dependabot[bot] | c06850ca34 | |
dependabot[bot] | d856c02fbb | |
Jan-Lukas Else | a33b1adf13 | |
fin444 | a631bd527d | |
Alpha Chen | ca62b0b36b | |
Kioubit | 7d6a4243c1 | |
dependabot[bot] | d056aa1f73 | |
dependabot[bot] | 018e24404e | |
Frédéric Guillot | 4d3ee0d15d | |
Frédéric Guillot | 797450986b | |
Ztec | 93bc9ce24d | |
dependabot[bot] | 9233568da3 | |
Frédéric Guillot | fb075b60b5 | |
Frédéric Guillot | 2c4c845cd2 | |
bo0tzz | 2caabbe939 | |
Frédéric Guillot | 771f9d2b5f | |
Romain de Laage | 647c66e70a | |
jvoisin | b205b5aad0 | |
goodfirm | 4ab0d9422d | |
Frédéric Guillot | 38b80d96ea | |
Michael Kuhn | 35edd8ea92 | |
Alexandros Kosiaris | f0cb041885 | |
Frédéric Guillot | fdd1b3f18e | |
Frédéric Guillot | 6e870cdccc | |
Michael Kuhn | 194f517be8 | |
dependabot[bot] | 11fd1c935e | |
dependabot[bot] | 47e1111908 | |
dependabot[bot] | c5b812eb7b | |
dependabot[bot] | 53be550e8a | |
dependabot[bot] | d0d693a6ef | |
Evan Elias Young | 1b8c45d162 | |
jvoisin | 19ce519836 | |
Thomas J Faughnan Jr | 3e0d5de7a3 | |
Frédéric Guillot | 0336774e8c | |
Jean Khawand | 756dd449cc | |
Taylan Tatlı | a0b4665080 | |
dependabot[bot] | 6592c1ad6b | |
jvoisin | f109e3207c | |
Romain de Laage | b54fe66809 | |
jvoisin | 93c9d43497 | |
Frédéric Guillot | e3b3c40c28 | |
Frédéric Guillot | 068790fc19 | |
Frédéric Guillot | 41d99c517f | |
Frédéric Guillot | 3db3f9884f | |
Frédéric Guillot | ad1d349a0c | |
Jean Khawand | 7ee4a731af | |
Jean Khawand | 3c822a45ac | |
Frédéric Guillot | c2311e316c | |
jvoisin | ed20771194 | |
jvoisin | beb8c80787 | |
jvoisin | fc4bdf3ab0 | |
Frédéric Guillot | 6bc819e198 | |
Frédéric Guillot | 08640b27d5 | |
jvoisin | 4be993e055 | |
jvoisin | 9df12177eb | |
Jean Khawand | a78d1c79da | |
Matt Behrens | 1ea3953271 | |
dependabot[bot] | fe8b7a907e | |
Frédéric Guillot | a15cdb1655 | |
Frédéric Guillot | fa9697b972 | |
jvoisin | 8e28e41b02 | |
jvoisin | e2ee74428a | |
jvoisin | 863a5b3648 | |
jvoisin | 91f5522ce0 | |
Frédéric Guillot | 8212f16aa2 | |
Frédéric Guillot | b1e73fafdf | |
Frédéric Guillot | f6404290ba | |
jvoisin | c29ca0e313 | |
jvoisin | 02a074ed26 | |
Romain de Laage | 00dabc1d3c | |
Frédéric Guillot | b68ada396a | |
Frédéric Guillot | e299e821a6 | |
Frédéric Guillot | 0f17dfc7d6 | |
Frédéric Guillot | 7c80d6b86d | |
Frédéric Guillot | f6f63b5282 | |
Frédéric Guillot | 309fdbb9fc | |
Frédéric Guillot | e2d862f2f6 | |
Frédéric Guillot | 4834e934f2 | |
Frédéric Guillot | dd4fb660c1 | |
jvoisin | 2ba893bc79 | |
Frédéric Guillot | 7a307f8e74 | |
jvoisin | 7310e13499 | |
dependabot[bot] | bf6d286735 | |
Frédéric Guillot | ca919c2ff8 | |
Frédéric Guillot | 5948786b15 | |
jvoisin | f4746a7306 | |
Frédéric Guillot | 648b9a8f6f | |
jvoisin | 66b8483791 | |
jvoisin | e0ee28c013 | |
dependabot[bot] | d862d86f90 | |
jvoisin | d25c032171 | |
Frédéric Guillot | 8429c6b0ab | |
Frédéric Guillot | 6bc4b35e38 | |
mcnesium | ee3486af66 | |
jvoisin | 45d486b919 | |
dependabot[bot] | 688b73b7ae | |
Frédéric Guillot | 6d97f8b458 | |
Frédéric Guillot | f8e50947f2 | |
Frédéric Guillot | 9a637ce95e | |
Frédéric Guillot | d3a85b049b | |
jvoisin | 5bcb37901c | |
jvoisin | 9c8a7dfffe | |
jvoisin | 74e4032ffc | |
jvoisin | fd1fee852c | |
Frédéric Guillot | c51a3270da | |
Frédéric Guillot | 45fa641d26 | |
jvoisin | fd8f25916b | |
jvoisin | 826e4d654f | |
jvoisin | d9d17f0d69 | |
Frédéric Guillot | eaaeb68474 |
|
@ -1,7 +1,7 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mcr.microsoft.com/devcontainers/go
|
image: mcr.microsoft.com/devcontainers/go:1.22
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace:cached
|
- ..:/workspace:cached
|
||||||
command: sleep infinity
|
command: sleep infinity
|
||||||
|
@ -24,7 +24,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
apprise:
|
apprise:
|
||||||
image: caronc/apprise:latest
|
image: caronc/apprise:1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
hostname: apprise
|
hostname: apprise
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
Do you follow the guidelines?
|
Do you follow the guidelines?
|
||||||
|
|
||||||
- [ ] I have tested my changes
|
- [ ] I have tested my changes
|
||||||
|
- [ ] There is no breaking changes
|
||||||
|
- [ ] I really tested my changes and there is no regression
|
||||||
|
- [ ] Ideally, my commit messages use the same convention as the Go project: https://go.dev/doc/contribute#commit_messages
|
||||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||||
|
|
|
@ -12,7 +12,8 @@ jobs:
|
||||||
- name: Set up Golang
|
- name: Set up Golang
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.22.x"
|
||||||
|
check-latest: true
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Compile binaries
|
- name: Compile binaries
|
||||||
|
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.22.x"
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
publish-packages:
|
publish-packages:
|
||||||
if: github.event.push
|
if: github.event_name == 'push'
|
||||||
name: Publish Packages
|
name: Publish Packages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -8,35 +8,8 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
jobs:
|
jobs:
|
||||||
test-docker-images:
|
docker-images:
|
||||||
if: github.event.pull_request
|
name: Docker Images
|
||||||
name: Test Images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Build Alpine image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./packaging/docker/alpine/Dockerfile
|
|
||||||
push: false
|
|
||||||
tags: ${{ github.repository_owner }}/miniflux:alpine-dev
|
|
||||||
- name: Test Alpine Docker image
|
|
||||||
run: docker run --rm ${{ github.repository_owner }}/miniflux:alpine-dev miniflux -i
|
|
||||||
- name: Build Distroless image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./packaging/docker/distroless/Dockerfile
|
|
||||||
push: false
|
|
||||||
tags: ${{ github.repository_owner }}/miniflux:distroless-dev
|
|
||||||
- name: Test Distroless Docker image
|
|
||||||
run: docker run --rm ${{ github.repository_owner }}/miniflux:distroless-dev miniflux -i
|
|
||||||
|
|
||||||
publish-docker-images:
|
|
||||||
if: ${{ ! github.event.pull_request }}
|
|
||||||
name: Publish Images
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -46,33 +19,33 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate Alpine Docker tag
|
- name: Generate Alpine Docker tags
|
||||||
id: docker_alpine_tag
|
id: docker_alpine_tags
|
||||||
run: |
|
uses: docker/metadata-action@v5
|
||||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
with:
|
||||||
DOCKER_VERSION=dev
|
images: |
|
||||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
docker.io/${{ github.repository_owner }}/miniflux
|
||||||
DOCKER_VERSION=nightly
|
ghcr.io/${{ github.repository_owner }}/miniflux
|
||||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
quay.io/${{ github.repository_owner }}/miniflux
|
||||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
tags: |
|
||||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}
|
type=ref,event=pr
|
||||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest,ghcr.io/${DOCKER_IMAGE}:latest,quay.io/${DOCKER_IMAGE}:latest"
|
type=schedule,pattern=nightly
|
||||||
fi
|
type=semver,pattern={{raw}}
|
||||||
echo ::set-output name=tags::${TAGS}
|
|
||||||
|
|
||||||
- name: Generate Distroless Docker tag
|
- name: Generate Distroless Docker tags
|
||||||
id: docker_distroless_tag
|
id: docker_distroless_tags
|
||||||
run: |
|
uses: docker/metadata-action@v5
|
||||||
DOCKER_IMAGE=${{ github.repository_owner }}/miniflux
|
with:
|
||||||
DOCKER_VERSION=dev-distroless
|
images: |
|
||||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
docker.io/${{ github.repository_owner }}/miniflux
|
||||||
DOCKER_VERSION=nightly-distroless
|
ghcr.io/${{ github.repository_owner }}/miniflux
|
||||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION}"
|
quay.io/${{ github.repository_owner }}/miniflux
|
||||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
tags: |
|
||||||
DOCKER_VERSION=${GITHUB_REF#refs/tags/}-distroless
|
type=ref,event=pr
|
||||||
TAGS="docker.io/${DOCKER_IMAGE}:${DOCKER_VERSION},ghcr.io/${DOCKER_IMAGE}:${DOCKER_VERSION},quay.io/${DOCKER_IMAGE}:${DOCKER_VERSION},docker.io/${DOCKER_IMAGE}:latest-distroless,ghcr.io/${DOCKER_IMAGE}:latest-distroless,quay.io/${DOCKER_IMAGE}:latest-distroless"
|
type=schedule,pattern=nightly
|
||||||
fi
|
type=semver,pattern={{raw}}
|
||||||
echo ::set-output name=tags::${TAGS}
|
flavor: |
|
||||||
|
suffix=-distroless,onlatest=true
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
@ -81,12 +54,14 @@ jobs:
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
|
@ -94,6 +69,7 @@ jobs:
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Quay Container Registry
|
- name: Login to Quay Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
|
@ -101,19 +77,19 @@ jobs:
|
||||||
password: ${{ secrets.QUAY_TOKEN }}
|
password: ${{ secrets.QUAY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push Alpine images
|
- name: Build and Push Alpine images
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./packaging/docker/alpine/Dockerfile
|
file: ./packaging/docker/alpine/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker_alpine_tag.outputs.tags }}
|
tags: ${{ steps.docker_alpine_tags.outputs.tags }}
|
||||||
|
|
||||||
- name: Build and Push Distroless images
|
- name: Build and Push Distroless images
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./packaging/docker/distroless/Dockerfile
|
file: ./packaging/docker/distroless/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker_distroless_tag.outputs.tags }}
|
tags: ${{ steps.docker_distroless_tags.outputs.tags }}
|
||||||
|
|
|
@ -13,25 +13,27 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install jshint
|
- name: Install linters
|
||||||
run: |
|
run: |
|
||||||
sudo npm install -g jshint@2.13.3
|
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||||
- name: Run jshint
|
- name: Run jshint
|
||||||
run: jshint ui/static/js/*.js
|
run: jshint internal/ui/static/js/*.js
|
||||||
|
- name: Run ESLint
|
||||||
|
run: eslint internal/ui/static/js/*.js
|
||||||
|
|
||||||
golangci:
|
golangci:
|
||||||
name: Golang Linter
|
name: Golang Linters
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.22.x"
|
||||||
- run: "go vet ./..."
|
- run: "go vet ./..."
|
||||||
- uses: golangci/golangci-lint-action@v4
|
- uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
|
||||||
- uses: dominikh/staticcheck-action@v1.3.0
|
- uses: dominikh/staticcheck-action@v1.3.1
|
||||||
with:
|
with:
|
||||||
version: "2023.1.7"
|
version: "2023.1.7"
|
||||||
install-go: false
|
install-go: false
|
||||||
|
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
publish-package:
|
publish-package:
|
||||||
if: github.event.push
|
if: github.event_name == 'push'
|
||||||
name: Publish Packages
|
name: Publish Packages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||||
go-version: ["1.22"]
|
go-version: ["1.22.x"]
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
|
@ -43,7 +43,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.22.x"
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Install Postgres client
|
- name: Install Postgres client
|
||||||
|
|
106
ChangeLog
106
ChangeLog
|
@ -1,3 +1,109 @@
|
||||||
|
Version 2.1.3 (April 27, 2024)
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
* `api`: `rand.Intn(math.MaxInt64)` causes tests to fail on 32-bit architectures (use `rand.Int()` instead)
|
||||||
|
* `ci`: use `docker/metadata-action` instead of deprecated shell-scripts
|
||||||
|
* `database`: remove `entries_feed_url_idx` index because entry URLs can exceeds btree index size limit
|
||||||
|
* `finder`: find feeds from YouTube playlist
|
||||||
|
* `http/response`: add brotli compression support
|
||||||
|
* `integration/matrix`: fix function name in comment
|
||||||
|
* `packaging`: specify container registry explicitly (e.g., Podman does not use `docker.io` by default)
|
||||||
|
* `packaging`: use `make miniflux` instead of duplicating `go build` arguments (this leverages Go's PIE build mode)
|
||||||
|
* `reader/fetcher`: add brotli content encoding support
|
||||||
|
* `reader/processor`: minimize feed entries HTML content
|
||||||
|
* `reader/rewrite`: add a rule for `oglaf.com`
|
||||||
|
* `storage`: change `GetReadTime()` function to use `entries_feed_id_hash_key` index
|
||||||
|
* `ui`: add seek and speed controls to media player
|
||||||
|
* `ui`: add tag entries page
|
||||||
|
* `ui`: fix JavaScript error when clicking on unread counter
|
||||||
|
* `ui`: use `FORCE_REFRESH_INTERVAL` config for category refresh
|
||||||
|
* Bump `github.com/tdewolff/minify/v2` from `2.20.19` to `2.20.20`
|
||||||
|
* Bump `golang.org/x/net` from `0.22.0` to `0.24.0`
|
||||||
|
* Bump `golang.org/x/term` from `0.18.0` to `0.19.0`
|
||||||
|
* Bump `golang.org/x/oauth2` from `0.18.0` to `0.19.0`
|
||||||
|
* Bump `github.com/yuin/goldmark` from `1.7.0` to `1.7.1`
|
||||||
|
|
||||||
|
Version 2.1.2 (March 30, 2024)
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
* `api`: rewrite API integration tests without build tags
|
||||||
|
* `ci`: add basic ESLinter checks
|
||||||
|
* `ci`: enable go-critic linter and fix various issues detected
|
||||||
|
* `ci`: fix JavaScript linter path in GitHub Actions
|
||||||
|
* `cli`: avoid misleading error message when creating an admin user automatically
|
||||||
|
* `config`: add `FILTER_ENTRY_MAX_AGE_DAYS` option
|
||||||
|
* `config`: bump the number of simultaneous workers
|
||||||
|
* `config`: rename `PROXY_*` options to `MEDIA_PROXY_*`
|
||||||
|
* `config`: use `crypto.GenerateRandomBytes` instead of doing it by hand
|
||||||
|
* `http/request`: refactor conditions to be more idiomatic
|
||||||
|
* `http/response`: remove legacy `X-XSS-Protection` header
|
||||||
|
* `integration/rssbrige`: fix rssbrige import
|
||||||
|
* `integration/shaarli`: factorize the header+payload concatenation as data
|
||||||
|
* `integration/shaarli`: no need to base64-encode then remove the padding when we can simply encode without padding
|
||||||
|
* `integration/shaarli`: the JWT token was declared as using HS256 as algorithm, but was using HS512
|
||||||
|
* `integration/webhook`: add category title to request body
|
||||||
|
* `locale`: update Turkish translations
|
||||||
|
* `man page`: sort config options in alphabetical order
|
||||||
|
* `mediaproxy`: reduce the internal indentation of `ProxifiedUrl` by inverting some conditions
|
||||||
|
* `mediaproxy`: simplify and refactor the package
|
||||||
|
* `model`: replace` Optional{Int,Int64,Float64}` with a generic function `OptionalNumber()`
|
||||||
|
* `model`: use struct embedding for `FeedCreationRequestFromSubscriptionDiscovery` to reduce code duplication
|
||||||
|
* `reader/atom`: avoid debug message when the date is empty
|
||||||
|
* `reader/atom`: change `if !a { a = } if !a {a = }` constructs into `if !a { a = ; if !a {a = }}` to reduce the number of comparisons and improve readability
|
||||||
|
* `reader/atom`: Move the population of the feed's entries into a new function, to make BuildFeed easier to understand/separate concerns/implementation details
|
||||||
|
* `reader/atom`: refactor Atom parser to use an adapter
|
||||||
|
* `reader/atom`: use `sort+compact` instead of `compact+sort` to remove duplicates
|
||||||
|
* `reader/atom`: when detecting the format, detect its version as well
|
||||||
|
* `reader/encoding`: inline a one-liner function
|
||||||
|
* `reader/handler`: fix force refresh feature
|
||||||
|
* `reader/json`: refactor JSON Feed parser to use an adapter
|
||||||
|
* `reader/media`: remove a superfluous error-check: `strconv.ParseInt` returns `0` when passed an empty string
|
||||||
|
* `reader/media`: simplify switch-case by moving a common condition above it
|
||||||
|
* `reader/processor`: compile block/keep regex only once per feed
|
||||||
|
* `reader/rdf`: refactor RDF parser to use an adapter
|
||||||
|
* `reader/rewrite`: inline some one-line functions
|
||||||
|
* `reader/rewrite`: simplify `removeClickbait`
|
||||||
|
* `reader/rewrite`: transform a free-standing function into a method
|
||||||
|
* `reader/rewrite`: use a proper constant instead of a magic number in `applyFuncOnTextContent`
|
||||||
|
* `reader/rss`: add support for `<media:category>` element
|
||||||
|
* `reader/rss`: don't add empty tags to RSS items
|
||||||
|
* `reader/rss`: refactor RSS parser to use a default namespace to avoid some limitations of the Go XML parser
|
||||||
|
* `reader/rss`: refactor RSS Parser to use an adapter
|
||||||
|
* `reader/rss`: remove some duplicated code in RSS parser
|
||||||
|
* `reader`: ensure that enclosure URLs are always absolute
|
||||||
|
* `reader`: move iTunes and GooglePlay XML definitions to their own packages
|
||||||
|
* `reader`: parse podcast categories
|
||||||
|
* `reader`: remove trailing space in `SiteURL` and `FeedURL`
|
||||||
|
* `storage`: do not store empty tags
|
||||||
|
* `storage`: simplify `removeDuplicates()` to use a `sort`+`compact` construct instead of doing it by hand with a hashmap
|
||||||
|
* `storage`: Use plain strings concatenation instead of building an array and then joining it
|
||||||
|
* `timezone`: make sure the tests pass when the timezone database is not installed on the host
|
||||||
|
* `ui/css`: align `min-width` with the other `min-width` values
|
||||||
|
* `ui/css`: fix regression: "Add to Home Screen" button is unreadable
|
||||||
|
* `ui/js`: don't use lambdas to return a function, use directly the function instead
|
||||||
|
* `ui/js`: enable trusted-types
|
||||||
|
* `ui/js`: fix download button loading label
|
||||||
|
* `ui/js`: fix JavaScript error on the login page when the user not authenticated
|
||||||
|
* `ui/js`: inline one-line functions
|
||||||
|
* `ui/js`: inline some `querySelectorAll` calls
|
||||||
|
* `ui/js`: reduce the scope of some variables
|
||||||
|
* `ui/js`: remove a hack for "Chrome 67 and earlier" since it was released in 2018
|
||||||
|
* `ui/js`: replace `DomHelper.findParent` with `.closest`
|
||||||
|
* `ui/js`: replace `let` with `const`
|
||||||
|
* `ui/js`: simplify `DomHelper.getVisibleElements` by using a `filter` instead of a loop with an index
|
||||||
|
* `ui/js`: use a `Set` instead of an array in a `KeyboardHandler`'s member
|
||||||
|
* `ui/js`: use some ternaries where it makes sense
|
||||||
|
* `ui/static`: make use of `HashFromBytes` everywhere
|
||||||
|
* `ui/static`: set minifier ECMAScript version
|
||||||
|
* `ui`: add keyboard shortcuts for scrolling to top/bottom of the item list
|
||||||
|
* `ui`: add media player control playback speed
|
||||||
|
* `ui`: remove unused variables and improve JSON decoding in `saveEnclosureProgression()`
|
||||||
|
* `validator`: display an error message on edit feed page when the feed URL is not unique
|
||||||
|
* Bump `github.com/coreos/go-oidc/v3` from `3.9.0` to `3.10.0`
|
||||||
|
* Bump `github.com/go-webauthn/webauthn` from `0.10.1` to `0.10.2`
|
||||||
|
* Bump `github.com/tdewolff/minify/v2` from `2.20.18` to `2.20.19`
|
||||||
|
* Bump `google.golang.org/protobuf` from `1.32.0` to `1.33.0`
|
||||||
|
|
||||||
Version 2.1.1 (March 10, 2024)
|
Version 2.1.1 (March 10, 2024)
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
|
42
Makefile
42
Makefile
|
@ -1,12 +1,12 @@
|
||||||
APP := miniflux
|
APP := miniflux
|
||||||
DOCKER_IMAGE := miniflux/miniflux
|
DOCKER_IMAGE := miniflux/miniflux
|
||||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||||
BUILD_DATE := `date +%FT%T%z`
|
BUILD_DATE := `date +%FT%T%z`
|
||||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||||
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
|
||||||
DEB_IMG_ARCH := amd64
|
DOCKER_PLATFORM := amd64
|
||||||
|
|
||||||
export PGPASSWORD := postgres
|
export PGPASSWORD := postgres
|
||||||
|
|
||||||
|
@ -101,7 +101,7 @@ windows-x86:
|
||||||
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||||
|
|
||||||
run:
|
run:
|
||||||
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
@ LOG_DATE_TIME=1 LOG_LEVEL=debug RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
|
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
|
||||||
|
@ -128,7 +128,11 @@ integration-test:
|
||||||
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||||
|
|
||||||
while ! nc -z localhost 8080; do sleep 1; done
|
while ! nc -z localhost 8080; do sleep 1; done
|
||||||
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests
|
|
||||||
|
TEST_MINIFLUX_BASE_URL=http://127.0.0.1:8080 \
|
||||||
|
TEST_MINIFLUX_ADMIN_USERNAME=admin \
|
||||||
|
TEST_MINIFLUX_ADMIN_PASSWORD=test123 \
|
||||||
|
go test -v -count=1 ./internal/api
|
||||||
|
|
||||||
clean-integration-test:
|
clean-integration-test:
|
||||||
@ kill -9 `cat /tmp/miniflux.pid`
|
@ kill -9 `cat /tmp/miniflux.pid`
|
||||||
|
@ -159,15 +163,15 @@ rpm: clean
|
||||||
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
|
rpmbuild -bb --define "_miniflux_version $(VERSION)" /root/rpmbuild/SPECS/miniflux.spec
|
||||||
|
|
||||||
debian:
|
debian:
|
||||||
@ docker build --load \
|
@ docker buildx build --load \
|
||||||
--build-arg BASE_IMAGE_ARCH=$(DEB_IMG_ARCH) \
|
--platform linux/$(DOCKER_PLATFORM) \
|
||||||
-t $(DEB_IMG_ARCH)/miniflux-deb-builder \
|
-t miniflux-deb-builder \
|
||||||
-f packaging/debian/Dockerfile \
|
-f packaging/debian/Dockerfile \
|
||||||
.
|
.
|
||||||
@ docker run --rm \
|
@ docker run --rm --platform linux/$(DOCKER_PLATFORM) \
|
||||||
-v ${PWD}:/pkg $(DEB_IMG_ARCH)/miniflux-deb-builder
|
-v ${PWD}:/pkg miniflux-deb-builder
|
||||||
|
|
||||||
debian-packages: clean
|
debian-packages: clean
|
||||||
$(MAKE) debian DEB_IMG_ARCH=amd64
|
$(MAKE) debian DOCKER_PLATFORM=amd64
|
||||||
$(MAKE) debian DEB_IMG_ARCH=arm64v8
|
$(MAKE) debian DOCKER_PLATFORM=arm64
|
||||||
$(MAKE) debian DEB_IMG_ARCH=arm32v7
|
$(MAKE) debian DOCKER_PLATFORM=arm/v7
|
||||||
|
|
|
@ -18,16 +18,44 @@ type Client struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new Miniflux client.
|
// New returns a new Miniflux client.
|
||||||
|
// Deprecated: use NewClient instead.
|
||||||
func New(endpoint string, credentials ...string) *Client {
|
func New(endpoint string, credentials ...string) *Client {
|
||||||
// Web gives "API Endpoint = https://miniflux.app/v1/", it doesn't work (/v1/v1/me)
|
return NewClient(endpoint, credentials...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient returns a new Miniflux client.
|
||||||
|
func NewClient(endpoint string, credentials ...string) *Client {
|
||||||
|
// Trim trailing slashes and /v1 from the endpoint.
|
||||||
endpoint = strings.TrimSuffix(endpoint, "/")
|
endpoint = strings.TrimSuffix(endpoint, "/")
|
||||||
endpoint = strings.TrimSuffix(endpoint, "/v1")
|
endpoint = strings.TrimSuffix(endpoint, "/v1")
|
||||||
// trim to https://miniflux.app
|
switch len(credentials) {
|
||||||
|
case 2:
|
||||||
if len(credentials) == 2 {
|
|
||||||
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
|
return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
|
||||||
|
case 1:
|
||||||
|
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
|
||||||
|
default:
|
||||||
|
return &Client{request: &request{endpoint: endpoint}}
|
||||||
}
|
}
|
||||||
return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
|
}
|
||||||
|
|
||||||
|
// Healthcheck checks if the application is up and running.
|
||||||
|
func (c *Client) Healthcheck() error {
|
||||||
|
body, err := c.request.Get("/healthcheck")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("miniflux: unable to perform healthcheck: %w", err)
|
||||||
|
}
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
responseBodyContent, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("miniflux: unable to read healthcheck response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(responseBodyContent) != "OK" {
|
||||||
|
return fmt.Errorf("miniflux: invalid healthcheck response: %q", responseBodyContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version returns the version of the Miniflux instance.
|
// Version returns the version of the Miniflux instance.
|
||||||
|
@ -528,6 +556,25 @@ func (c *Client) SaveEntry(entryID int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchEntryOriginalContent fetches the original content of an entry using the scraper.
|
||||||
|
func (c *Client) FetchEntryOriginalContent(entryID int64) (string, error) {
|
||||||
|
body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d/fetch-content", entryID))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(body).Decode(&response); err != nil {
|
||||||
|
return "", fmt.Errorf("miniflux: response error (%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FetchCounters fetches feed counters.
|
// FetchCounters fetches feed counters.
|
||||||
func (c *Client) FetchCounters() (*FeedCounters, error) {
|
func (c *Client) FetchCounters() (*FeedCounters, error) {
|
||||||
body, err := c.request.Get("/v1/feeds/counters")
|
body, err := c.request.Get("/v1/feeds/counters")
|
||||||
|
|
|
@ -12,7 +12,7 @@ This code snippet fetch the list of users:
|
||||||
miniflux "miniflux.app/v2/client"
|
miniflux "miniflux.app/v2/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
client := miniflux.New("https://api.example.org", "admin", "secret")
|
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
|
||||||
users, err := client.Users()
|
users, err := client.Users()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
|
|
|
@ -41,6 +41,7 @@ type User struct {
|
||||||
DefaultHomePage string `json:"default_home_page"`
|
DefaultHomePage string `json:"default_home_page"`
|
||||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||||
|
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) String() string {
|
func (u User) String() string {
|
||||||
|
@ -58,28 +59,29 @@ type UserCreationRequest struct {
|
||||||
|
|
||||||
// UserModificationRequest represents the request to update a user.
|
// UserModificationRequest represents the request to update a user.
|
||||||
type UserModificationRequest struct {
|
type UserModificationRequest struct {
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
IsAdmin *bool `json:"is_admin"`
|
IsAdmin *bool `json:"is_admin"`
|
||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
Language *string `json:"language"`
|
Language *string `json:"language"`
|
||||||
Timezone *string `json:"timezone"`
|
Timezone *string `json:"timezone"`
|
||||||
EntryDirection *string `json:"entry_sorting_direction"`
|
EntryDirection *string `json:"entry_sorting_direction"`
|
||||||
EntryOrder *string `json:"entry_sorting_order"`
|
EntryOrder *string `json:"entry_sorting_order"`
|
||||||
Stylesheet *string `json:"stylesheet"`
|
Stylesheet *string `json:"stylesheet"`
|
||||||
GoogleID *string `json:"google_id"`
|
GoogleID *string `json:"google_id"`
|
||||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||||
EntriesPerPage *int `json:"entries_per_page"`
|
EntriesPerPage *int `json:"entries_per_page"`
|
||||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||||
ShowReadingTime *bool `json:"show_reading_time"`
|
ShowReadingTime *bool `json:"show_reading_time"`
|
||||||
EntrySwipe *bool `json:"entry_swipe"`
|
EntrySwipe *bool `json:"entry_swipe"`
|
||||||
GestureNav *string `json:"gesture_nav"`
|
GestureNav *string `json:"gesture_nav"`
|
||||||
DisplayMode *string `json:"display_mode"`
|
DisplayMode *string `json:"display_mode"`
|
||||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||||
DefaultHomePage *string `json:"default_home_page"`
|
DefaultHomePage *string `json:"default_home_page"`
|
||||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||||
|
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users represents a list of users.
|
// Users represents a list of users.
|
||||||
|
@ -290,3 +292,7 @@ type VersionResponse struct {
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetOptionalField[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ var (
|
||||||
ErrForbidden = errors.New("miniflux: access forbidden")
|
ErrForbidden = errors.New("miniflux: access forbidden")
|
||||||
ErrServerError = errors.New("miniflux: internal server error")
|
ErrServerError = errors.New("miniflux: internal server error")
|
||||||
ErrNotFound = errors.New("miniflux: resource not found")
|
ErrNotFound = errors.New("miniflux: resource not found")
|
||||||
|
ErrBadRequest = errors.New("miniflux: bad request")
|
||||||
)
|
)
|
||||||
|
|
||||||
type errorResponse struct {
|
type errorResponse struct {
|
||||||
|
@ -124,10 +125,10 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||||
var resp errorResponse
|
var resp errorResponse
|
||||||
decoder := json.NewDecoder(response.Body)
|
decoder := json.NewDecoder(response.Body)
|
||||||
if err := decoder.Decode(&resp); err != nil {
|
if err := decoder.Decode(&resp); err != nil {
|
||||||
return nil, fmt.Errorf("miniflux: bad request error (%v)", err)
|
return nil, fmt.Errorf("%w (%v)", ErrBadRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("miniflux: bad request (%s)", resp.ErrorMessage)
|
return nil, fmt.Errorf("%w (%s)", ErrBadRequest, resp.ErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode > 400 {
|
if response.StatusCode > 400 {
|
||||||
|
|
39
go.mod
39
go.mod
|
@ -3,25 +3,27 @@ module miniflux.app/v2
|
||||||
// +heroku goVersion go1.22
|
// +heroku goVersion go1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.9.1
|
github.com/PuerkitoBio/goquery v1.9.2
|
||||||
github.com/abadojack/whatlanggo v1.0.1
|
github.com/abadojack/whatlanggo v1.0.1
|
||||||
github.com/coreos/go-oidc/v3 v3.9.0
|
github.com/andybalholm/brotli v1.1.0
|
||||||
github.com/go-webauthn/webauthn v0.10.1
|
github.com/coreos/go-oidc/v3 v3.10.0
|
||||||
|
github.com/go-webauthn/webauthn v0.10.2
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/prometheus/client_golang v1.19.0
|
github.com/prometheus/client_golang v1.19.1
|
||||||
github.com/tdewolff/minify/v2 v2.20.18
|
github.com/tdewolff/minify/v2 v2.20.34
|
||||||
github.com/yuin/goldmark v1.7.0
|
github.com/yuin/goldmark v1.7.4
|
||||||
golang.org/x/crypto v0.21.0
|
golang.org/x/crypto v0.24.0
|
||||||
golang.org/x/net v0.22.0
|
golang.org/x/net v0.26.0
|
||||||
golang.org/x/oauth2 v0.18.0
|
golang.org/x/oauth2 v0.21.0
|
||||||
golang.org/x/term v0.18.0
|
golang.org/x/term v0.21.0
|
||||||
|
golang.org/x/text v0.16.0
|
||||||
mvdan.cc/xurls/v2 v2.5.0
|
mvdan.cc/xurls/v2 v2.5.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-webauthn/x v0.1.8 // indirect
|
github.com/go-webauthn/x v0.1.9 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0 // 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.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,20 +31,17 @@ require (
|
||||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
github.com/prometheus/common v0.48.0 // indirect
|
github.com/prometheus/common v0.48.0 // indirect
|
||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
github.com/tdewolff/parse/v2 v2.7.12 // indirect
|
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/sys v0.18.0 // indirect
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
google.golang.org/protobuf v1.33.0 // indirect
|
||||||
google.golang.org/appengine v1.6.8 // indirect
|
|
||||||
google.golang.org/protobuf v1.32.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
101
go.sum
101
go.sum
|
@ -1,34 +1,29 @@
|
||||||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
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/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
|
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
|
||||||
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
|
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
|
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||||
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||||
github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE=
|
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||||
github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI=
|
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||||
github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc=
|
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||||
github.com/go-webauthn/x v0.1.8/go.mod h1:i8UNlGVt3oy6oAFcP4SZB1djZLx/4pbekCbWowjTaJg=
|
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||||
|
@ -43,35 +38,32 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
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/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 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
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 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/tdewolff/minify/v2 v2.20.34 h1:XueI6sQtgS7du45fyBCNkNfPQ9SINaYavMFNOxp37SA=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/tdewolff/minify/v2 v2.20.34/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
|
||||||
github.com/tdewolff/minify/v2 v2.20.18 h1:y+s6OzlZwFqApgNXWNtaMuEMEPbHT72zrCyb9Az35Xo=
|
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
|
||||||
github.com/tdewolff/minify/v2 v2.20.18/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
|
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||||
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
|
|
||||||
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
|
||||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
|
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||||
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
@ -79,11 +71,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
@ -94,40 +85,28 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
|
||||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
|
||||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -15,8 +15,8 @@ import (
|
||||||
"miniflux.app/v2/internal/http/request"
|
"miniflux.app/v2/internal/http/request"
|
||||||
"miniflux.app/v2/internal/http/response/json"
|
"miniflux.app/v2/internal/http/response/json"
|
||||||
"miniflux.app/v2/internal/integration"
|
"miniflux.app/v2/internal/integration"
|
||||||
|
"miniflux.app/v2/internal/mediaproxy"
|
||||||
"miniflux.app/v2/internal/model"
|
"miniflux.app/v2/internal/model"
|
||||||
"miniflux.app/v2/internal/proxy"
|
|
||||||
"miniflux.app/v2/internal/reader/processor"
|
"miniflux.app/v2/internal/reader/processor"
|
||||||
"miniflux.app/v2/internal/reader/readingtime"
|
"miniflux.app/v2/internal/reader/readingtime"
|
||||||
"miniflux.app/v2/internal/storage"
|
"miniflux.app/v2/internal/storage"
|
||||||
|
@ -36,14 +36,14 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
|
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
|
||||||
proxyOption := config.Opts.ProxyOption()
|
proxyOption := config.Opts.MediaProxyMode()
|
||||||
|
|
||||||
for i := range entry.Enclosures {
|
for i := range entry.Enclosures {
|
||||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
||||||
for _, mediaType := range config.Opts.ProxyMediaTypes() {
|
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||||
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
|
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -164,7 +164,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
entries[i].Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entries[i].Content)
|
entries[i].Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entries[i].Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
|
||||||
|
|
|
@ -115,7 +115,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if validationErr := validator.ValidateFeedModification(h.store, userID, &feedModificationRequest); validationErr != nil {
|
if validationErr := validator.ValidateFeedModification(h.store, userID, originalFeed.ID, &feedModificationRequest); validationErr != nil {
|
||||||
json.BadRequest(w, r, validationErr.Error())
|
json.BadRequest(w, r, validationErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ func askCredentials() (string, string) {
|
||||||
fd := int(os.Stdin.Fd())
|
fd := int(os.Stdin.Fd())
|
||||||
|
|
||||||
if !term.IsTerminal(fd) {
|
if !term.IsTerminal(fd) {
|
||||||
printErrorAndExit(fmt.Errorf("this is not a terminal, exiting"))
|
printErrorAndExit(fmt.Errorf("this is not an interactive terminal, exiting"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print("Enter Username: ")
|
fmt.Print("Enter Username: ")
|
||||||
|
|
|
@ -23,7 +23,7 @@ const (
|
||||||
flagVersionHelp = "Show application version"
|
flagVersionHelp = "Show application version"
|
||||||
flagMigrateHelp = "Run SQL migrations"
|
flagMigrateHelp = "Run SQL migrations"
|
||||||
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
|
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
|
||||||
flagCreateAdminHelp = "Create admin user"
|
flagCreateAdminHelp = "Create an admin user from an interactive terminal"
|
||||||
flagResetPasswordHelp = "Reset user password"
|
flagResetPasswordHelp = "Reset user password"
|
||||||
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
|
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
|
||||||
flagDebugModeHelp = "Show debug logs"
|
flagDebugModeHelp = "Show debug logs"
|
||||||
|
@ -191,7 +191,7 @@ func Parse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagCreateAdmin {
|
if flagCreateAdmin {
|
||||||
createAdmin(store)
|
createAdminUserFromInteractiveTerminal(store)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,9 +211,8 @@ func Parse() {
|
||||||
printErrorAndExit(err)
|
printErrorAndExit(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create admin user and start the daemon.
|
|
||||||
if config.Opts.CreateAdmin() {
|
if config.Opts.CreateAdmin() {
|
||||||
createAdmin(store)
|
createAdminUserFromEnvironmentVariables(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagRefreshFeeds {
|
if flagRefreshFeeds {
|
||||||
|
|
|
@ -12,15 +12,20 @@ import (
|
||||||
"miniflux.app/v2/internal/validator"
|
"miniflux.app/v2/internal/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
func createAdmin(store *storage.Storage) {
|
func createAdminUserFromEnvironmentVariables(store *storage.Storage) {
|
||||||
userCreationRequest := &model.UserCreationRequest{
|
createAdminUser(store, config.Opts.AdminUsername(), config.Opts.AdminPassword())
|
||||||
Username: config.Opts.AdminUsername(),
|
}
|
||||||
Password: config.Opts.AdminPassword(),
|
|
||||||
IsAdmin: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if userCreationRequest.Username == "" || userCreationRequest.Password == "" {
|
func createAdminUserFromInteractiveTerminal(store *storage.Storage) {
|
||||||
userCreationRequest.Username, userCreationRequest.Password = askCredentials()
|
username, password := askCredentials()
|
||||||
|
createAdminUser(store, username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdminUser(store *storage.Storage, username, password string) {
|
||||||
|
userCreationRequest := &model.UserCreationRequest{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
IsAdmin: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if store.UserExists(userCreationRequest.Username) {
|
if store.UserExists(userCreationRequest.Username) {
|
||||||
|
@ -34,7 +39,12 @@ func createAdmin(store *storage.Storage) {
|
||||||
printErrorAndExit(validationErr.Error())
|
printErrorAndExit(validationErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := store.CreateUser(userCreationRequest); err != nil {
|
if user, err := store.CreateUser(userCreationRequest); err != nil {
|
||||||
printErrorAndExit(err)
|
printErrorAndExit(err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Created new admin user",
|
||||||
|
slog.String("username", user.Username),
|
||||||
|
slog.Int64("user_id", user.ID),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
package config // import "miniflux.app/v2/internal/config"
|
package config // import "miniflux.app/v2/internal/config"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
@ -1442,9 +1443,9 @@ func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxyOption(t *testing.T) {
|
func TestMediaProxyMode(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
os.Setenv("PROXY_OPTION", "all")
|
os.Setenv("MEDIA_PROXY_MODE", "all")
|
||||||
|
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
opts, err := parser.ParseEnvironmentVariables()
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
@ -1453,14 +1454,14 @@ func TestProxyOption(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := "all"
|
expected := "all"
|
||||||
result := opts.ProxyOption()
|
result := opts.MediaProxyMode()
|
||||||
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
|
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultProxyOptionValue(t *testing.T) {
|
func TestDefaultMediaProxyModeValue(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
|
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
|
@ -1469,17 +1470,17 @@ func TestDefaultProxyOptionValue(t *testing.T) {
|
||||||
t.Fatalf(`Parsing failure: %v`, err)
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := defaultProxyOption
|
expected := defaultMediaProxyMode
|
||||||
result := opts.ProxyOption()
|
result := opts.MediaProxyMode()
|
||||||
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
|
t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxyMediaTypes(t *testing.T) {
|
func TestMediaProxyResourceTypes(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
|
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio")
|
||||||
|
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
opts, err := parser.ParseEnvironmentVariables()
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
@ -1489,25 +1490,25 @@ func TestProxyMediaTypes(t *testing.T) {
|
||||||
|
|
||||||
expected := []string{"audio", "image"}
|
expected := []string{"audio", "image"}
|
||||||
|
|
||||||
if len(expected) != len(opts.ProxyMediaTypes()) {
|
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultMap := make(map[string]bool)
|
resultMap := make(map[string]bool)
|
||||||
for _, mediaType := range opts.ProxyMediaTypes() {
|
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||||
resultMap[mediaType] = true
|
resultMap[mediaType] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mediaType := range expected {
|
for _, mediaType := range expected {
|
||||||
if !resultMap[mediaType] {
|
if !resultMap[mediaType] {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
|
func TestMediaProxyResourceTypesWithDuplicatedValues(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
os.Setenv("PROXY_MEDIA_TYPES", "image,audio, image")
|
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio, image")
|
||||||
|
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
opts, err := parser.ParseEnvironmentVariables()
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
@ -1516,23 +1517,119 @@ func TestProxyMediaTypesWithDuplicatedValues(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"audio", "image"}
|
expected := []string{"audio", "image"}
|
||||||
if len(expected) != len(opts.ProxyMediaTypes()) {
|
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultMap := make(map[string]bool)
|
resultMap := make(map[string]bool)
|
||||||
for _, mediaType := range opts.ProxyMediaTypes() {
|
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||||
resultMap[mediaType] = true
|
resultMap[mediaType] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mediaType := range expected {
|
for _, mediaType := range expected {
|
||||||
if !resultMap[mediaType] {
|
if !resultMap[mediaType] {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
|
func TestDefaultMediaProxyResourceTypes(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"image"}
|
||||||
|
|
||||||
|
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||||
|
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMap := make(map[string]bool)
|
||||||
|
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||||
|
resultMap[mediaType] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mediaType := range expected {
|
||||||
|
if !resultMap[mediaType] {
|
||||||
|
t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaProxyHTTPClientTimeout(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("MEDIA_PROXY_HTTP_CLIENT_TIMEOUT", "24")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := 24
|
||||||
|
result := opts.MediaProxyHTTPClientTimeout()
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultMediaProxyHTTPClientTimeoutValue(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := defaultMediaProxyHTTPClientTimeout
|
||||||
|
result := opts.MediaProxyHTTPClientTimeout()
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaProxyCustomURL(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://example.org/proxy")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
expected := "http://example.org/proxy"
|
||||||
|
result := opts.MediaCustomProxyURL()
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaProxyPrivateKey(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "foobar")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []byte("foobar")
|
||||||
|
result := opts.MediaProxyPrivateKey()
|
||||||
|
|
||||||
|
if !bytes.Equal(result, expected) {
|
||||||
|
t.Fatalf(`Unexpected MEDIA_PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyImagesOptionForBackwardCompatibility(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
os.Setenv("PROXY_IMAGES", "all")
|
os.Setenv("PROXY_IMAGES", "all")
|
||||||
|
|
||||||
|
@ -1543,30 +1640,31 @@ func TestProxyImagesOptionBackwardCompatibility(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"image"}
|
expected := []string{"image"}
|
||||||
if len(expected) != len(opts.ProxyMediaTypes()) {
|
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultMap := make(map[string]bool)
|
resultMap := make(map[string]bool)
|
||||||
for _, mediaType := range opts.ProxyMediaTypes() {
|
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||||
resultMap[mediaType] = true
|
resultMap[mediaType] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mediaType := range expected {
|
for _, mediaType := range expected {
|
||||||
if !resultMap[mediaType] {
|
if !resultMap[mediaType] {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected PROXY_IMAGES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedProxyOption := "all"
|
expectedProxyOption := "all"
|
||||||
result := opts.ProxyOption()
|
result := opts.MediaProxyMode()
|
||||||
if result != expectedProxyOption {
|
if result != expectedProxyOption {
|
||||||
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
|
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expectedProxyOption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultProxyMediaTypes(t *testing.T) {
|
func TestProxyImageURLForBackwardCompatibility(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_IMAGE_URL", "http://example.org/proxy")
|
||||||
|
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
opts, err := parser.ParseEnvironmentVariables()
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
@ -1574,25 +1672,73 @@ func TestDefaultProxyMediaTypes(t *testing.T) {
|
||||||
t.Fatalf(`Parsing failure: %v`, err)
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"image"}
|
expected := "http://example.org/proxy"
|
||||||
|
result := opts.MediaCustomProxyURL()
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected PROXY_IMAGE_URL value, got %q instead of %q`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(expected) != len(opts.ProxyMediaTypes()) {
|
func TestProxyURLOptionForBackwardCompatibility(t *testing.T) {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_URL", "http://example.org/proxy")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "http://example.org/proxy"
|
||||||
|
result := opts.MediaCustomProxyURL()
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected PROXY_URL value, got %q instead of %q`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyMediaTypesOptionForBackwardCompatibility(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_MEDIA_TYPES", "image,audio")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
expected := []string{"audio", "image"}
|
||||||
|
if len(expected) != len(opts.MediaProxyResourceTypes()) {
|
||||||
|
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultMap := make(map[string]bool)
|
resultMap := make(map[string]bool)
|
||||||
for _, mediaType := range opts.ProxyMediaTypes() {
|
for _, mediaType := range opts.MediaProxyResourceTypes() {
|
||||||
resultMap[mediaType] = true
|
resultMap[mediaType] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mediaType := range expected {
|
for _, mediaType := range expected {
|
||||||
if !resultMap[mediaType] {
|
if !resultMap[mediaType] {
|
||||||
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.ProxyMediaTypes(), expected)
|
t.Fatalf(`Unexpected PROXY_MEDIA_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxyHTTPClientTimeout(t *testing.T) {
|
func TestProxyOptionForBackwardCompatibility(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_OPTION", "all")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
expected := "all"
|
||||||
|
result := opts.MediaProxyMode()
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected PROXY_OPTION value, got %q instead of %q`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyHTTPClientTimeoutOptionForBackwardCompatibility(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
|
os.Setenv("PROXY_HTTP_CLIENT_TIMEOUT", "24")
|
||||||
|
|
||||||
|
@ -1601,29 +1747,26 @@ func TestProxyHTTPClientTimeout(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(`Parsing failure: %v`, err)
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := 24
|
expected := 24
|
||||||
result := opts.ProxyHTTPClientTimeout()
|
result := opts.MediaProxyHTTPClientTimeout()
|
||||||
|
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
|
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultProxyHTTPClientTimeoutValue(t *testing.T) {
|
func TestProxyPrivateKeyOptionForBackwardCompatibility(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_PRIVATE_KEY", "foobar")
|
||||||
|
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
opts, err := parser.ParseEnvironmentVariables()
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(`Parsing failure: %v`, err)
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
}
|
}
|
||||||
|
expected := []byte("foobar")
|
||||||
expected := defaultProxyHTTPClientTimeout
|
result := opts.MediaProxyPrivateKey()
|
||||||
result := opts.ProxyHTTPClientTimeout()
|
if !bytes.Equal(result, expected) {
|
||||||
|
t.Fatalf(`Unexpected PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
|
||||||
if result != expected {
|
|
||||||
t.Fatalf(`Unexpected PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1878,6 +2021,24 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchNebulaWatchTime(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
|
||||||
|
|
||||||
|
parser := NewParser()
|
||||||
|
opts, err := parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := true
|
||||||
|
result := opts.FetchNebulaWatchTime()
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchOdyseeWatchTime(t *testing.T) {
|
func TestFetchOdyseeWatchTime(t *testing.T) {
|
||||||
os.Clearenv()
|
os.Clearenv()
|
||||||
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
|
os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
|
||||||
|
|
|
@ -4,12 +4,12 @@
|
||||||
package config // import "miniflux.app/v2/internal/config"
|
package config // import "miniflux.app/v2/internal/config"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/crypto"
|
||||||
"miniflux.app/v2/internal/version"
|
"miniflux.app/v2/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ const (
|
||||||
defaultBaseURL = "http://localhost"
|
defaultBaseURL = "http://localhost"
|
||||||
defaultRootURL = "http://localhost"
|
defaultRootURL = "http://localhost"
|
||||||
defaultBasePath = ""
|
defaultBasePath = ""
|
||||||
defaultWorkerPoolSize = 5
|
defaultWorkerPoolSize = 16
|
||||||
defaultPollingFrequency = 60
|
defaultPollingFrequency = 60
|
||||||
defaultForceRefreshInterval = 30
|
defaultForceRefreshInterval = 30
|
||||||
defaultBatchSize = 100
|
defaultBatchSize = 100
|
||||||
|
@ -51,10 +51,12 @@ const (
|
||||||
defaultCleanupArchiveUnreadDays = 180
|
defaultCleanupArchiveUnreadDays = 180
|
||||||
defaultCleanupArchiveBatchSize = 10000
|
defaultCleanupArchiveBatchSize = 10000
|
||||||
defaultCleanupRemoveSessionsDays = 30
|
defaultCleanupRemoveSessionsDays = 30
|
||||||
defaultProxyHTTPClientTimeout = 120
|
defaultMediaProxyHTTPClientTimeout = 120
|
||||||
defaultProxyOption = "http-only"
|
defaultMediaProxyMode = "http-only"
|
||||||
defaultProxyMediaTypes = "image"
|
defaultMediaResourceTypes = "image"
|
||||||
defaultProxyUrl = ""
|
defaultMediaProxyURL = ""
|
||||||
|
defaultFilterEntryMaxAgeDays = 0
|
||||||
|
defaultFetchNebulaWatchTime = false
|
||||||
defaultFetchOdyseeWatchTime = false
|
defaultFetchOdyseeWatchTime = false
|
||||||
defaultFetchYouTubeWatchTime = false
|
defaultFetchYouTubeWatchTime = false
|
||||||
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
defaultYouTubeEmbedUrlOverride = "https://www.youtube-nocookie.com/embed/"
|
||||||
|
@ -135,12 +137,14 @@ type Options struct {
|
||||||
createAdmin bool
|
createAdmin bool
|
||||||
adminUsername string
|
adminUsername string
|
||||||
adminPassword string
|
adminPassword string
|
||||||
proxyHTTPClientTimeout int
|
mediaProxyHTTPClientTimeout int
|
||||||
proxyOption string
|
mediaProxyMode string
|
||||||
proxyMediaTypes []string
|
mediaProxyResourceTypes []string
|
||||||
proxyUrl string
|
mediaProxyCustomURL string
|
||||||
|
fetchNebulaWatchTime bool
|
||||||
fetchOdyseeWatchTime bool
|
fetchOdyseeWatchTime bool
|
||||||
fetchYouTubeWatchTime bool
|
fetchYouTubeWatchTime bool
|
||||||
|
filterEntryMaxAgeDays int
|
||||||
youTubeEmbedUrlOverride string
|
youTubeEmbedUrlOverride string
|
||||||
oauth2UserCreationAllowed bool
|
oauth2UserCreationAllowed bool
|
||||||
oauth2ClientID string
|
oauth2ClientID string
|
||||||
|
@ -165,15 +169,12 @@ type Options struct {
|
||||||
metricsPassword string
|
metricsPassword string
|
||||||
watchdog bool
|
watchdog bool
|
||||||
invidiousInstance string
|
invidiousInstance string
|
||||||
proxyPrivateKey []byte
|
mediaProxyPrivateKey []byte
|
||||||
webAuthn bool
|
webAuthn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOptions returns Options with default values.
|
// NewOptions returns Options with default values.
|
||||||
func NewOptions() *Options {
|
func NewOptions() *Options {
|
||||||
randomKey := make([]byte, 16)
|
|
||||||
rand.Read(randomKey)
|
|
||||||
|
|
||||||
return &Options{
|
return &Options{
|
||||||
HTTPS: defaultHTTPS,
|
HTTPS: defaultHTTPS,
|
||||||
logFile: defaultLogFile,
|
logFile: defaultLogFile,
|
||||||
|
@ -212,10 +213,12 @@ func NewOptions() *Options {
|
||||||
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
|
pollingParsingErrorLimit: defaultPollingParsingErrorLimit,
|
||||||
workerPoolSize: defaultWorkerPoolSize,
|
workerPoolSize: defaultWorkerPoolSize,
|
||||||
createAdmin: defaultCreateAdmin,
|
createAdmin: defaultCreateAdmin,
|
||||||
proxyHTTPClientTimeout: defaultProxyHTTPClientTimeout,
|
mediaProxyHTTPClientTimeout: defaultMediaProxyHTTPClientTimeout,
|
||||||
proxyOption: defaultProxyOption,
|
mediaProxyMode: defaultMediaProxyMode,
|
||||||
proxyMediaTypes: []string{defaultProxyMediaTypes},
|
mediaProxyResourceTypes: []string{defaultMediaResourceTypes},
|
||||||
proxyUrl: defaultProxyUrl,
|
mediaProxyCustomURL: defaultMediaProxyURL,
|
||||||
|
filterEntryMaxAgeDays: defaultFilterEntryMaxAgeDays,
|
||||||
|
fetchNebulaWatchTime: defaultFetchNebulaWatchTime,
|
||||||
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
fetchOdyseeWatchTime: defaultFetchOdyseeWatchTime,
|
||||||
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
|
||||||
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
youTubeEmbedUrlOverride: defaultYouTubeEmbedUrlOverride,
|
||||||
|
@ -242,7 +245,7 @@ func NewOptions() *Options {
|
||||||
metricsPassword: defaultMetricsPassword,
|
metricsPassword: defaultMetricsPassword,
|
||||||
watchdog: defaultWatchdog,
|
watchdog: defaultWatchdog,
|
||||||
invidiousInstance: defaultInvidiousInstance,
|
invidiousInstance: defaultInvidiousInstance,
|
||||||
proxyPrivateKey: randomKey,
|
mediaProxyPrivateKey: crypto.GenerateRandomBytes(16),
|
||||||
webAuthn: defaultWebAuthn,
|
webAuthn: defaultWebAuthn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -486,30 +489,41 @@ func (o *Options) YouTubeEmbedUrlOverride() string {
|
||||||
return o.youTubeEmbedUrlOverride
|
return o.youTubeEmbedUrlOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchNebulaWatchTime returns true if the Nebula video duration
|
||||||
|
// should be fetched and used as a reading time.
|
||||||
|
func (o *Options) FetchNebulaWatchTime() bool {
|
||||||
|
return o.fetchNebulaWatchTime
|
||||||
|
}
|
||||||
|
|
||||||
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
// FetchOdyseeWatchTime returns true if the Odysee video duration
|
||||||
// should be fetched and used as a reading time.
|
// should be fetched and used as a reading time.
|
||||||
func (o *Options) FetchOdyseeWatchTime() bool {
|
func (o *Options) FetchOdyseeWatchTime() bool {
|
||||||
return o.fetchOdyseeWatchTime
|
return o.fetchOdyseeWatchTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyOption returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
|
||||||
func (o *Options) ProxyOption() string {
|
func (o *Options) MediaProxyMode() string {
|
||||||
return o.proxyOption
|
return o.mediaProxyMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyMediaTypes returns a slice of media types to proxy.
|
// MediaProxyResourceTypes returns a slice of resource types to proxy.
|
||||||
func (o *Options) ProxyMediaTypes() []string {
|
func (o *Options) MediaProxyResourceTypes() []string {
|
||||||
return o.proxyMediaTypes
|
return o.mediaProxyResourceTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyUrl returns a string of a URL to use to proxy image requests
|
// MediaCustomProxyURL returns the custom proxy URL for medias.
|
||||||
func (o *Options) ProxyUrl() string {
|
func (o *Options) MediaCustomProxyURL() string {
|
||||||
return o.proxyUrl
|
return o.mediaProxyCustomURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
|
// MediaProxyHTTPClientTimeout returns the time limit in seconds before the proxy HTTP client cancel the request.
|
||||||
func (o *Options) ProxyHTTPClientTimeout() int {
|
func (o *Options) MediaProxyHTTPClientTimeout() int {
|
||||||
return o.proxyHTTPClientTimeout
|
return o.mediaProxyHTTPClientTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaProxyPrivateKey returns the private key used by the media proxy.
|
||||||
|
func (o *Options) MediaProxyPrivateKey() []byte {
|
||||||
|
return o.mediaProxyPrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasHTTPService returns true if the HTTP service is enabled.
|
// HasHTTPService returns true if the HTTP service is enabled.
|
||||||
|
@ -605,16 +619,16 @@ func (o *Options) InvidiousInstance() string {
|
||||||
return o.invidiousInstance
|
return o.invidiousInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyPrivateKey returns the private key used by the media proxy
|
|
||||||
func (o *Options) ProxyPrivateKey() []byte {
|
|
||||||
return o.proxyPrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebAuthn returns true if WebAuthn logins are supported
|
// WebAuthn returns true if WebAuthn logins are supported
|
||||||
func (o *Options) WebAuthn() bool {
|
func (o *Options) WebAuthn() bool {
|
||||||
return o.webAuthn
|
return o.webAuthn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
|
||||||
|
func (o *Options) FilterEntryMaxAgeDays() int {
|
||||||
|
return o.filterEntryMaxAgeDays
|
||||||
|
}
|
||||||
|
|
||||||
// SortedOptions returns options as a list of key value pairs, sorted by keys.
|
// SortedOptions returns options as a list of key value pairs, sorted by keys.
|
||||||
func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||||
var keyValues = map[string]interface{}{
|
var keyValues = map[string]interface{}{
|
||||||
|
@ -640,7 +654,9 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||||
"DISABLE_HSTS": !o.hsts,
|
"DISABLE_HSTS": !o.hsts,
|
||||||
"DISABLE_HTTP_SERVICE": !o.httpService,
|
"DISABLE_HTTP_SERVICE": !o.httpService,
|
||||||
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
|
"DISABLE_SCHEDULER_SERVICE": !o.schedulerService,
|
||||||
|
"FILTER_ENTRY_MAX_AGE_DAYS": o.filterEntryMaxAgeDays,
|
||||||
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
|
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
|
||||||
|
"FETCH_NEBULA_WATCH_TIME": o.fetchNebulaWatchTime,
|
||||||
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
|
"FETCH_ODYSEE_WATCH_TIME": o.fetchOdyseeWatchTime,
|
||||||
"HTTPS": o.HTTPS,
|
"HTTPS": o.HTTPS,
|
||||||
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
|
||||||
|
@ -674,11 +690,11 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
|
||||||
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
|
"FORCE_REFRESH_INTERVAL": o.forceRefreshInterval,
|
||||||
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
|
"POLLING_PARSING_ERROR_LIMIT": o.pollingParsingErrorLimit,
|
||||||
"POLLING_SCHEDULER": o.pollingScheduler,
|
"POLLING_SCHEDULER": o.pollingScheduler,
|
||||||
"PROXY_HTTP_CLIENT_TIMEOUT": o.proxyHTTPClientTimeout,
|
"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": o.mediaProxyHTTPClientTimeout,
|
||||||
"PROXY_MEDIA_TYPES": o.proxyMediaTypes,
|
"MEDIA_PROXY_RESOURCE_TYPES": o.mediaProxyResourceTypes,
|
||||||
"PROXY_OPTION": o.proxyOption,
|
"MEDIA_PROXY_MODE": o.mediaProxyMode,
|
||||||
"PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
|
"MEDIA_PROXY_PRIVATE_KEY": redactSecretValue(string(o.mediaProxyPrivateKey), redactSecret),
|
||||||
"PROXY_URL": o.proxyUrl,
|
"MEDIA_PROXY_CUSTOM_URL": o.mediaProxyCustomURL,
|
||||||
"ROOT_URL": o.rootURL,
|
"ROOT_URL": o.rootURL,
|
||||||
"RUN_MIGRATIONS": o.runMigrations,
|
"RUN_MIGRATIONS": o.runMigrations,
|
||||||
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,
|
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -87,6 +88,7 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||||
p.opts.logFormat = parsedValue
|
p.opts.logFormat = parsedValue
|
||||||
}
|
}
|
||||||
case "DEBUG":
|
case "DEBUG":
|
||||||
|
slog.Warn("The DEBUG environment variable is deprecated, use LOG_LEVEL instead")
|
||||||
parsedValue := parseBool(value, defaultDebug)
|
parsedValue := parseBool(value, defaultDebug)
|
||||||
if parsedValue {
|
if parsedValue {
|
||||||
p.opts.logLevel = "debug"
|
p.opts.logLevel = "debug"
|
||||||
|
@ -112,6 +114,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||||
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
|
||||||
case "DATABASE_CONNECTION_LIFETIME":
|
case "DATABASE_CONNECTION_LIFETIME":
|
||||||
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
|
||||||
|
case "FILTER_ENTRY_MAX_AGE_DAYS":
|
||||||
|
p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
|
||||||
case "RUN_MIGRATIONS":
|
case "RUN_MIGRATIONS":
|
||||||
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
|
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
|
||||||
case "DISABLE_HSTS":
|
case "DISABLE_HSTS":
|
||||||
|
@ -158,20 +162,41 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||||
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
p.opts.schedulerRoundRobinMinInterval = parseInt(value, defaultSchedulerRoundRobinMinInterval)
|
||||||
case "POLLING_PARSING_ERROR_LIMIT":
|
case "POLLING_PARSING_ERROR_LIMIT":
|
||||||
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
|
||||||
// kept for compatibility purpose
|
|
||||||
case "PROXY_IMAGES":
|
case "PROXY_IMAGES":
|
||||||
p.opts.proxyOption = parseString(value, defaultProxyOption)
|
slog.Warn("The PROXY_IMAGES environment variable is deprecated, use MEDIA_PROXY_MODE instead")
|
||||||
|
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||||
case "PROXY_HTTP_CLIENT_TIMEOUT":
|
case "PROXY_HTTP_CLIENT_TIMEOUT":
|
||||||
p.opts.proxyHTTPClientTimeout = parseInt(value, defaultProxyHTTPClientTimeout)
|
slog.Warn("The PROXY_HTTP_CLIENT_TIMEOUT environment variable is deprecated, use MEDIA_PROXY_HTTP_CLIENT_TIMEOUT instead")
|
||||||
|
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||||
|
case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
|
||||||
|
p.opts.mediaProxyHTTPClientTimeout = parseInt(value, defaultMediaProxyHTTPClientTimeout)
|
||||||
case "PROXY_OPTION":
|
case "PROXY_OPTION":
|
||||||
p.opts.proxyOption = parseString(value, defaultProxyOption)
|
slog.Warn("The PROXY_OPTION environment variable is deprecated, use MEDIA_PROXY_MODE instead")
|
||||||
|
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||||
|
case "MEDIA_PROXY_MODE":
|
||||||
|
p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
|
||||||
case "PROXY_MEDIA_TYPES":
|
case "PROXY_MEDIA_TYPES":
|
||||||
p.opts.proxyMediaTypes = parseStringList(value, []string{defaultProxyMediaTypes})
|
slog.Warn("The PROXY_MEDIA_TYPES environment variable is deprecated, use MEDIA_PROXY_RESOURCE_TYPES instead")
|
||||||
// kept for compatibility purpose
|
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||||
|
case "MEDIA_PROXY_RESOURCE_TYPES":
|
||||||
|
p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
|
||||||
case "PROXY_IMAGE_URL":
|
case "PROXY_IMAGE_URL":
|
||||||
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
|
slog.Warn("The PROXY_IMAGE_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
|
||||||
|
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||||
case "PROXY_URL":
|
case "PROXY_URL":
|
||||||
p.opts.proxyUrl = parseString(value, defaultProxyUrl)
|
slog.Warn("The PROXY_URL environment variable is deprecated, use MEDIA_PROXY_CUSTOM_URL instead")
|
||||||
|
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||||
|
case "PROXY_PRIVATE_KEY":
|
||||||
|
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
|
||||||
|
randomKey := make([]byte, 16)
|
||||||
|
rand.Read(randomKey)
|
||||||
|
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||||
|
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||||
|
randomKey := make([]byte, 16)
|
||||||
|
rand.Read(randomKey)
|
||||||
|
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||||
|
case "MEDIA_PROXY_CUSTOM_URL":
|
||||||
|
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||||
case "CREATE_ADMIN":
|
case "CREATE_ADMIN":
|
||||||
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
|
||||||
case "ADMIN_USERNAME":
|
case "ADMIN_USERNAME":
|
||||||
|
@ -234,6 +259,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||||
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
|
||||||
case "METRICS_PASSWORD_FILE":
|
case "METRICS_PASSWORD_FILE":
|
||||||
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
|
||||||
|
case "FETCH_NEBULA_WATCH_TIME":
|
||||||
|
p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
|
||||||
case "FETCH_ODYSEE_WATCH_TIME":
|
case "FETCH_ODYSEE_WATCH_TIME":
|
||||||
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
|
||||||
case "FETCH_YOUTUBE_WATCH_TIME":
|
case "FETCH_YOUTUBE_WATCH_TIME":
|
||||||
|
@ -244,10 +271,6 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||||
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
p.opts.watchdog = parseBool(value, defaultWatchdog)
|
||||||
case "INVIDIOUS_INSTANCE":
|
case "INVIDIOUS_INSTANCE":
|
||||||
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
|
||||||
case "PROXY_PRIVATE_KEY":
|
|
||||||
randomKey := make([]byte, 16)
|
|
||||||
rand.Read(randomKey)
|
|
||||||
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
|
|
||||||
case "WEBAUTHN":
|
case "WEBAUTHN":
|
||||||
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
|
p.opts.webAuthn = parseBool(value, defaultWebAuthn)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,8 +17,7 @@ import (
|
||||||
|
|
||||||
// HashFromBytes returns a SHA-256 checksum of the input.
|
// HashFromBytes returns a SHA-256 checksum of the input.
|
||||||
func HashFromBytes(value []byte) string {
|
func HashFromBytes(value []byte) string {
|
||||||
sum := sha256.Sum256(value)
|
return fmt.Sprintf("%x", sha256.Sum256(value))
|
||||||
return fmt.Sprintf("%x", sum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash returns a SHA-256 checksum of a string.
|
// Hash returns a SHA-256 checksum of a string.
|
||||||
|
|
|
@ -871,4 +871,36 @@ var migrations = []func(tx *sql.Tx) error{
|
||||||
_, err = tx.Exec(sql)
|
_, err = tx.Exec(sql)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
func(tx *sql.Tx) (err error) {
|
||||||
|
sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
|
||||||
|
_, err = tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
func(tx *sql.Tx) (err error) {
|
||||||
|
// the WHERE part speed-up the request a lot
|
||||||
|
sql := `UPDATE entries SET tags = array_remove(tags, '') WHERE '' = ANY(tags);`
|
||||||
|
_, err = tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
func(tx *sql.Tx) (err error) {
|
||||||
|
// Entry URLs can exceeds btree maximum size
|
||||||
|
// Checking entry existence is now using entries_feed_id_status_hash_idx index
|
||||||
|
_, err = tx.Exec(`DROP INDEX entries_feed_url_idx`)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
func(tx *sql.Tx) (err error) {
|
||||||
|
sql := `
|
||||||
|
ALTER TABLE integrations ADD COLUMN raindrop_enabled bool default 'f';
|
||||||
|
ALTER TABLE integrations ADD COLUMN raindrop_token text default '';
|
||||||
|
ALTER TABLE integrations ADD COLUMN raindrop_collection_id text default '';
|
||||||
|
ALTER TABLE integrations ADD COLUMN raindrop_tags text default '';
|
||||||
|
`
|
||||||
|
_, err = tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
func(tx *sql.Tx) (err error) {
|
||||||
|
sql := `ALTER TABLE feeds ADD COLUMN description text default ''`
|
||||||
|
_, err = tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,8 +13,8 @@ import (
|
||||||
"miniflux.app/v2/internal/http/request"
|
"miniflux.app/v2/internal/http/request"
|
||||||
"miniflux.app/v2/internal/http/response/json"
|
"miniflux.app/v2/internal/http/response/json"
|
||||||
"miniflux.app/v2/internal/integration"
|
"miniflux.app/v2/internal/integration"
|
||||||
|
"miniflux.app/v2/internal/mediaproxy"
|
||||||
"miniflux.app/v2/internal/model"
|
"miniflux.app/v2/internal/model"
|
||||||
"miniflux.app/v2/internal/proxy"
|
|
||||||
"miniflux.app/v2/internal/storage"
|
"miniflux.app/v2/internal/storage"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -324,7 +324,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||||
FeedID: entry.FeedID,
|
FeedID: entry.FeedID,
|
||||||
Title: entry.Title,
|
Title: entry.Title,
|
||||||
Author: entry.Author,
|
Author: entry.Author,
|
||||||
HTML: proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content),
|
HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content),
|
||||||
URL: entry.URL,
|
URL: entry.URL,
|
||||||
IsSaved: isSaved,
|
IsSaved: isSaved,
|
||||||
IsRead: isRead,
|
IsRead: isRead,
|
||||||
|
|
|
@ -18,8 +18,8 @@ import (
|
||||||
"miniflux.app/v2/internal/http/response/json"
|
"miniflux.app/v2/internal/http/response/json"
|
||||||
"miniflux.app/v2/internal/http/route"
|
"miniflux.app/v2/internal/http/route"
|
||||||
"miniflux.app/v2/internal/integration"
|
"miniflux.app/v2/internal/integration"
|
||||||
|
"miniflux.app/v2/internal/mediaproxy"
|
||||||
"miniflux.app/v2/internal/model"
|
"miniflux.app/v2/internal/model"
|
||||||
"miniflux.app/v2/internal/proxy"
|
|
||||||
"miniflux.app/v2/internal/reader/fetcher"
|
"miniflux.app/v2/internal/reader/fetcher"
|
||||||
mff "miniflux.app/v2/internal/reader/handler"
|
mff "miniflux.app/v2/internal/reader/handler"
|
||||||
mfs "miniflux.app/v2/internal/reader/subscription"
|
mfs "miniflux.app/v2/internal/reader/subscription"
|
||||||
|
@ -265,9 +265,10 @@ func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStream(streamID string, userID int64) (Stream, error) {
|
func getStream(streamID string, userID int64) (Stream, error) {
|
||||||
if strings.HasPrefix(streamID, FeedPrefix) {
|
switch {
|
||||||
|
case strings.HasPrefix(streamID, FeedPrefix):
|
||||||
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
|
return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
|
||||||
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix) {
|
case strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix):
|
||||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
|
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
|
||||||
id = strings.TrimPrefix(id, StreamPrefix)
|
id = strings.TrimPrefix(id, StreamPrefix)
|
||||||
switch id {
|
switch id {
|
||||||
|
@ -288,15 +289,15 @@ func getStream(streamID string, userID int64) (Stream, error) {
|
||||||
default:
|
default:
|
||||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
|
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream with id: %s", id)
|
||||||
}
|
}
|
||||||
} else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
|
case strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix):
|
||||||
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
|
id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
|
||||||
id = strings.TrimPrefix(id, LabelPrefix)
|
id = strings.TrimPrefix(id, LabelPrefix)
|
||||||
return Stream{LabelStream, id}, nil
|
return Stream{LabelStream, id}, nil
|
||||||
} else if streamID == "" {
|
case streamID == "":
|
||||||
return Stream{NoStream, ""}, nil
|
return Stream{NoStream, ""}, nil
|
||||||
|
default:
|
||||||
|
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stream{NoStream, ""}, fmt.Errorf("googlereader: unknown stream type: %s", streamID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
|
func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
|
||||||
|
@ -382,7 +383,7 @@ func getItemIDs(r *http.Request) ([]int64, error) {
|
||||||
return itemIDs, nil
|
return itemIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkOutputFormat(w http.ResponseWriter, r *http.Request) error {
|
func checkOutputFormat(r *http.Request) error {
|
||||||
var output string
|
var output string
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
|
@ -736,11 +737,12 @@ func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed,
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
|
func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
|
||||||
if category.ID == "" {
|
switch {
|
||||||
|
case category.ID == "":
|
||||||
return store.FirstCategory(userID)
|
return store.FirstCategory(userID)
|
||||||
} else if store.CategoryTitleExists(userID, category.ID) {
|
case store.CategoryTitleExists(userID, category.ID):
|
||||||
return store.CategoryByTitle(userID, category.ID)
|
return store.CategoryByTitle(userID, category.ID)
|
||||||
} else {
|
default:
|
||||||
catRequest := model.CategoryRequest{
|
catRequest := model.CategoryRequest{
|
||||||
Title: category.ID,
|
Title: category.ID,
|
||||||
}
|
}
|
||||||
|
@ -764,7 +766,7 @@ func subscribe(newFeed Stream, category Stream, title string, store *storage.Sto
|
||||||
}
|
}
|
||||||
|
|
||||||
created, localizedError := mff.CreateFeed(store, userID, &feedRequest)
|
created, localizedError := mff.CreateFeed(store, userID, &feedRequest)
|
||||||
if err != nil {
|
if localizedError != nil {
|
||||||
return nil, localizedError.Error()
|
return nil, localizedError.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -908,7 +910,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||||
slog.Int64("user_id", userID),
|
slog.Int64("user_id", userID),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := checkOutputFormat(w, r); err != nil {
|
if err := checkOutputFormat(r); err != nil {
|
||||||
json.BadRequest(w, r, err)
|
json.BadRequest(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1001,14 +1003,14 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||||
categories = append(categories, userStarred)
|
categories = append(categories, userStarred)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Content = proxy.AbsoluteProxyRewriter(h.router, r.Host, entry.Content)
|
entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, r.Host, entry.Content)
|
||||||
proxyOption := config.Opts.ProxyOption()
|
proxyOption := config.Opts.MediaProxyMode()
|
||||||
|
|
||||||
for i := range entry.Enclosures {
|
for i := range entry.Enclosures {
|
||||||
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(entry.Enclosures[i].URL) {
|
||||||
for _, mediaType := range config.Opts.ProxyMediaTypes() {
|
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||||
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
if strings.HasPrefix(entry.Enclosures[i].MimeType, mediaType+"/") {
|
||||||
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
|
entry.Enclosures[i].URL = mediaproxy.ProxifyAbsoluteURL(h.router, r.Host, entry.Enclosures[i].URL)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1019,10 +1021,10 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||||
ID: fmt.Sprintf(EntryIDLong, entry.ID),
|
ID: fmt.Sprintf(EntryIDLong, entry.ID),
|
||||||
Title: entry.Title,
|
Title: entry.Title,
|
||||||
Author: entry.Author,
|
Author: entry.Author,
|
||||||
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
|
TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixMicro()),
|
||||||
CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
|
CrawlTimeMsec: fmt.Sprintf("%d", entry.CreatedAt.UnixMilli()),
|
||||||
Published: entry.Date.Unix(),
|
Published: entry.Date.Unix(),
|
||||||
Updated: entry.Date.Unix(),
|
Updated: entry.ChangedAt.Unix(),
|
||||||
Categories: categories,
|
Categories: categories,
|
||||||
Canonical: []contentHREF{
|
Canonical: []contentHREF{
|
||||||
{
|
{
|
||||||
|
@ -1170,7 +1172,7 @@ func (h *handler) tagListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
slog.String("user_agent", r.UserAgent()),
|
slog.String("user_agent", r.UserAgent()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := checkOutputFormat(w, r); err != nil {
|
if err := checkOutputFormat(r); err != nil {
|
||||||
json.BadRequest(w, r, err)
|
json.BadRequest(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1205,7 +1207,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||||
slog.String("user_agent", r.UserAgent()),
|
slog.String("user_agent", r.UserAgent()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := checkOutputFormat(w, r); err != nil {
|
if err := checkOutputFormat(r); err != nil {
|
||||||
json.BadRequest(w, r, err)
|
json.BadRequest(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1224,7 +1226,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||||
URL: feed.FeedURL,
|
URL: feed.FeedURL,
|
||||||
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||||
HTMLURL: feed.SiteURL,
|
HTMLURL: feed.SiteURL,
|
||||||
IconURL: "", //TODO Icons are only base64 encode in DB yet
|
IconURL: "", // TODO: Icons are base64 encoded in the DB.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
json.OK(w, r, result)
|
json.OK(w, r, result)
|
||||||
|
@ -1251,7 +1253,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
slog.String("user_agent", r.UserAgent()),
|
slog.String("user_agent", r.UserAgent()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := checkOutputFormat(w, r); err != nil {
|
if err := checkOutputFormat(r); err != nil {
|
||||||
json.BadRequest(w, r, err)
|
json.BadRequest(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1276,7 +1278,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
slog.Int64("user_id", userID),
|
slog.Int64("user_id", userID),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := checkOutputFormat(w, r); err != nil {
|
if err := checkOutputFormat(r); err != nil {
|
||||||
json.BadRequest(w, r, err)
|
json.BadRequest(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1477,8 +1479,7 @@ func (h *handler) handleFeedStreamHandler(w http.ResponseWriter, r *http.Request
|
||||||
|
|
||||||
if len(rm.ExcludeTargets) > 0 {
|
if len(rm.ExcludeTargets) > 0 {
|
||||||
for _, s := range rm.ExcludeTargets {
|
for _, s := range rm.ExcludeTargets {
|
||||||
switch s.Type {
|
if s.Type == ReadStream {
|
||||||
case ReadStream:
|
|
||||||
builder.WithoutStatus(model.EntryStatusRead)
|
builder.WithoutStatus(model.EntryStatusRead)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,14 @@ package cookie // import "miniflux.app/v2/internal/http/cookie"
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cookie names.
|
// Cookie names.
|
||||||
const (
|
const (
|
||||||
CookieAppSessionID = "MinifluxAppSessionID"
|
CookieAppSessionID = "MinifluxAppSessionID"
|
||||||
CookieUserSessionID = "MinifluxUserSessionID"
|
CookieUserSessionID = "MinifluxUserSessionID"
|
||||||
|
|
||||||
// Cookie duration in days.
|
|
||||||
cookieDuration = 30
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// New creates a new cookie.
|
// New creates a new cookie.
|
||||||
|
@ -25,7 +24,7 @@ func New(name, value string, isHTTPS bool, path string) *http.Cookie {
|
||||||
Path: basePath(path),
|
Path: basePath(path),
|
||||||
Secure: isHTTPS,
|
Secure: isHTTPS,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
|
Expires: time.Now().Add(time.Duration(config.Opts.CleanupRemoveSessionsDays()) * 24 * time.Hour),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,14 +37,10 @@ const (
|
||||||
|
|
||||||
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
|
||||||
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
|
||||||
value, valid := v.(model.WebAuthnSession)
|
if value, valid := v.(model.WebAuthnSession); valid {
|
||||||
if !valid {
|
return &value
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,39 +147,27 @@ func ClientIP(r *http.Request) string {
|
||||||
|
|
||||||
func getContextStringValue(r *http.Request, key ContextKey) string {
|
func getContextStringValue(r *http.Request, key ContextKey) string {
|
||||||
if v := r.Context().Value(key); v != nil {
|
if v := r.Context().Value(key); v != nil {
|
||||||
value, valid := v.(string)
|
if value, valid := v.(string); valid {
|
||||||
if !valid {
|
return value
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContextBoolValue(r *http.Request, key ContextKey) bool {
|
func getContextBoolValue(r *http.Request, key ContextKey) bool {
|
||||||
if v := r.Context().Value(key); v != nil {
|
if v := r.Context().Value(key); v != nil {
|
||||||
value, valid := v.(bool)
|
if value, valid := v.(bool); valid {
|
||||||
if !valid {
|
return value
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContextInt64Value(r *http.Request, key ContextKey) int64 {
|
func getContextInt64Value(r *http.Request, key ContextKey) int64 {
|
||||||
if v := r.Context().Value(key); v != nil {
|
if v := r.Context().Value(key); v != nil {
|
||||||
value, valid := v.(int64)
|
if value, valid := v.(int64); valid {
|
||||||
if !valid {
|
return value
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andybalholm/brotli"
|
||||||
)
|
)
|
||||||
|
|
||||||
const compressionThreshold = 1024
|
const compressionThreshold = 1024
|
||||||
|
@ -96,7 +98,6 @@ func (b *Builder) Write() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Builder) writeHeaders() {
|
func (b *Builder) writeHeaders() {
|
||||||
b.headers["X-XSS-Protection"] = "1; mode=block"
|
|
||||||
b.headers["X-Content-Type-Options"] = "nosniff"
|
b.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
b.headers["X-Frame-Options"] = "DENY"
|
b.headers["X-Frame-Options"] = "DENY"
|
||||||
b.headers["Referrer-Policy"] = "no-referrer"
|
b.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
@ -111,8 +112,15 @@ func (b *Builder) writeHeaders() {
|
||||||
func (b *Builder) compress(data []byte) {
|
func (b *Builder) compress(data []byte) {
|
||||||
if b.enableCompression && len(data) > compressionThreshold {
|
if b.enableCompression && len(data) > compressionThreshold {
|
||||||
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
acceptEncoding := b.r.Header.Get("Accept-Encoding")
|
||||||
|
|
||||||
switch {
|
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"):
|
case strings.Contains(acceptEncoding, "gzip"):
|
||||||
b.headers["Content-Encoding"] = "gzip"
|
b.headers["Content-Encoding"] = "gzip"
|
||||||
b.writeHeaders()
|
b.writeHeaders()
|
||||||
|
|
|
@ -28,7 +28,6 @@ func TestResponseHasCommonHeaders(t *testing.T) {
|
||||||
resp := w.Result()
|
resp := w.Result()
|
||||||
|
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
"X-XSS-Protection": "1; mode=block",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
}
|
}
|
||||||
|
@ -229,7 +228,7 @@ func TestBuildResponseWithCachingAndEtag(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildResponseWithGzipCompression(t *testing.T) {
|
func TestBuildResponseWithBrotliCompression(t *testing.T) {
|
||||||
body := strings.Repeat("a", compressionThreshold+1)
|
body := strings.Repeat("a", compressionThreshold+1)
|
||||||
r, err := http.NewRequest("GET", "/", nil)
|
r, err := http.NewRequest("GET", "/", nil)
|
||||||
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
r.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||||
|
@ -246,6 +245,30 @@ func TestBuildResponseWithGzipCompression(t *testing.T) {
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
resp := w.Result()
|
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"
|
expected := "gzip"
|
||||||
actual := resp.Header.Get("Content-Encoding")
|
actual := resp.Header.Get("Content-Encoding")
|
||||||
if actual != expected {
|
if actual != expected {
|
||||||
|
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"miniflux.app/v2/internal/integration/omnivore"
|
"miniflux.app/v2/internal/integration/omnivore"
|
||||||
"miniflux.app/v2/internal/integration/pinboard"
|
"miniflux.app/v2/internal/integration/pinboard"
|
||||||
"miniflux.app/v2/internal/integration/pocket"
|
"miniflux.app/v2/internal/integration/pocket"
|
||||||
|
"miniflux.app/v2/internal/integration/raindrop"
|
||||||
"miniflux.app/v2/internal/integration/readeck"
|
"miniflux.app/v2/internal/integration/readeck"
|
||||||
"miniflux.app/v2/internal/integration/readwise"
|
"miniflux.app/v2/internal/integration/readwise"
|
||||||
"miniflux.app/v2/internal/integration/shaarli"
|
"miniflux.app/v2/internal/integration/shaarli"
|
||||||
|
@ -359,6 +360,7 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if userIntegrations.OmnivoreEnabled {
|
if userIntegrations.OmnivoreEnabled {
|
||||||
slog.Debug("Sending entry to Omnivore",
|
slog.Debug("Sending entry to Omnivore",
|
||||||
slog.Int64("user_id", userIntegrations.UserID),
|
slog.Int64("user_id", userIntegrations.UserID),
|
||||||
|
@ -376,6 +378,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.
|
// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"miniflux.app/v2/internal/model"
|
"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 {
|
func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixUsername, matrixPassword, matrixRoomID string) error {
|
||||||
client := NewClient(matrixBaseURL)
|
client := NewClient(matrixBaseURL)
|
||||||
discovery, err := client.DiscoverEndpoints()
|
discovery, err := client.DiscoverEndpoints()
|
||||||
|
|
|
@ -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"`
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package rssbridge // import "miniflux.app/integration/rssbridge"
|
package rssbridge // import "miniflux.app/v2/internal/integration/rssbridge"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"miniflux.app/v2/internal/urllib"
|
"miniflux.app/v2/internal/urllib"
|
||||||
|
@ -74,14 +73,15 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) generateBearerToken() string {
|
func (c *Client) generateBearerToken() string {
|
||||||
header := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(`{"typ":"JWT", "alg":"HS256"}`)), "=")
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS512"}`))
|
||||||
payload := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat": %d}`, time.Now().Unix()))), "=")
|
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat":%d}`, time.Now().Unix())))
|
||||||
|
data := header + "." + payload
|
||||||
|
|
||||||
mac := hmac.New(sha512.New, []byte(c.apiSecret))
|
mac := hmac.New(sha512.New, []byte(c.apiSecret))
|
||||||
mac.Write([]byte(header + "." + payload))
|
mac.Write([]byte(data))
|
||||||
signature := strings.TrimRight(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=")
|
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
return header + "." + payload + "." + signature
|
return data + "." + signature
|
||||||
}
|
}
|
||||||
|
|
||||||
type addLinkRequest struct {
|
type addLinkRequest struct {
|
||||||
|
|
|
@ -11,7 +11,8 @@ import (
|
||||||
|
|
||||||
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
|
func PushEntry(feed *model.Feed, entry *model.Entry, botToken, chatID string, topicID *int64, disableWebPagePreview, disableNotification bool, disableButtons bool) error {
|
||||||
formattedText := fmt.Sprintf(
|
formattedText := fmt.Sprintf(
|
||||||
`<a href=%q>%s</a>`,
|
`<b>%s</b> - <a href=%q>%s</a>`,
|
||||||
|
feed.Title,
|
||||||
entry.URL,
|
entry.URL,
|
||||||
entry.Title,
|
entry.Title,
|
||||||
)
|
)
|
||||||
|
|
|
@ -57,6 +57,7 @@ func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error {
|
||||||
ID: entry.Feed.ID,
|
ID: entry.Feed.ID,
|
||||||
UserID: entry.Feed.UserID,
|
UserID: entry.Feed.UserID,
|
||||||
CategoryID: entry.Feed.Category.ID,
|
CategoryID: entry.Feed.Category.ID,
|
||||||
|
Category: &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title},
|
||||||
FeedURL: entry.Feed.FeedURL,
|
FeedURL: entry.Feed.FeedURL,
|
||||||
SiteURL: entry.Feed.SiteURL,
|
SiteURL: entry.Feed.SiteURL,
|
||||||
Title: entry.Feed.Title,
|
Title: entry.Feed.Title,
|
||||||
|
@ -94,13 +95,13 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr
|
||||||
Tags: entry.Tags,
|
Tags: entry.Tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{
|
return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{
|
||||||
EventType: NewEntriesEventType,
|
EventType: NewEntriesEventType,
|
||||||
Feed: &WebhookFeed{
|
Feed: &WebhookFeed{
|
||||||
ID: feed.ID,
|
ID: feed.ID,
|
||||||
UserID: feed.UserID,
|
UserID: feed.UserID,
|
||||||
CategoryID: feed.Category.ID,
|
CategoryID: feed.Category.ID,
|
||||||
|
Category: &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title},
|
||||||
FeedURL: feed.FeedURL,
|
FeedURL: feed.FeedURL,
|
||||||
SiteURL: feed.SiteURL,
|
SiteURL: feed.SiteURL,
|
||||||
Title: feed.Title,
|
Title: feed.Title,
|
||||||
|
@ -145,13 +146,19 @@ func (c *Client) makeRequest(eventType string, payload any) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebhookFeed struct {
|
type WebhookFeed struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
CategoryID int64 `json:"category_id"`
|
CategoryID int64 `json:"category_id"`
|
||||||
FeedURL string `json:"feed_url"`
|
Category *WebhookCategory `json:"category,omitempty"`
|
||||||
SiteURL string `json:"site_url"`
|
FeedURL string `json:"feed_url"`
|
||||||
Title string `json:"title"`
|
SiteURL string `json:"site_url"`
|
||||||
CheckedAt time.Time `json:"checked_at"`
|
Title string `json:"title"`
|
||||||
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookCategory struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebhookEntry struct {
|
type WebhookEntry struct {
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
|
"page.keyboard_shortcuts.go_to_feed": "Zum Abonnement gehen",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
|
"page.keyboard_shortcuts.go_to_previous_page": "Zur vorherigen Seite gehen",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
|
"page.keyboard_shortcuts.go_to_next_page": "Zur nächsten Seite gehen",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Gehen Sie zum untersten Element",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Zum obersten Artikel gehen",
|
||||||
"page.keyboard_shortcuts.open_item": "Gewählten Artikel öffnen",
|
"page.keyboard_shortcuts.open_item": "Gewählten Artikel öffnen",
|
||||||
"page.keyboard_shortcuts.open_original": "Original-Artikel öffnen",
|
"page.keyboard_shortcuts.open_original": "Original-Artikel öffnen",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Öffne den Original-Link in der aktuellen Registerkarte",
|
"page.keyboard_shortcuts.open_original_same_window": "Öffne den Original-Link in der aktuellen Registerkarte",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
|
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
|
||||||
"alert.no_category": "Es ist keine Kategorie vorhanden.",
|
"alert.no_category": "Es ist keine Kategorie vorhanden.",
|
||||||
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
|
"alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
|
||||||
|
"alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
|
||||||
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
|
"alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
|
||||||
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
|
"alert.no_feed": "Es sind keine Abonnements vorhanden.",
|
||||||
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
|
"alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Titel",
|
"form.feed.label.title": "Titel",
|
||||||
"form.feed.label.site_url": "URL der Webseite",
|
"form.feed.label.site_url": "URL der Webseite",
|
||||||
"form.feed.label.feed_url": "URL des Abonnements",
|
"form.feed.label.feed_url": "URL des Abonnements",
|
||||||
|
"form.feed.label.description": "Beschreibung",
|
||||||
"form.feed.label.category": "Kategorie",
|
"form.feed.label.category": "Kategorie",
|
||||||
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
"form.feed.label.crawler": "Originalinhalt herunterladen",
|
||||||
"form.feed.label.feed_username": "Benutzername des Abonnements",
|
"form.feed.label.feed_username": "Benutzername des Abonnements",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
|
"error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
|
||||||
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
|
"error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
|
||||||
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
"error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
|
||||||
"error.tls_error": "TLS-Fehler: %v. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.",
|
"error.tls_error": "TLS-Fehler: %q. Wenn Sie mögen, können Sie versuchen die TLS-Verifizierung in den Einstellungen des Abonnements zu deaktivieren.",
|
||||||
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
|
"error.network_operation": "Miniflux kann die Webseite aufgrund eines Netzwerk-Fehlers nicht erreichen: %v",
|
||||||
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
|
"error.network_timeout": "Die Webseite ist zu langsam und die Anfrage ist abgelaufen: %v.",
|
||||||
"error.http_client_error": "HTTP-Client-Fehler: %v.",
|
"error.http_client_error": "HTTP-Client-Fehler: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
|
"error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
|
||||||
"error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
|
"error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
|
||||||
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
|
"error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
|
||||||
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v."
|
"error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
|
||||||
|
"error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
|
"page.keyboard_shortcuts.go_to_feed": "Πηγαίνετε στη ροή",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
|
"page.keyboard_shortcuts.go_to_previous_page": "Μετάβαση στην προηγούμενη σελίδα",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
|
"page.keyboard_shortcuts.go_to_next_page": "Μετάβαση στην επόμενη σελίδα",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Μετάβαση στο κάτω στοιχείο",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Μετάβαση στο επάνω στοιχείο",
|
||||||
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
|
"page.keyboard_shortcuts.open_item": "Άνοιγμα επιλεγμένου στοιχείου",
|
||||||
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
|
"page.keyboard_shortcuts.open_original": "Άνοιγμα αρχικού συνδέσμου",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
|
"page.keyboard_shortcuts.open_original_same_window": "Άνοιγμα αρχικού συνδέσμου στην τρέχουσα καρτέλα",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
"alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
|
||||||
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
"alert.no_category": "Δεν υπάρχει κατηγορία.",
|
||||||
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
"alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
|
||||||
|
"alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
|
||||||
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
|
"alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
|
||||||
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
"alert.no_feed": "Δεν έχετε συνδρομές.",
|
||||||
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
|
"alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
|
||||||
|
@ -316,6 +319,7 @@
|
||||||
"form.feed.label.title": "Τίτλος",
|
"form.feed.label.title": "Τίτλος",
|
||||||
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
|
"form.feed.label.site_url": "Διεύθυνση URL ιστότοπου",
|
||||||
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
|
"form.feed.label.feed_url": "Διεύθυνση URL ροής",
|
||||||
|
"form.feed.label.description": "Περιγραφή",
|
||||||
"form.feed.label.category": "Κατηγορία",
|
"form.feed.label.category": "Κατηγορία",
|
||||||
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
"form.feed.label.crawler": "Λήψη αρχικού περιεχομένου",
|
||||||
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
|
"form.feed.label.feed_username": "Όνομα Χρήστη ροής",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
||||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||||
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
|
||||||
|
"error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,6 +176,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
|
"page.keyboard_shortcuts.go_to_previous_item": "Go to previous item",
|
||||||
"page.keyboard_shortcuts.go_to_next_item": "Go to next item",
|
"page.keyboard_shortcuts.go_to_next_item": "Go to next item",
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Go to feed",
|
"page.keyboard_shortcuts.go_to_feed": "Go to feed",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Go to top item",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Go to bottom item",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
|
"page.keyboard_shortcuts.go_to_previous_page": "Go to previous page",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Go to next page",
|
"page.keyboard_shortcuts.go_to_next_page": "Go to next page",
|
||||||
"page.keyboard_shortcuts.open_item": "Open selected item",
|
"page.keyboard_shortcuts.open_item": "Open selected item",
|
||||||
|
@ -215,7 +217,7 @@
|
||||||
"page.settings.webauthn.last_seen_on": "Last Used",
|
"page.settings.webauthn.last_seen_on": "Last Used",
|
||||||
"page.settings.webauthn.register": "Register passkey",
|
"page.settings.webauthn.register": "Register passkey",
|
||||||
"page.settings.webauthn.register.error": "Unable to register passkey",
|
"page.settings.webauthn.register.error": "Unable to register passkey",
|
||||||
"page.settings.webauthn.delete" : [
|
"page.settings.webauthn.delete": [
|
||||||
"Remove %d passkey",
|
"Remove %d passkey",
|
||||||
"Remove %d passkeys"
|
"Remove %d passkeys"
|
||||||
],
|
],
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "There are no starred entries.",
|
"alert.no_bookmark": "There are no starred entries.",
|
||||||
"alert.no_category": "There is no category.",
|
"alert.no_category": "There is no category.",
|
||||||
"alert.no_category_entry": "There are no entries in this category.",
|
"alert.no_category_entry": "There are no entries in this category.",
|
||||||
|
"alert.no_tag_entry": "There are no entries matching this tag.",
|
||||||
"alert.no_feed_entry": "There are no entries for this feed.",
|
"alert.no_feed_entry": "There are no entries for this feed.",
|
||||||
"alert.no_feed": "You don’t have any feeds.",
|
"alert.no_feed": "You don’t have any feeds.",
|
||||||
"alert.no_feed_in_category": "There is no feed for this category.",
|
"alert.no_feed_in_category": "There is no feed for this category.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Title",
|
"form.feed.label.title": "Title",
|
||||||
"form.feed.label.site_url": "Site URL",
|
"form.feed.label.site_url": "Site URL",
|
||||||
"form.feed.label.feed_url": "Feed URL",
|
"form.feed.label.feed_url": "Feed URL",
|
||||||
|
"form.feed.label.description": "Description",
|
||||||
"form.feed.label.category": "Category",
|
"form.feed.label.category": "Category",
|
||||||
"form.feed.label.crawler": "Fetch original content",
|
"form.feed.label.crawler": "Fetch original content",
|
||||||
"form.feed.label.feed_username": "Feed Username",
|
"form.feed.label.feed_username": "Feed Username",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||||
"form.integration.matrix_bot_url": "Matrix server URL",
|
"form.integration.matrix_bot_url": "Matrix server URL",
|
||||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Save entries to readeck",
|
"form.integration.readeck_activate": "Save entries to readeck",
|
||||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||||
"form.integration.readeck_api_key": "Readeck API key",
|
"form.integration.readeck_api_key": "Readeck API key",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
|
||||||
|
"error.settings_media_playback_rate_range": "Playback speed is out of range",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
|
"page.keyboard_shortcuts.go_to_feed": "Ir a la fuente",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
|
"page.keyboard_shortcuts.go_to_previous_page": "Ir al página anterior",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
|
"page.keyboard_shortcuts.go_to_next_page": "Ir al página siguiente",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Ir al elemento inferior",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Ir al elemento superior",
|
||||||
"page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado",
|
"page.keyboard_shortcuts.open_item": "Abrir el elemento seleccionado",
|
||||||
"page.keyboard_shortcuts.open_original": "Abrir el enlace original",
|
"page.keyboard_shortcuts.open_original": "Abrir el enlace original",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Abrir enlace original en la pestaña actual",
|
"page.keyboard_shortcuts.open_original_same_window": "Abrir enlace original en la pestaña actual",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "No hay marcador en este momento.",
|
"alert.no_bookmark": "No hay marcador en este momento.",
|
||||||
"alert.no_category": "No hay categoría.",
|
"alert.no_category": "No hay categoría.",
|
||||||
"alert.no_category_entry": "No hay artículos en esta categoría.",
|
"alert.no_category_entry": "No hay artículos en esta categoría.",
|
||||||
|
"alert.no_tag_entry": "No hay artículos con esta etiqueta.",
|
||||||
"alert.no_feed_entry": "No hay artículos para esta fuente.",
|
"alert.no_feed_entry": "No hay artículos para esta fuente.",
|
||||||
"alert.no_feed": "No tienes fuentes.",
|
"alert.no_feed": "No tienes fuentes.",
|
||||||
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
|
"alert.no_feed_in_category": "No hay fuentes para esta categoría.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Título",
|
"form.feed.label.title": "Título",
|
||||||
"form.feed.label.site_url": "URL del sitio",
|
"form.feed.label.site_url": "URL del sitio",
|
||||||
"form.feed.label.feed_url": "URL de la fuente",
|
"form.feed.label.feed_url": "URL de la fuente",
|
||||||
|
"form.feed.label.description": "Descripción",
|
||||||
"form.feed.label.category": "Categoría",
|
"form.feed.label.category": "Categoría",
|
||||||
"form.feed.label.crawler": "Obtener rastreador original",
|
"form.feed.label.crawler": "Obtener rastreador original",
|
||||||
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
|
"form.feed.label.feed_username": "Nombre de usuario de la fuente",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
|
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
||||||
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
||||||
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
|
||||||
|
"error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango",
|
||||||
|
"enclosure_media_controls.seek" : "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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
|
"page.keyboard_shortcuts.go_to_feed": "Siirry syötteeseen",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
|
"page.keyboard_shortcuts.go_to_previous_page": "Siirry edelliselle sivulle",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
|
"page.keyboard_shortcuts.go_to_next_page": "Siirry seuraavalle sivulle",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Siirry alimpaan kohtaan",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Siirry alkuun",
|
||||||
"page.keyboard_shortcuts.open_item": "Avaa valittu kohde",
|
"page.keyboard_shortcuts.open_item": "Avaa valittu kohde",
|
||||||
"page.keyboard_shortcuts.open_original": "Avaa alkuperäinen linkki",
|
"page.keyboard_shortcuts.open_original": "Avaa alkuperäinen linkki",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Avaa alkuperäinen linkki nykyisessä välilehdessä",
|
"page.keyboard_shortcuts.open_original_same_window": "Avaa alkuperäinen linkki nykyisessä välilehdessä",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
"alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
|
||||||
"alert.no_category": "Ei ole kategoriaa.",
|
"alert.no_category": "Ei ole kategoriaa.",
|
||||||
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
|
"alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
|
||||||
|
"alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
|
||||||
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
|
"alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
|
||||||
"alert.no_feed": "Sinulla ei ole tilauksia.",
|
"alert.no_feed": "Sinulla ei ole tilauksia.",
|
||||||
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
|
"alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
|
||||||
|
@ -316,6 +319,7 @@
|
||||||
"form.feed.label.title": "Otsikko",
|
"form.feed.label.title": "Otsikko",
|
||||||
"form.feed.label.site_url": "Sivuston URL-osoite",
|
"form.feed.label.site_url": "Sivuston URL-osoite",
|
||||||
"form.feed.label.feed_url": "Syötteen URL-osoite",
|
"form.feed.label.feed_url": "Syötteen URL-osoite",
|
||||||
|
"form.feed.label.description": "Kuvaus",
|
||||||
"form.feed.label.category": "Kategoria",
|
"form.feed.label.category": "Kategoria",
|
||||||
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
|
"form.feed.label.crawler": "Nouda alkuperäinen sisältö",
|
||||||
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
|
"form.feed.label.feed_username": "Syötteen käyttäjätunnus",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
||||||
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
||||||
"form.integration.readeck_api_key": "Readeck API-avain",
|
"form.integration.readeck_api_key": "Readeck API-avain",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
|
||||||
|
"error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
|
"page.keyboard_shortcuts.go_to_feed": "Voir abonnement",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
|
"page.keyboard_shortcuts.go_to_previous_page": "Page précédente",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Page suivante",
|
"page.keyboard_shortcuts.go_to_next_page": "Page suivante",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Aller à l'élément du bas",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Aller à l'élément supérieur",
|
||||||
"page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné",
|
"page.keyboard_shortcuts.open_item": "Ouvrir élément sélectionné",
|
||||||
"page.keyboard_shortcuts.open_original": "Ouvrir le lien original",
|
"page.keyboard_shortcuts.open_original": "Ouvrir le lien original",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Ouvrir le lien original dans l'onglet en cours",
|
"page.keyboard_shortcuts.open_original_same_window": "Ouvrir le lien original dans l'onglet en cours",
|
||||||
|
@ -215,7 +217,7 @@
|
||||||
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
|
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
|
||||||
"page.settings.webauthn.register": "Enregister une nouvelle clé d’accès",
|
"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.settings.webauthn.register.error": "Impossible d'enregistrer la clé d’accès",
|
||||||
"page.settings.webauthn.delete" : [
|
"page.settings.webauthn.delete": [
|
||||||
"Supprimer %d clé d’accès",
|
"Supprimer %d clé d’accès",
|
||||||
"Supprimer %d clés d’accès"
|
"Supprimer %d clés d’accès"
|
||||||
],
|
],
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
|
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
|
||||||
"alert.no_category": "Il n'y a aucune catégorie.",
|
"alert.no_category": "Il n'y a aucune catégorie.",
|
||||||
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
|
"alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
|
||||||
|
"alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
|
||||||
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
|
"alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
|
||||||
"alert.no_feed": "Vous n'avez aucun abonnement.",
|
"alert.no_feed": "Vous n'avez aucun abonnement.",
|
||||||
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
|
"alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Titre",
|
"form.feed.label.title": "Titre",
|
||||||
"form.feed.label.site_url": "URL du site web",
|
"form.feed.label.site_url": "URL du site web",
|
||||||
"form.feed.label.feed_url": "URL du flux",
|
"form.feed.label.feed_url": "URL du flux",
|
||||||
|
"form.feed.label.description": "Description",
|
||||||
"form.feed.label.category": "Catégorie",
|
"form.feed.label.category": "Catégorie",
|
||||||
"form.feed.label.crawler": "Récupérer le contenu original",
|
"form.feed.label.crawler": "Récupérer le contenu original",
|
||||||
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
|
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL du serveur Matrix",
|
"form.integration.matrix_bot_url": "URL du serveur Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
||||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||||
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
|
"error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
|
||||||
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
|
"error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
|
||||||
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
|
"error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
|
||||||
"error.tls_error": "Erreur TLS : %v. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
|
"error.tls_error": "Erreur TLS : %q. Vous pouvez désactiver la vérification TLS dans les paramètres de l'abonnement.",
|
||||||
"error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.",
|
"error.network_operation": "Miniflux n'est pas en mesure de se connecter à ce site web à cause d'un problème réseau : %v.",
|
||||||
"error.network_timeout": "Ce site web est trop lent à répondre : %v.",
|
"error.network_timeout": "Ce site web est trop lent à répondre : %v.",
|
||||||
"error.http_client_error": "Erreur du client HTTP : %v.",
|
"error.http_client_error": "Erreur du client HTTP : %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
|
"error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
|
||||||
"error.feed_not_found": "Impossible de trouver ce flux.",
|
"error.feed_not_found": "Impossible de trouver ce flux.",
|
||||||
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v."
|
"error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
|
||||||
|
"error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites",
|
||||||
|
"enclosure_media_controls.seek" : "Avancer/Reculer :",
|
||||||
|
"enclosure_media_controls.seek.title" : "Avancer/Reculer de %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Vitesse :",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Accélérer",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Accélérer de %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Ralentir",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Ralentir de %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Réinitialiser",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Réinitialiser la vitesse de lecture à 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
|
"page.keyboard_shortcuts.go_to_feed": "फ़ीड पर जाएं",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
|
"page.keyboard_shortcuts.go_to_previous_page": "पिछले पृष्ठ पर जाएं",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
|
"page.keyboard_shortcuts.go_to_next_page": "अगले पेज पर जाएं",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "निचले आइटम पर जाएँ",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "शीर्ष आइटम पर जाएँ",
|
||||||
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
|
"page.keyboard_shortcuts.open_item": "चयनित आइटम खोलें",
|
||||||
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
|
"page.keyboard_shortcuts.open_original": "मूल लिंक खोलें",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
|
"page.keyboard_shortcuts.open_original_same_window": "वर्तमान टैब में मूल लिंक खोलें",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
"alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
|
||||||
"alert.no_category": "कोई श्रेणी नहीं है।",
|
"alert.no_category": "कोई श्रेणी नहीं है।",
|
||||||
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
"alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
|
||||||
|
"alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
|
||||||
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
|
"alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
|
||||||
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
"alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
|
||||||
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
|
"alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "शीर्षक",
|
"form.feed.label.title": "शीर्षक",
|
||||||
"form.feed.label.site_url": "साइट यूआरएल",
|
"form.feed.label.site_url": "साइट यूआरएल",
|
||||||
"form.feed.label.feed_url": "फ़ीड यूआरएल",
|
"form.feed.label.feed_url": "फ़ीड यूआरएल",
|
||||||
|
"form.feed.label.description": "विवरण",
|
||||||
"form.feed.label.category": "श्रेणी",
|
"form.feed.label.category": "श्रेणी",
|
||||||
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
"form.feed.label.crawler": "मूल सामग्री प्राप्त करें",
|
||||||
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
|
"form.feed.label.feed_username": "फ़ीड उपयोगकर्ता नाम",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||||
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
||||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
|
||||||
|
"error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,6 +169,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Ke umpan",
|
"page.keyboard_shortcuts.go_to_feed": "Ke umpan",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
|
"page.keyboard_shortcuts.go_to_previous_page": "Ke halaman sebelumnya",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
|
"page.keyboard_shortcuts.go_to_next_page": "Ke halaman berikutnya",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Pergi ke item paling bawah",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Pergi ke item teratas",
|
||||||
"page.keyboard_shortcuts.open_item": "Buka entri yang dipilih",
|
"page.keyboard_shortcuts.open_item": "Buka entri yang dipilih",
|
||||||
"page.keyboard_shortcuts.open_original": "Buka tautan asli",
|
"page.keyboard_shortcuts.open_original": "Buka tautan asli",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Buka tautan asli di bilah saat ini",
|
"page.keyboard_shortcuts.open_original_same_window": "Buka tautan asli di bilah saat ini",
|
||||||
|
@ -246,6 +248,7 @@
|
||||||
"alert.no_bookmark": "Tidak ada markah.",
|
"alert.no_bookmark": "Tidak ada markah.",
|
||||||
"alert.no_category": "Tidak ada kategori.",
|
"alert.no_category": "Tidak ada kategori.",
|
||||||
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
|
"alert.no_category_entry": "Tidak ada artikel di kategori ini.",
|
||||||
|
"alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
|
||||||
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
|
"alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
|
||||||
"alert.no_feed": "Anda tidak memiliki langganan.",
|
"alert.no_feed": "Anda tidak memiliki langganan.",
|
||||||
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
|
"alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
|
||||||
|
@ -304,6 +307,7 @@
|
||||||
"form.feed.label.title": "Judul",
|
"form.feed.label.title": "Judul",
|
||||||
"form.feed.label.site_url": "URL Situs",
|
"form.feed.label.site_url": "URL Situs",
|
||||||
"form.feed.label.feed_url": "URL Umpan",
|
"form.feed.label.feed_url": "URL Umpan",
|
||||||
|
"form.feed.label.description": "Deskripsi",
|
||||||
"form.feed.label.category": "Kategori",
|
"form.feed.label.category": "Kategori",
|
||||||
"form.feed.label.crawler": "Ambil konten asli",
|
"form.feed.label.crawler": "Ambil konten asli",
|
||||||
"form.feed.label.feed_username": "Nama Pengguna Umpan",
|
"form.feed.label.feed_username": "Nama Pengguna Umpan",
|
||||||
|
@ -439,6 +443,10 @@
|
||||||
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
||||||
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
||||||
"form.integration.readeck_api_key": "Kunci API Readeck",
|
"form.integration.readeck_api_key": "Kunci API Readeck",
|
||||||
|
@ -488,7 +496,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -507,5 +515,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
|
||||||
|
"error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
|
"page.keyboard_shortcuts.go_to_feed": "Mostra il feed",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
|
"page.keyboard_shortcuts.go_to_previous_page": "Mostra la pagina precedente",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
|
"page.keyboard_shortcuts.go_to_next_page": "Mostra la pagina successiva",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Vai all'elemento in fondo",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Vai all'elemento principale",
|
||||||
"page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato",
|
"page.keyboard_shortcuts.open_item": "Apri l'articolo selezionato",
|
||||||
"page.keyboard_shortcuts.open_original": "Apri la pagina web originale",
|
"page.keyboard_shortcuts.open_original": "Apri la pagina web originale",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Apri il link originale nella scheda corrente",
|
"page.keyboard_shortcuts.open_original_same_window": "Apri il link originale nella scheda corrente",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Nessun preferito disponibile.",
|
"alert.no_bookmark": "Nessun preferito disponibile.",
|
||||||
"alert.no_category": "Nessuna categoria disponibile.",
|
"alert.no_category": "Nessuna categoria disponibile.",
|
||||||
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
|
"alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
|
||||||
|
"alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
|
||||||
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
|
"alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
|
||||||
"alert.no_feed": "Nessun feed disponibile.",
|
"alert.no_feed": "Nessun feed disponibile.",
|
||||||
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
|
"alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Titolo",
|
"form.feed.label.title": "Titolo",
|
||||||
"form.feed.label.site_url": "URL del sito",
|
"form.feed.label.site_url": "URL del sito",
|
||||||
"form.feed.label.feed_url": "URL del feed",
|
"form.feed.label.feed_url": "URL del feed",
|
||||||
|
"form.feed.label.description": "Descrizione",
|
||||||
"form.feed.label.category": "Categoria",
|
"form.feed.label.category": "Categoria",
|
||||||
"form.feed.label.crawler": "Scarica il contenuto integrale",
|
"form.feed.label.crawler": "Scarica il contenuto integrale",
|
||||||
"form.feed.label.feed_username": "Nome utente del feed",
|
"form.feed.label.feed_username": "Nome utente del feed",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL del server Matrix",
|
"form.integration.matrix_bot_url": "URL del server Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
|
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
|
||||||
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
|
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
|
||||||
"form.integration.readeck_api_key": "API key dell'account Readeck",
|
"form.integration.readeck_api_key": "API key dell'account Readeck",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
|
||||||
|
"error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,6 +169,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "フィード",
|
"page.keyboard_shortcuts.go_to_feed": "フィード",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "前のページ",
|
"page.keyboard_shortcuts.go_to_previous_page": "前のページ",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "次のページ",
|
"page.keyboard_shortcuts.go_to_next_page": "次のページ",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "一番下の項目に移動",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "先頭の項目に移動",
|
||||||
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
|
"page.keyboard_shortcuts.open_item": "選択されたアイテムを開く",
|
||||||
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
|
"page.keyboard_shortcuts.open_original": "オリジナルのリンクを開く",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
|
"page.keyboard_shortcuts.open_original_same_window": "現在のタブでオリジナルのリンクを開く",
|
||||||
|
@ -246,6 +248,7 @@
|
||||||
"alert.no_bookmark": "現在星付きはありません。",
|
"alert.no_bookmark": "現在星付きはありません。",
|
||||||
"alert.no_category": "カテゴリが存在しません。",
|
"alert.no_category": "カテゴリが存在しません。",
|
||||||
"alert.no_category_entry": "このカテゴリには記事がありません。",
|
"alert.no_category_entry": "このカテゴリには記事がありません。",
|
||||||
|
"alert.no_tag_entry": "このタグに一致するエントリーはありません。",
|
||||||
"alert.no_feed_entry": "このフィードには記事がありません。",
|
"alert.no_feed_entry": "このフィードには記事がありません。",
|
||||||
"alert.no_feed": "何も購読していません。",
|
"alert.no_feed": "何も購読していません。",
|
||||||
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
|
"alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
|
||||||
|
@ -304,6 +307,7 @@
|
||||||
"form.feed.label.title": "タイトル",
|
"form.feed.label.title": "タイトル",
|
||||||
"form.feed.label.site_url": "サイト URL",
|
"form.feed.label.site_url": "サイト URL",
|
||||||
"form.feed.label.feed_url": "フィード URL",
|
"form.feed.label.feed_url": "フィード URL",
|
||||||
|
"form.feed.label.description": "説明",
|
||||||
"form.feed.label.category": "カテゴリ",
|
"form.feed.label.category": "カテゴリ",
|
||||||
"form.feed.label.crawler": "オリジナルの内容を取得",
|
"form.feed.label.crawler": "オリジナルの内容を取得",
|
||||||
"form.feed.label.feed_username": "フィードのユーザー名",
|
"form.feed.label.feed_username": "フィードのユーザー名",
|
||||||
|
@ -439,6 +443,10 @@
|
||||||
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
||||||
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
|
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
|
||||||
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Readeck に記事を保存する",
|
"form.integration.readeck_activate": "Readeck に記事を保存する",
|
||||||
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
|
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
|
||||||
"form.integration.readeck_api_key": "Readeck の API key",
|
"form.integration.readeck_api_key": "Readeck の API key",
|
||||||
|
@ -488,7 +496,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -507,5 +515,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
|
||||||
|
"error.settings_media_playback_rate_range": "再生速度が範囲外",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,6 +179,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
|
"page.keyboard_shortcuts.go_to_feed": "Ga naar feed",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
|
"page.keyboard_shortcuts.go_to_previous_page": "Vorige pagina",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
|
"page.keyboard_shortcuts.go_to_next_page": "Volgende pagina",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Ga naar het onderste item",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Ga naar het bovenste item",
|
||||||
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
|
"page.keyboard_shortcuts.open_item": "Open geselecteerde link",
|
||||||
"page.keyboard_shortcuts.open_original": "Open originele link",
|
"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_original_same_window": "Oorspronkelijke koppeling op huidig tabblad openen",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
|
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
|
||||||
"alert.no_category": "Er zijn geen categorieën.",
|
"alert.no_category": "Er zijn geen categorieën.",
|
||||||
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
|
"alert.no_category_entry": "Deze categorie bevat geen feeds.",
|
||||||
|
"alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
|
||||||
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
|
"alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
|
||||||
"alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
|
"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_in_category": "Er is geen abonnement voor deze categorie.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Naam",
|
"form.feed.label.title": "Naam",
|
||||||
"form.feed.label.site_url": "Website URL",
|
"form.feed.label.site_url": "Website URL",
|
||||||
"form.feed.label.feed_url": "Feed URL",
|
"form.feed.label.feed_url": "Feed URL",
|
||||||
|
"form.feed.label.description": "Beschrijving",
|
||||||
"form.feed.label.category": "Categorie",
|
"form.feed.label.category": "Categorie",
|
||||||
"form.feed.label.crawler": "Download originele content",
|
"form.feed.label.crawler": "Download originele content",
|
||||||
"form.feed.label.feed_username": "Feed-gebruikersnaam",
|
"form.feed.label.feed_username": "Feed-gebruikersnaam",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
||||||
"form.integration.matrix_bot_url": "URL van de Matrix-server",
|
"form.integration.matrix_bot_url": "URL van de Matrix-server",
|
||||||
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Opslaan naar Readeck",
|
"form.integration.readeck_activate": "Opslaan naar Readeck",
|
||||||
"form.integration.readeck_endpoint": "Readeck URL",
|
"form.integration.readeck_endpoint": "Readeck URL",
|
||||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
|
||||||
|
"error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -187,6 +187,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
|
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
|
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
|
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do najwyższego elementu",
|
||||||
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
|
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
|
||||||
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
|
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
|
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
|
||||||
|
@ -266,6 +268,7 @@
|
||||||
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
|
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
|
||||||
"alert.no_category": "Nie ma żadnej kategorii!",
|
"alert.no_category": "Nie ma żadnej kategorii!",
|
||||||
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
|
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
|
||||||
|
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
|
||||||
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
|
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
|
||||||
"alert.no_feed": "Nie masz żadnej subskrypcji.",
|
"alert.no_feed": "Nie masz żadnej subskrypcji.",
|
||||||
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
|
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
|
||||||
|
@ -324,6 +327,7 @@
|
||||||
"form.feed.label.title": "Tytuł",
|
"form.feed.label.title": "Tytuł",
|
||||||
"form.feed.label.site_url": "URL strony",
|
"form.feed.label.site_url": "URL strony",
|
||||||
"form.feed.label.feed_url": "URL kanału",
|
"form.feed.label.feed_url": "URL kanału",
|
||||||
|
"form.feed.label.description": "Opis",
|
||||||
"form.feed.label.category": "Kategoria",
|
"form.feed.label.category": "Kategoria",
|
||||||
"form.feed.label.crawler": "Pobierz oryginalną treść",
|
"form.feed.label.crawler": "Pobierz oryginalną treść",
|
||||||
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
|
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
|
||||||
|
@ -459,6 +463,10 @@
|
||||||
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
|
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
|
||||||
"form.integration.readeck_endpoint": "Readeck URL",
|
"form.integration.readeck_endpoint": "Readeck URL",
|
||||||
"form.integration.readeck_api_key": "Readeck API key",
|
"form.integration.readeck_api_key": "Readeck API key",
|
||||||
|
@ -522,7 +530,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -541,5 +549,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
|
||||||
|
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,6 +178,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
|
"page.keyboard_shortcuts.go_to_feed": "Ir a fonte",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
|
"page.keyboard_shortcuts.go_to_previous_page": "Ir a página anterior",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
|
"page.keyboard_shortcuts.go_to_next_page": "Ir a página seguinte",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Ir para o item inferior",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Ir para o item superior",
|
||||||
"page.keyboard_shortcuts.open_item": "Abrir o item selecionado",
|
"page.keyboard_shortcuts.open_item": "Abrir o item selecionado",
|
||||||
"page.keyboard_shortcuts.open_original": "Abrir o conteúdo original",
|
"page.keyboard_shortcuts.open_original": "Abrir o conteúdo original",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Abrir o conteúdo original na janela atual",
|
"page.keyboard_shortcuts.open_original_same_window": "Abrir o conteúdo original na janela atual",
|
||||||
|
@ -256,6 +258,7 @@
|
||||||
"alert.no_bookmark": "Não há favorito neste momento.",
|
"alert.no_bookmark": "Não há favorito neste momento.",
|
||||||
"alert.no_category": "Não há categoria.",
|
"alert.no_category": "Não há categoria.",
|
||||||
"alert.no_category_entry": "Não há itens nesta categoria.",
|
"alert.no_category_entry": "Não há itens nesta categoria.",
|
||||||
|
"alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
|
||||||
"alert.no_feed_entry": "Não há itens nessa fonte.",
|
"alert.no_feed_entry": "Não há itens nessa fonte.",
|
||||||
"alert.no_feed": "Não há inscrições.",
|
"alert.no_feed": "Não há inscrições.",
|
||||||
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
|
"alert.no_feed_in_category": "Não há inscrições nessa categoria.",
|
||||||
|
@ -314,6 +317,7 @@
|
||||||
"form.feed.label.title": "Título",
|
"form.feed.label.title": "Título",
|
||||||
"form.feed.label.site_url": "URL do site",
|
"form.feed.label.site_url": "URL do site",
|
||||||
"form.feed.label.feed_url": "URL da fonte",
|
"form.feed.label.feed_url": "URL da fonte",
|
||||||
|
"form.feed.label.description": "Descrição",
|
||||||
"form.feed.label.category": "Categoria",
|
"form.feed.label.category": "Categoria",
|
||||||
"form.feed.label.crawler": "Obter conteúdo original",
|
"form.feed.label.crawler": "Obter conteúdo original",
|
||||||
"form.feed.label.feed_username": "Nome de usuário da fonte",
|
"form.feed.label.feed_username": "Nome de usuário da fonte",
|
||||||
|
@ -449,6 +453,10 @@
|
||||||
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Salvar itens no Readeck",
|
"form.integration.readeck_activate": "Salvar itens no Readeck",
|
||||||
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
|
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
|
||||||
"form.integration.readeck_api_key": "Chave de API do Readeck",
|
"form.integration.readeck_api_key": "Chave de API do Readeck",
|
||||||
|
@ -505,7 +513,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -524,5 +532,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
|
||||||
|
"error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -187,6 +187,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
|
"page.keyboard_shortcuts.go_to_feed": "Перейти к подписке",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
|
"page.keyboard_shortcuts.go_to_previous_page": "Перейти к предыдущей странице",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
|
"page.keyboard_shortcuts.go_to_next_page": "Перейти к следующей странице",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти к нижнему элементу",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Перейти к верхнему элементу",
|
||||||
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
|
"page.keyboard_shortcuts.open_item": "Открыть выбранный элемент",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
|
"page.keyboard_shortcuts.open_original_same_window": "Открыть оригинальную ссылку в текущей вкладке",
|
||||||
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
|
"page.keyboard_shortcuts.open_original": "Открыть оригинальную ссылку",
|
||||||
|
@ -266,6 +268,7 @@
|
||||||
"alert.no_bookmark": "Избранное отсутствует.",
|
"alert.no_bookmark": "Избранное отсутствует.",
|
||||||
"alert.no_category": "Категории отсутствуют.",
|
"alert.no_category": "Категории отсутствуют.",
|
||||||
"alert.no_category_entry": "В этой категории нет статей.",
|
"alert.no_category_entry": "В этой категории нет статей.",
|
||||||
|
"alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
|
||||||
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
|
"alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
|
||||||
"alert.no_feed": "У вас нет ни одной подписки.",
|
"alert.no_feed": "У вас нет ни одной подписки.",
|
||||||
"alert.no_feed_in_category": "Для этой категории нет подписки.",
|
"alert.no_feed_in_category": "Для этой категории нет подписки.",
|
||||||
|
@ -324,6 +327,7 @@
|
||||||
"form.feed.label.title": "Название",
|
"form.feed.label.title": "Название",
|
||||||
"form.feed.label.site_url": "Адрес сайта",
|
"form.feed.label.site_url": "Адрес сайта",
|
||||||
"form.feed.label.feed_url": "Адрес подписки",
|
"form.feed.label.feed_url": "Адрес подписки",
|
||||||
|
"form.feed.label.description": "Описание",
|
||||||
"form.feed.label.category": "Категория",
|
"form.feed.label.category": "Категория",
|
||||||
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
|
"form.feed.label.crawler": "Извлечь оригинальное содержимое",
|
||||||
"form.feed.label.feed_username": "Имя пользователя подписки",
|
"form.feed.label.feed_username": "Имя пользователя подписки",
|
||||||
|
@ -459,6 +463,10 @@
|
||||||
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
||||||
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
|
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
|
||||||
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
|
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
|
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
|
||||||
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
|
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
|
||||||
"form.integration.readeck_api_key": "API-ключ Readeck",
|
"form.integration.readeck_api_key": "API-ключ Readeck",
|
||||||
|
@ -522,7 +530,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -541,5 +549,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
|
||||||
|
"error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -187,6 +187,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
|
"page.keyboard_shortcuts.go_to_feed": "Перейти до стрічки",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
|
"page.keyboard_shortcuts.go_to_previous_page": "Перейти до попередньої сторінки",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
|
"page.keyboard_shortcuts.go_to_next_page": "Перейти до наступної сторінки",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "Перейти до нижнього пункту",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "Перейти до верхнього пункту",
|
||||||
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
|
"page.keyboard_shortcuts.open_item": "Відкрити виділений запис",
|
||||||
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
|
"page.keyboard_shortcuts.open_original": "Відкрити оригінальне посилання",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
|
"page.keyboard_shortcuts.open_original_same_window": "Відкрити оригінальне посилання в поточній вкладці",
|
||||||
|
@ -266,6 +268,7 @@
|
||||||
"alert.no_bookmark": "Наразі закладки відсутні.",
|
"alert.no_bookmark": "Наразі закладки відсутні.",
|
||||||
"alert.no_category": "Немає категорії.",
|
"alert.no_category": "Немає категорії.",
|
||||||
"alert.no_category_entry": "У цій категорії немає записів.",
|
"alert.no_category_entry": "У цій категорії немає записів.",
|
||||||
|
"alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
|
||||||
"alert.no_feed_entry": "У цій стрічці немає записів.",
|
"alert.no_feed_entry": "У цій стрічці немає записів.",
|
||||||
"alert.no_feed": "У вас немає підписок.",
|
"alert.no_feed": "У вас немає підписок.",
|
||||||
"alert.no_feed_in_category": "У цій категорії немає підписок.",
|
"alert.no_feed_in_category": "У цій категорії немає підписок.",
|
||||||
|
@ -324,6 +327,7 @@
|
||||||
"form.feed.label.title": "Назва",
|
"form.feed.label.title": "Назва",
|
||||||
"form.feed.label.site_url": "URL-адреса сайту",
|
"form.feed.label.site_url": "URL-адреса сайту",
|
||||||
"form.feed.label.feed_url": "URL-адреса стрічки",
|
"form.feed.label.feed_url": "URL-адреса стрічки",
|
||||||
|
"form.feed.label.description": "Опис",
|
||||||
"form.feed.label.category": "Категорія",
|
"form.feed.label.category": "Категорія",
|
||||||
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
|
"form.feed.label.crawler": "Завантажувати оригінальний вміст",
|
||||||
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
|
"form.feed.label.feed_username": "Ім’я користувача для завантаження",
|
||||||
|
@ -459,6 +463,10 @@
|
||||||
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
||||||
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
|
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
|
||||||
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
||||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||||
|
@ -522,7 +530,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -541,5 +549,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
|
||||||
|
"error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,6 +169,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
|
"page.keyboard_shortcuts.go_to_feed": "转到源页面",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
|
"page.keyboard_shortcuts.go_to_previous_page": "上一页",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "下一页",
|
"page.keyboard_shortcuts.go_to_next_page": "下一页",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
|
||||||
"page.keyboard_shortcuts.open_item": "打开选定的文章",
|
"page.keyboard_shortcuts.open_item": "打开选定的文章",
|
||||||
"page.keyboard_shortcuts.open_original": "打开原始链接",
|
"page.keyboard_shortcuts.open_original": "打开原始链接",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
|
"page.keyboard_shortcuts.open_original_same_window": "在当前标签页中打开原始链接",
|
||||||
|
@ -246,6 +248,7 @@
|
||||||
"alert.no_bookmark": "目前没有收藏",
|
"alert.no_bookmark": "目前没有收藏",
|
||||||
"alert.no_category": "目前没有分类",
|
"alert.no_category": "目前没有分类",
|
||||||
"alert.no_category_entry": "该分类下没有文章",
|
"alert.no_category_entry": "该分类下没有文章",
|
||||||
|
"alert.no_tag_entry": "没有与此标签匹配的条目。",
|
||||||
"alert.no_feed_entry": "该源中没有文章",
|
"alert.no_feed_entry": "该源中没有文章",
|
||||||
"alert.no_feed": "目前没有源",
|
"alert.no_feed": "目前没有源",
|
||||||
"alert.no_history": "目前没有历史",
|
"alert.no_history": "目前没有历史",
|
||||||
|
@ -304,6 +307,7 @@
|
||||||
"form.feed.label.title": "标题",
|
"form.feed.label.title": "标题",
|
||||||
"form.feed.label.site_url": "源网站 URL",
|
"form.feed.label.site_url": "源网站 URL",
|
||||||
"form.feed.label.feed_url": "订阅源 URL",
|
"form.feed.label.feed_url": "订阅源 URL",
|
||||||
|
"form.feed.label.description": "描述",
|
||||||
"form.feed.label.category": "类别",
|
"form.feed.label.category": "类别",
|
||||||
"form.feed.label.crawler": "抓取全文内容",
|
"form.feed.label.crawler": "抓取全文内容",
|
||||||
"form.feed.label.feed_username": "源用户名",
|
"form.feed.label.feed_username": "源用户名",
|
||||||
|
@ -406,9 +410,9 @@
|
||||||
"form.integration.omnivore_activate": "保存文章到 Omnivore",
|
"form.integration.omnivore_activate": "保存文章到 Omnivore",
|
||||||
"form.integration.omnivore_url": "Omnivore API 端点",
|
"form.integration.omnivore_url": "Omnivore API 端点",
|
||||||
"form.integration.omnivore_api_key": "Omnivore API 密钥",
|
"form.integration.omnivore_api_key": "Omnivore API 密钥",
|
||||||
"form.integration.espial_activate": "保存文章到 Espial",
|
"form.integration.espial_activate": "保存文章到 Espial",
|
||||||
"form.integration.espial_endpoint": "Espial API 端点",
|
"form.integration.espial_endpoint": "Espial API 端点",
|
||||||
"form.integration.espial_api_key": "Espial API 密钥",
|
"form.integration.espial_api_key": "Espial API 密钥",
|
||||||
"form.integration.espial_tags": "Espial 标签",
|
"form.integration.espial_tags": "Espial 标签",
|
||||||
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
|
"form.integration.readwise_activate": "保存文章到 Readwise Reader",
|
||||||
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
"form.integration.readwise_api_key": "Readwise Reader Access Token",
|
||||||
|
@ -439,6 +443,10 @@
|
||||||
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
||||||
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
||||||
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "保存文章到 Readeck",
|
"form.integration.readeck_activate": "保存文章到 Readeck",
|
||||||
"form.integration.readeck_endpoint": "Readeck API 端点",
|
"form.integration.readeck_endpoint": "Readeck API 端点",
|
||||||
"form.integration.readeck_api_key": "Readeck API 密钥",
|
"form.integration.readeck_api_key": "Readeck API 密钥",
|
||||||
|
@ -488,7 +496,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -507,5 +515,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "音频/视频的播放速度",
|
||||||
|
"error.settings_media_playback_rate_range": "播放速度超出范围",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,6 +169,8 @@
|
||||||
"page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面",
|
"page.keyboard_shortcuts.go_to_feed": "轉到Feed頁面",
|
||||||
"page.keyboard_shortcuts.go_to_previous_page": "上一頁",
|
"page.keyboard_shortcuts.go_to_previous_page": "上一頁",
|
||||||
"page.keyboard_shortcuts.go_to_next_page": "下一頁",
|
"page.keyboard_shortcuts.go_to_next_page": "下一頁",
|
||||||
|
"page.keyboard_shortcuts.go_to_bottom_item": "转到底部项目",
|
||||||
|
"page.keyboard_shortcuts.go_to_top_item": "转到顶部项目",
|
||||||
"page.keyboard_shortcuts.open_item": "開啟選定的文章",
|
"page.keyboard_shortcuts.open_item": "開啟選定的文章",
|
||||||
"page.keyboard_shortcuts.open_original": "開啟原始連結",
|
"page.keyboard_shortcuts.open_original": "開啟原始連結",
|
||||||
"page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結",
|
"page.keyboard_shortcuts.open_original_same_window": "在當前標籤頁中開啟原始連結",
|
||||||
|
@ -246,6 +248,7 @@
|
||||||
"alert.no_bookmark": "目前沒有收藏",
|
"alert.no_bookmark": "目前沒有收藏",
|
||||||
"alert.no_category": "目前沒有分類",
|
"alert.no_category": "目前沒有分類",
|
||||||
"alert.no_category_entry": "該分類下沒有文章",
|
"alert.no_category_entry": "該分類下沒有文章",
|
||||||
|
"alert.no_tag_entry": "沒有與此標籤相符的條目。",
|
||||||
"alert.no_feed_entry": "該Feed中沒有文章",
|
"alert.no_feed_entry": "該Feed中沒有文章",
|
||||||
"alert.no_feed": "目前沒有Feed",
|
"alert.no_feed": "目前沒有Feed",
|
||||||
"alert.no_history": "目前沒有歷史",
|
"alert.no_history": "目前沒有歷史",
|
||||||
|
@ -304,6 +307,7 @@
|
||||||
"form.feed.label.title": "標題",
|
"form.feed.label.title": "標題",
|
||||||
"form.feed.label.site_url": "網站 URL",
|
"form.feed.label.site_url": "網站 URL",
|
||||||
"form.feed.label.feed_url": "訂閱 Feed URL",
|
"form.feed.label.feed_url": "訂閱 Feed URL",
|
||||||
|
"form.feed.label.description": "描述",
|
||||||
"form.feed.label.category": "類別",
|
"form.feed.label.category": "類別",
|
||||||
"form.feed.label.crawler": "下載原文內容",
|
"form.feed.label.crawler": "下載原文內容",
|
||||||
"form.feed.label.feed_username": "Feed 使用者名稱",
|
"form.feed.label.feed_username": "Feed 使用者名稱",
|
||||||
|
@ -439,6 +443,10 @@
|
||||||
"form.integration.matrix_bot_password": "Matrix 的密碼",
|
"form.integration.matrix_bot_password": "Matrix 的密碼",
|
||||||
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
|
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
|
||||||
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
||||||
|
"form.integration.raindrop_activate": "Save entries to Raindrop",
|
||||||
|
"form.integration.raindrop_token": "(Test) Token",
|
||||||
|
"form.integration.raindrop_collection_id": "Collection ID",
|
||||||
|
"form.integration.raindrop_tags": "Tags (comma-separated)",
|
||||||
"form.integration.readeck_activate": "儲存文章到 Readeck",
|
"form.integration.readeck_activate": "儲存文章到 Readeck",
|
||||||
"form.integration.readeck_endpoint": "Readeck API 端點",
|
"form.integration.readeck_endpoint": "Readeck API 端點",
|
||||||
"form.integration.readeck_api_key": "Readeck API 金鑰",
|
"form.integration.readeck_api_key": "Readeck API 金鑰",
|
||||||
|
@ -488,7 +496,7 @@
|
||||||
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
"error.http_body_read": "Unable to read the HTTP body: %v.",
|
||||||
"error.http_empty_response_body": "The HTTP response body is empty.",
|
"error.http_empty_response_body": "The HTTP response body is empty.",
|
||||||
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
|
||||||
"error.tls_error": "TLS error: %v. You could disable TLS verification in the feed settings if you would like.",
|
"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_operation": "Miniflux is not able to reach this website due to a network error: %v.",
|
||||||
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
"error.network_timeout": "This website is too slow and the request timed out: %v",
|
||||||
"error.http_client_error": "HTTP client error: %v.",
|
"error.http_client_error": "HTTP client error: %v.",
|
||||||
|
@ -507,5 +515,16 @@
|
||||||
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
|
||||||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
|
||||||
|
"form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
|
||||||
|
"error.settings_media_playback_rate_range": "播放速度超出範圍",
|
||||||
|
"enclosure_media_controls.seek" : "Seek:",
|
||||||
|
"enclosure_media_controls.seek.title" : "Seek %s seconds",
|
||||||
|
"enclosure_media_controls.speed" : "Speed:",
|
||||||
|
"enclosure_media_controls.speed.faster" : "Faster",
|
||||||
|
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
|
||||||
|
"enclosure_media_controls.speed.slower" : "Slower",
|
||||||
|
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
|
||||||
|
"enclosure_media_controls.speed.reset" : "Reset",
|
||||||
|
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package proxy // import "miniflux.app/v2/internal/proxy"
|
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -29,11 +29,11 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,11 +53,11 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,11 +76,11 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := input
|
expected := input
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,11 +99,11 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := input
|
expected := input
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,11 +124,11 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,11 +149,87 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
expected := `<p><img src="/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_OPTION", "all")
|
||||||
|
os.Setenv("PROXY_MEDIA_TYPES", "image")
|
||||||
|
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
parser := config.NewParser()
|
||||||
|
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||||
|
expected := `<p><img src="http://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||||
|
|
||||||
|
if expected != output {
|
||||||
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsoluteProxyFilterWithHttpsScheme(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")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
parser := config.NewParser()
|
||||||
|
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||||
|
expected := `<p><img src="https://localhost/proxy/LdPNR1GBDigeeNp2ArUQRyZsVqT_PWLfHGjYFrrWWIY=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||||
|
|
||||||
|
if expected != output {
|
||||||
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_OPTION", "all")
|
||||||
|
os.Setenv("PROXY_MEDIA_TYPES", "audio")
|
||||||
|
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
parser := config.NewParser()
|
||||||
|
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
|
input := `<audio src="https://website/folder/audio.mp3"></audio>`
|
||||||
|
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||||
|
expected := `<audio src="http://localhost/proxy/EmBTvmU5B17wGuONkeknkptYopW_Tl6Y6_W8oYbN_Xs=/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9hdWRpby5tcDM="></audio>`
|
||||||
|
|
||||||
|
if expected != output {
|
||||||
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,11 +250,61 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("PROXY_OPTION", "all")
|
||||||
|
os.Setenv("PROXY_MEDIA_TYPES", "image")
|
||||||
|
os.Setenv("PROXY_URL", "http://:8080example.com")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
parser := config.NewParser()
|
||||||
|
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
|
||||||
|
if expected != output {
|
||||||
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
os.Setenv("MEDIA_PROXY_MODE", "all")
|
||||||
|
os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
|
||||||
|
os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
parser := config.NewParser()
|
||||||
|
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(`Parsing failure: %v`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
output := RewriteDocumentWithAbsoluteProxyURL(r, "localhost", input)
|
||||||
|
expected := `<p><img src="https://proxy-example/proxy/aHR0cHM6Ly93ZWJzaXRlL2ZvbGRlci9pbWFnZS5wbmc=" alt="Test"/></p>`
|
||||||
|
|
||||||
|
if expected != output {
|
||||||
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,11 +324,11 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,11 +348,11 @@ func TestProxyFilterWithHttpsInvalid(t *testing.T) {
|
||||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||||
|
|
||||||
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,7 +374,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
|
||||||
|
|
||||||
input := `<p><img src="http://website/folder/image.png" srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w" alt="test"></p>`
|
input := `<p><img src="http://website/folder/image.png" srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w" alt="test"></p>`
|
||||||
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w" alt="test"/></p>`
|
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w" alt="test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -273,7 +399,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
|
||||||
|
|
||||||
input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>`
|
input := `<p><img src="http://website/folder/image.png" srcset="" alt="test"></p>`
|
||||||
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>`
|
expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" srcset="" alt="test"/></p>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -298,7 +424,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
|
||||||
|
|
||||||
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>`
|
input := `<picture><source srcset="http://website/folder/image2.png 656w, http://website/folder/image3.png 360w, https://website/some,image.png 2x"></picture>`
|
||||||
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>`
|
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, /proxy/QgAmrJWiAud_nNAsz3F8OTxaIofwAiO36EDzH_YfMzo=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMy5wbmc= 360w, /proxy/ZIw0hv8WhSTls5aSqhnFaCXlUrKIqTnBRaY0-NaLnds=/aHR0cHM6Ly93ZWJzaXRlL3NvbWUsaW1hZ2UucG5n 2x"/></picture>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -323,7 +449,7 @@ func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
|
||||||
|
|
||||||
input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>`
|
input := `<picture><source srcset="http://website/folder/image2.png 656w, https://website/some,image.png 2x"></picture>`
|
||||||
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>`
|
expected := `<picture><source srcset="/proxy/aY5Hb4urDnUCly2vTJ7ExQeeaVS-52O7kjUr2v9VrAs=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlMi5wbmc= 656w, https://website/some,image.png 2x"/></picture>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -347,7 +473,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
|
||||||
|
|
||||||
input := `<img src="data:image/gif;base64,test">`
|
input := `<img src="data:image/gif;base64,test">`
|
||||||
expected := `<img src="data:image/gif;base64,test"/>`
|
expected := `<img src="data:image/gif;base64,test"/>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -371,7 +497,7 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
|
||||||
|
|
||||||
input := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
|
input := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
|
||||||
expected := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
|
expected := `<picture><source srcset="data:image/gif;base64,test"/></picture>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -396,7 +522,7 @@ func TestProxyFilterWithVideo(t *testing.T) {
|
||||||
|
|
||||||
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
||||||
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
|
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
||||||
|
@ -421,7 +547,7 @@ func TestProxyFilterVideoPoster(t *testing.T) {
|
||||||
|
|
||||||
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
||||||
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
|
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
|
||||||
output := ProxyRewriter(r, input)
|
output := RewriteDocumentWithRelativeProxyURL(r, input)
|
||||||
|
|
||||||
if expected != output {
|
if expected != output {
|
||||||
t.Errorf(`Not expected output: got %s`, output)
|
t.Errorf(`Not expected output: got %s`, output)
|
|
@ -1,7 +1,7 @@
|
||||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package proxy // import "miniflux.app/v2/internal/proxy"
|
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -16,31 +16,29 @@ import (
|
||||||
|
|
||||||
type urlProxyRewriter func(router *mux.Router, url string) string
|
type urlProxyRewriter func(router *mux.Router, url string) string
|
||||||
|
|
||||||
// ProxyRewriter replaces media URLs with internal proxy URLs.
|
func RewriteDocumentWithRelativeProxyURL(router *mux.Router, htmlDocument string) string {
|
||||||
func ProxyRewriter(router *mux.Router, data string) string {
|
return genericProxyRewriter(router, ProxifyRelativeURL, htmlDocument)
|
||||||
return genericProxyRewriter(router, ProxifyURL, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbsoluteProxyRewriter do the same as ProxyRewriter except it uses absolute URLs.
|
func RewriteDocumentWithAbsoluteProxyURL(router *mux.Router, host, htmlDocument string) string {
|
||||||
func AbsoluteProxyRewriter(router *mux.Router, host, data string) string {
|
|
||||||
proxifyFunction := func(router *mux.Router, url string) string {
|
proxifyFunction := func(router *mux.Router, url string) string {
|
||||||
return AbsoluteProxifyURL(router, host, url)
|
return ProxifyAbsoluteURL(router, host, url)
|
||||||
}
|
}
|
||||||
return genericProxyRewriter(router, proxifyFunction, data)
|
return genericProxyRewriter(router, proxifyFunction, htmlDocument)
|
||||||
}
|
}
|
||||||
|
|
||||||
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
|
func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, htmlDocument string) string {
|
||||||
proxyOption := config.Opts.ProxyOption()
|
proxyOption := config.Opts.MediaProxyMode()
|
||||||
if proxyOption == "none" {
|
if proxyOption == "none" {
|
||||||
return data
|
return htmlDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlDocument))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data
|
return htmlDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mediaType := range config.Opts.ProxyMediaTypes() {
|
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "image":
|
case "image":
|
||||||
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
|
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
|
||||||
|
@ -91,7 +89,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
||||||
|
|
||||||
output, err := doc.Find("body").First().Html()
|
output, err := doc.Find("body").First().Html()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data
|
return htmlDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
return output
|
return output
|
|
@ -0,0 +1,70 @@
|
||||||
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package mediaproxy // import "miniflux.app/v2/internal/mediaproxy"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/http/route"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProxifyRelativeURL(router *mux.Router, mediaURL string) string {
|
||||||
|
if mediaURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
|
||||||
|
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, config.Opts.MediaProxyPrivateKey())
|
||||||
|
mac.Write([]byte(mediaURL))
|
||||||
|
digest := mac.Sum(nil)
|
||||||
|
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProxifyAbsoluteURL(router *mux.Router, host, mediaURL string) string {
|
||||||
|
if mediaURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if customProxyURL := config.Opts.MediaCustomProxyURL(); customProxyURL != "" {
|
||||||
|
return proxifyURLWithCustomProxy(mediaURL, customProxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxifiedUrl := ProxifyRelativeURL(router, mediaURL)
|
||||||
|
scheme := "http"
|
||||||
|
if config.Opts.HTTPS {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host + proxifiedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxifyURLWithCustomProxy(mediaURL, customProxyURL string) string {
|
||||||
|
if customProxyURL == "" {
|
||||||
|
return mediaURL
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyUrl, err := url.Parse(customProxyURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Incorrect custom media proxy URL",
|
||||||
|
slog.String("custom_proxy_url", customProxyURL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
return mediaURL
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(mediaURL)))
|
||||||
|
return proxyUrl.String()
|
||||||
|
}
|
|
@ -2,7 +2,6 @@
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package model // import "miniflux.app/v2/internal/model"
|
package model // import "miniflux.app/v2/internal/model"
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// Enclosure represents an attachment.
|
// Enclosure represents an attachment.
|
||||||
type Enclosure struct {
|
type Enclosure struct {
|
||||||
|
@ -17,15 +16,8 @@ type Enclosure struct {
|
||||||
|
|
||||||
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
|
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
|
||||||
func (e Enclosure) Html5MimeType() string {
|
func (e Enclosure) Html5MimeType() string {
|
||||||
if strings.HasPrefix(e.MimeType, "video") {
|
if e.MimeType == "video/m4v" {
|
||||||
switch e.MimeType {
|
return "video/x-m4v"
|
||||||
// Solution from this stackoverflow discussion:
|
|
||||||
// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
|
|
||||||
// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
|
|
||||||
// https://www.florenceporcel.com/podcast/lfhdu.xml
|
|
||||||
case "video/m4v":
|
|
||||||
return "video/x-m4v"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return e.MimeType
|
return e.MimeType
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ type Feed struct {
|
||||||
FeedURL string `json:"feed_url"`
|
FeedURL string `json:"feed_url"`
|
||||||
SiteURL string `json:"site_url"`
|
SiteURL string `json:"site_url"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
CheckedAt time.Time `json:"checked_at"`
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
NextCheckAt time.Time `json:"next_check_at"`
|
NextCheckAt time.Time `json:"next_check_at"`
|
||||||
EtagHeader string `json:"etag_header"`
|
EtagHeader string `json:"etag_header"`
|
||||||
|
@ -159,25 +160,7 @@ type FeedCreationRequestFromSubscriptionDiscovery struct {
|
||||||
ETag string
|
ETag string
|
||||||
LastModified string
|
LastModified string
|
||||||
|
|
||||||
FeedURL string `json:"feed_url"`
|
FeedCreationRequest
|
||||||
CategoryID int64 `json:"category_id"`
|
|
||||||
UserAgent string `json:"user_agent"`
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Crawler bool `json:"crawler"`
|
|
||||||
Disabled bool `json:"disabled"`
|
|
||||||
NoMediaPlayer bool `json:"no_media_player"`
|
|
||||||
IgnoreHTTPCache bool `json:"ignore_http_cache"`
|
|
||||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
|
||||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
|
||||||
ScraperRules string `json:"scraper_rules"`
|
|
||||||
RewriteRules string `json:"rewrite_rules"`
|
|
||||||
BlocklistRules string `json:"blocklist_rules"`
|
|
||||||
KeeplistRules string `json:"keeplist_rules"`
|
|
||||||
HideGlobally bool `json:"hide_globally"`
|
|
||||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
|
||||||
DisableHTTP2 bool `json:"disable_http2"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeedModificationRequest represents the request to update a feed.
|
// FeedModificationRequest represents the request to update a feed.
|
||||||
|
@ -185,6 +168,7 @@ type FeedModificationRequest struct {
|
||||||
FeedURL *string `json:"feed_url"`
|
FeedURL *string `json:"feed_url"`
|
||||||
SiteURL *string `json:"site_url"`
|
SiteURL *string `json:"site_url"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
ScraperRules *string `json:"scraper_rules"`
|
ScraperRules *string `json:"scraper_rules"`
|
||||||
RewriteRules *string `json:"rewrite_rules"`
|
RewriteRules *string `json:"rewrite_rules"`
|
||||||
BlocklistRules *string `json:"blocklist_rules"`
|
BlocklistRules *string `json:"blocklist_rules"`
|
||||||
|
@ -219,6 +203,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
|
||||||
feed.Title = *f.Title
|
feed.Title = *f.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if f.Description != nil && *f.Description != "" {
|
||||||
|
feed.Description = *f.Description
|
||||||
|
}
|
||||||
|
|
||||||
if f.ScraperRules != nil {
|
if f.ScraperRules != nil {
|
||||||
feed.ScraperRules = *f.ScraperRules
|
feed.ScraperRules = *f.ScraperRules
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,4 +90,8 @@ type Integration struct {
|
||||||
OmnivoreEnabled bool
|
OmnivoreEnabled bool
|
||||||
OmnivoreAPIKey string
|
OmnivoreAPIKey string
|
||||||
OmnivoreURL string
|
OmnivoreURL string
|
||||||
|
RaindropEnabled bool
|
||||||
|
RaindropToken string
|
||||||
|
RaindropCollectionID string
|
||||||
|
RaindropTags string
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,26 +3,20 @@
|
||||||
|
|
||||||
package model // import "miniflux.app/v2/internal/model"
|
package model // import "miniflux.app/v2/internal/model"
|
||||||
|
|
||||||
// OptionalString populates an optional string field.
|
type Number interface {
|
||||||
|
int | int64 | float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func OptionalNumber[T Number](value T) *T {
|
||||||
|
if value > 0 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func OptionalString(value string) *string {
|
func OptionalString(value string) *string {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionalInt populates an optional int field.
|
|
||||||
func OptionalInt(value int) *int {
|
|
||||||
if value > 0 {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionalInt64 populates an optional int64 field.
|
|
||||||
func OptionalInt64(value int64) *int64 {
|
|
||||||
if value > 0 {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ type User struct {
|
||||||
DefaultHomePage string `json:"default_home_page"`
|
DefaultHomePage string `json:"default_home_page"`
|
||||||
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
CategoriesSortingOrder string `json:"categories_sorting_order"`
|
||||||
MarkReadOnView bool `json:"mark_read_on_view"`
|
MarkReadOnView bool `json:"mark_read_on_view"`
|
||||||
|
MediaPlaybackRate float64 `json:"media_playback_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserCreationRequest represents the request to create a user.
|
// UserCreationRequest represents the request to create a user.
|
||||||
|
@ -48,28 +49,29 @@ type UserCreationRequest struct {
|
||||||
|
|
||||||
// UserModificationRequest represents the request to update a user.
|
// UserModificationRequest represents the request to update a user.
|
||||||
type UserModificationRequest struct {
|
type UserModificationRequest struct {
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
Language *string `json:"language"`
|
Language *string `json:"language"`
|
||||||
Timezone *string `json:"timezone"`
|
Timezone *string `json:"timezone"`
|
||||||
EntryDirection *string `json:"entry_sorting_direction"`
|
EntryDirection *string `json:"entry_sorting_direction"`
|
||||||
EntryOrder *string `json:"entry_sorting_order"`
|
EntryOrder *string `json:"entry_sorting_order"`
|
||||||
Stylesheet *string `json:"stylesheet"`
|
Stylesheet *string `json:"stylesheet"`
|
||||||
GoogleID *string `json:"google_id"`
|
GoogleID *string `json:"google_id"`
|
||||||
OpenIDConnectID *string `json:"openid_connect_id"`
|
OpenIDConnectID *string `json:"openid_connect_id"`
|
||||||
EntriesPerPage *int `json:"entries_per_page"`
|
EntriesPerPage *int `json:"entries_per_page"`
|
||||||
IsAdmin *bool `json:"is_admin"`
|
IsAdmin *bool `json:"is_admin"`
|
||||||
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
|
||||||
ShowReadingTime *bool `json:"show_reading_time"`
|
ShowReadingTime *bool `json:"show_reading_time"`
|
||||||
EntrySwipe *bool `json:"entry_swipe"`
|
EntrySwipe *bool `json:"entry_swipe"`
|
||||||
GestureNav *string `json:"gesture_nav"`
|
GestureNav *string `json:"gesture_nav"`
|
||||||
DisplayMode *string `json:"display_mode"`
|
DisplayMode *string `json:"display_mode"`
|
||||||
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
DefaultReadingSpeed *int `json:"default_reading_speed"`
|
||||||
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
CJKReadingSpeed *int `json:"cjk_reading_speed"`
|
||||||
DefaultHomePage *string `json:"default_home_page"`
|
DefaultHomePage *string `json:"default_home_page"`
|
||||||
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
CategoriesSortingOrder *string `json:"categories_sorting_order"`
|
||||||
MarkReadOnView *bool `json:"mark_read_on_view"`
|
MarkReadOnView *bool `json:"mark_read_on_view"`
|
||||||
|
MediaPlaybackRate *float64 `json:"media_playback_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch updates the User object with the modification request.
|
// Patch updates the User object with the modification request.
|
||||||
|
@ -161,6 +163,10 @@ func (u *UserModificationRequest) Patch(user *User) {
|
||||||
if u.MarkReadOnView != nil {
|
if u.MarkReadOnView != nil {
|
||||||
user.MarkReadOnView = *u.MarkReadOnView
|
user.MarkReadOnView = *u.MarkReadOnView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if u.MediaPlaybackRate != nil {
|
||||||
|
user.MediaPlaybackRate = *u.MediaPlaybackRate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UseTimezone converts last login date to the given timezone.
|
// UseTimezone converts last login date to the given timezone.
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package proxy // import "miniflux.app/v2/internal/proxy"
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"miniflux.app/v2/internal/http/route"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
"miniflux.app/v2/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProxifyURL generates a relative URL for a proxified resource.
|
|
||||||
func ProxifyURL(router *mux.Router, link string) string {
|
|
||||||
if link != "" {
|
|
||||||
proxyImageUrl := config.Opts.ProxyUrl()
|
|
||||||
|
|
||||||
if proxyImageUrl == "" {
|
|
||||||
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
|
|
||||||
mac.Write([]byte(link))
|
|
||||||
digest := mac.Sum(nil)
|
|
||||||
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyUrl, err := url.Parse(proxyImageUrl)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
|
|
||||||
return proxyUrl.String()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// AbsoluteProxifyURL generates an absolute URL for a proxified resource.
|
|
||||||
func AbsoluteProxifyURL(router *mux.Router, host, link string) string {
|
|
||||||
if link != "" {
|
|
||||||
proxyImageUrl := config.Opts.ProxyUrl()
|
|
||||||
|
|
||||||
if proxyImageUrl == "" {
|
|
||||||
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
|
|
||||||
mac.Write([]byte(link))
|
|
||||||
digest := mac.Sum(nil)
|
|
||||||
path := route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
|
|
||||||
if config.Opts.HTTPS {
|
|
||||||
return "https://" + host + path
|
|
||||||
} else {
|
|
||||||
return "http://" + host + path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyUrl, err := url.Parse(proxyImageUrl)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
|
|
||||||
return proxyUrl.String()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
|
@ -6,158 +6,114 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"html"
|
"html"
|
||||||
"log/slog"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"miniflux.app/v2/internal/crypto"
|
|
||||||
"miniflux.app/v2/internal/model"
|
|
||||||
"miniflux.app/v2/internal/reader/date"
|
|
||||||
"miniflux.app/v2/internal/reader/sanitizer"
|
|
||||||
"miniflux.app/v2/internal/urllib"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
|
// Specs: http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
|
||||||
type atom03Feed struct {
|
type Atom03Feed struct {
|
||||||
ID string `xml:"id"`
|
Version string `xml:"version,attr"`
|
||||||
Title atom03Text `xml:"title"`
|
|
||||||
Author atomPerson `xml:"author"`
|
// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
|
||||||
Links atomLinks `xml:"link"`
|
// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
|
||||||
Entries []atom03Entry `xml:"entry"`
|
// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
|
||||||
|
ID string `xml:"http://purl.org/atom/ns# id"`
|
||||||
|
|
||||||
|
// The "atom:title" element is a Content construct that conveys a human-readable title for the feed.
|
||||||
|
// atom:feed elements MUST contain exactly one atom:title element.
|
||||||
|
// If the feed describes a Web resource, its content SHOULD be the same as that resource's title.
|
||||||
|
Title Atom03Content `xml:"http://purl.org/atom/ns# title"`
|
||||||
|
|
||||||
|
// The "atom:link" element is a Link construct that conveys a URI associated with the feed.
|
||||||
|
// The nature of the relationship as well as the link itself is determined by the element's content.
|
||||||
|
// atom:feed elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
|
||||||
|
// atom:feed elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
|
||||||
|
// atom:feed elements MAY contain additional atom:link elements beyond those described above.
|
||||||
|
Links AtomLinks `xml:"http://purl.org/atom/ns# link"`
|
||||||
|
|
||||||
|
// The "atom:author" element is a Person construct that indicates the default author of the feed.
|
||||||
|
// atom:feed elements MUST contain exactly one atom:author element,
|
||||||
|
// UNLESS all of the atom:feed element's child atom:entry elements contain an atom:author element.
|
||||||
|
// atom:feed elements MUST NOT contain more than one atom:author element.
|
||||||
|
Author AtomPerson `xml:"http://purl.org/atom/ns# author"`
|
||||||
|
|
||||||
|
// The "atom:entry" element's represents an individual entry that is contained by the feed.
|
||||||
|
// atom:feed elements MAY contain one or more atom:entry elements.
|
||||||
|
Entries []Atom03Entry `xml:"http://purl.org/atom/ns# entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *atom03Feed) Transform(baseURL string) *model.Feed {
|
type Atom03Entry struct {
|
||||||
var err error
|
// The "atom:id" element's content conveys a permanent, globally unique identifier for the entry.
|
||||||
|
// It MUST NOT change over time, even if other representations of the entry (such as a web representation pointed to by the entry's atom:link element) are relocated.
|
||||||
|
// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
|
||||||
|
ID string `xml:"id"`
|
||||||
|
|
||||||
feed := new(model.Feed)
|
// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
|
||||||
|
// atom:entry elements MUST have exactly one "atom:title" element.
|
||||||
|
// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.
|
||||||
|
Title Atom03Content `xml:"title"`
|
||||||
|
|
||||||
feedURL := a.Links.firstLinkWithRelation("self")
|
// The "atom:modified" element is a Date construct that indicates the time that the entry was last modified.
|
||||||
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
|
// atom:entry elements MUST contain an atom:modified element, but MUST NOT contain more than one.
|
||||||
if err != nil {
|
// The content of an atom:modified element MUST have a time zone whose value SHOULD be "UTC".
|
||||||
feed.FeedURL = feedURL
|
Modified string `xml:"modified"`
|
||||||
}
|
|
||||||
|
|
||||||
siteURL := a.Links.originalLink()
|
// The "atom:issued" element is a Date construct that indicates the time that the entry was issued.
|
||||||
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
|
// atom:entry elements MUST contain an atom:issued element, but MUST NOT contain more than one.
|
||||||
if err != nil {
|
// The content of an atom:issued element MAY omit a time zone.
|
||||||
feed.SiteURL = siteURL
|
Issued string `xml:"issued"`
|
||||||
}
|
|
||||||
|
|
||||||
feed.Title = a.Title.String()
|
// The "atom:created" element is a Date construct that indicates the time that the entry was created.
|
||||||
if feed.Title == "" {
|
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
|
||||||
feed.Title = feed.SiteURL
|
// The content of an atom:created element MUST have a time zone whose value SHOULD be "UTC".
|
||||||
}
|
// If atom:created is not present, its content MUST considered to be the same as that of atom:modified.
|
||||||
|
Created string `xml:"created"`
|
||||||
|
|
||||||
for _, entry := range a.Entries {
|
// The "atom:link" element is a Link construct that conveys a URI associated with the entry.
|
||||||
item := entry.Transform()
|
// The nature of the relationship as well as the link itself is determined by the element's content.
|
||||||
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
|
// atom:entry elements MUST contain at least one atom:link element with a rel attribute value of "alternate".
|
||||||
if err == nil {
|
// atom:entry elements MUST NOT contain more than one atom:link element with a rel attribute value of "alternate" that has the same type attribute value.
|
||||||
item.URL = entryURL
|
// atom:entry elements MAY contain additional atom:link elements beyond those described above.
|
||||||
}
|
Links AtomLinks `xml:"link"`
|
||||||
|
|
||||||
if item.Author == "" {
|
// The "atom:summary" element is a Content construct that conveys a short summary, abstract or excerpt of the entry.
|
||||||
item.Author = a.Author.String()
|
// atom:entry elements MAY contain an atom:created element, but MUST NOT contain more than one.
|
||||||
}
|
Summary Atom03Content `xml:"summary"`
|
||||||
|
|
||||||
if item.Title == "" {
|
// The "atom:content" element is a Content construct that conveys the content of the entry.
|
||||||
item.Title = sanitizer.TruncateHTML(item.Content, 100)
|
// atom:entry elements MAY contain one or more atom:content elements.
|
||||||
}
|
Content Atom03Content `xml:"content"`
|
||||||
|
|
||||||
if item.Title == "" {
|
// The "atom:author" element is a Person construct that indicates the default author of the entry.
|
||||||
item.Title = item.URL
|
// atom:entry elements MUST contain exactly one atom:author element,
|
||||||
}
|
// UNLESS the atom:feed element containing them contains an atom:author element itself.
|
||||||
|
// atom:entry elements MUST NOT contain more than one atom:author element.
|
||||||
feed.Entries = append(feed.Entries, item)
|
Author AtomPerson `xml:"author"`
|
||||||
}
|
|
||||||
|
|
||||||
return feed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type atom03Entry struct {
|
type Atom03Content struct {
|
||||||
ID string `xml:"id"`
|
// Content constructs MAY have a "type" attribute, whose value indicates the media type of the content.
|
||||||
Title atom03Text `xml:"title"`
|
// When present, this attribute's value MUST be a registered media type [RFC2045].
|
||||||
Modified string `xml:"modified"`
|
// If not present, its value MUST be considered to be "text/plain".
|
||||||
Issued string `xml:"issued"`
|
Type string `xml:"type,attr"`
|
||||||
Created string `xml:"created"`
|
|
||||||
Links atomLinks `xml:"link"`
|
|
||||||
Summary atom03Text `xml:"summary"`
|
|
||||||
Content atom03Text `xml:"content"`
|
|
||||||
Author atomPerson `xml:"author"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom03Entry) Transform() *model.Entry {
|
// Content constructs MAY have a "mode" attribute, whose value indicates the method used to encode the content.
|
||||||
entry := model.NewEntry()
|
// When present, this attribute's value MUST be listed below.
|
||||||
entry.URL = a.Links.originalLink()
|
// If not present, its value MUST be considered to be "xml".
|
||||||
entry.Date = a.entryDate()
|
//
|
||||||
entry.Author = a.Author.String()
|
// "xml": A mode attribute with the value "xml" indicates that the element's content is inline xml (for example, namespace-qualified XHTML).
|
||||||
entry.Hash = a.entryHash()
|
//
|
||||||
entry.Content = a.entryContent()
|
// "escaped": A mode attribute with the value "escaped" indicates that the element's content is an escaped string.
|
||||||
entry.Title = a.entryTitle()
|
// Processors MUST unescape the element's content before considering it as content of the indicated media type.
|
||||||
return entry
|
//
|
||||||
}
|
// "base64": A mode attribute with the value "base64" indicates that the element's content is base64-encoded [RFC2045].
|
||||||
|
// Processors MUST decode the element's content before considering it as content of the the indicated media type.
|
||||||
|
Mode string `xml:"mode,attr"`
|
||||||
|
|
||||||
func (a *atom03Entry) entryTitle() string {
|
|
||||||
return sanitizer.StripTags(a.Title.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom03Entry) entryContent() string {
|
|
||||||
content := a.Content.String()
|
|
||||||
if content != "" {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := a.Summary.String()
|
|
||||||
if summary != "" {
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom03Entry) entryDate() time.Time {
|
|
||||||
dateText := ""
|
|
||||||
for _, value := range []string{a.Issued, a.Modified, a.Created} {
|
|
||||||
if value != "" {
|
|
||||||
dateText = value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if dateText != "" {
|
|
||||||
result, err := date.Parse(dateText)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Unable to parse date from Atom 0.3 feed",
|
|
||||||
slog.String("date", dateText),
|
|
||||||
slog.String("id", a.ID),
|
|
||||||
slog.Any("error", err),
|
|
||||||
)
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom03Entry) entryHash() string {
|
|
||||||
for _, value := range []string{a.ID, a.Links.originalLink()} {
|
|
||||||
if value != "" {
|
|
||||||
return crypto.Hash(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type atom03Text struct {
|
|
||||||
Type string `xml:"type,attr"`
|
|
||||||
Mode string `xml:"mode,attr"`
|
|
||||||
CharData string `xml:",chardata"`
|
CharData string `xml:",chardata"`
|
||||||
InnerXML string `xml:",innerxml"`
|
InnerXML string `xml:",innerxml"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *atom03Text) String() string {
|
func (a *Atom03Content) Content() string {
|
||||||
content := ""
|
content := ""
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/crypto"
|
||||||
|
"miniflux.app/v2/internal/model"
|
||||||
|
"miniflux.app/v2/internal/reader/date"
|
||||||
|
"miniflux.app/v2/internal/reader/sanitizer"
|
||||||
|
"miniflux.app/v2/internal/urllib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Atom03Adapter struct {
|
||||||
|
atomFeed *Atom03Feed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAtom03Adapter(atomFeed *Atom03Feed) *Atom03Adapter {
|
||||||
|
return &Atom03Adapter{atomFeed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Atom03Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||||
|
feed := new(model.Feed)
|
||||||
|
|
||||||
|
// Populate the feed URL.
|
||||||
|
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
|
||||||
|
if feedURL != "" {
|
||||||
|
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
|
||||||
|
feed.FeedURL = absoluteFeedURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feed.FeedURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the site URL.
|
||||||
|
siteURL := a.atomFeed.Links.OriginalLink()
|
||||||
|
if siteURL != "" {
|
||||||
|
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
|
||||||
|
feed.SiteURL = absoluteSiteURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feed.SiteURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the feed title.
|
||||||
|
feed.Title = a.atomFeed.Title.Content()
|
||||||
|
if feed.Title == "" {
|
||||||
|
feed.Title = feed.SiteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, atomEntry := range a.atomFeed.Entries {
|
||||||
|
entry := model.NewEntry()
|
||||||
|
|
||||||
|
// Populate the entry URL.
|
||||||
|
entry.URL = atomEntry.Links.OriginalLink()
|
||||||
|
if entry.URL != "" {
|
||||||
|
if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
|
||||||
|
entry.URL = absoluteEntryURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry content.
|
||||||
|
entry.Content = atomEntry.Content.Content()
|
||||||
|
if entry.Content == "" {
|
||||||
|
entry.Content = atomEntry.Summary.Content()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry title.
|
||||||
|
entry.Title = atomEntry.Title.Content()
|
||||||
|
if entry.Title == "" {
|
||||||
|
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
|
||||||
|
}
|
||||||
|
if entry.Title == "" {
|
||||||
|
entry.Title = entry.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry author.
|
||||||
|
entry.Author = atomEntry.Author.PersonName()
|
||||||
|
if entry.Author == "" {
|
||||||
|
entry.Author = a.atomFeed.Author.PersonName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry date.
|
||||||
|
for _, value := range []string{atomEntry.Issued, atomEntry.Modified, atomEntry.Created} {
|
||||||
|
if parsedDate, err := date.Parse(value); err == nil {
|
||||||
|
entry.Date = parsedDate
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
slog.Debug("Unable to parse date from Atom 0.3 feed",
|
||||||
|
slog.String("date", value),
|
||||||
|
slog.String("id", atomEntry.ID),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Date.IsZero() {
|
||||||
|
entry.Date = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the entry hash.
|
||||||
|
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
|
||||||
|
if value != "" {
|
||||||
|
entry.Hash = crypto.Hash(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.Entries = append(feed.Entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feed
|
||||||
|
}
|
|
@ -27,7 +27,7 @@ func TestParseAtom03(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,7 @@ func TestParseAtom03(t *testing.T) {
|
||||||
t.Errorf("Incorrect title, got: %s", feed.Title)
|
t.Errorf("Incorrect title, got: %s", feed.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.FeedURL != "http://diveintomark.org/" {
|
if feed.FeedURL != "http://diveintomark.org/atom.xml" {
|
||||||
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
|
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,6 +74,28 @@ func TestParseAtom03(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAtom03WithoutSiteURL(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
|
||||||
|
<modified>2003-12-13T18:30:02Z</modified>
|
||||||
|
<author><name>Mark Pilgrim</name></author>
|
||||||
|
<entry>
|
||||||
|
<title>Atom 0.3 snapshot</title>
|
||||||
|
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
|
||||||
|
<id>tag:diveintomark.org,2003:3.2397</id>
|
||||||
|
</entry>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("http://diveintomark.org/atom.xml", bytes.NewReader([]byte(data)), "0.3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.SiteURL != "http://diveintomark.org/atom.xml" {
|
||||||
|
t.Errorf("Incorrect title, got: %s", feed.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseAtom03WithoutFeedTitle(t *testing.T) {
|
func TestParseAtom03WithoutFeedTitle(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
|
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
|
||||||
|
@ -87,7 +109,7 @@ func TestParseAtom03WithoutFeedTitle(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -110,7 +132,7 @@ func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -138,7 +160,7 @@ func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -166,7 +188,7 @@ func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -197,7 +219,7 @@ func TestParseAtom03WithSummaryOnly(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -228,7 +250,7 @@ func TestParseAtom03WithXMLContent(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -259,7 +281,7 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)), "0.3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,286 +6,200 @@ package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"html"
|
"html"
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"miniflux.app/v2/internal/crypto"
|
|
||||||
"miniflux.app/v2/internal/model"
|
|
||||||
"miniflux.app/v2/internal/reader/date"
|
|
||||||
"miniflux.app/v2/internal/reader/media"
|
"miniflux.app/v2/internal/reader/media"
|
||||||
"miniflux.app/v2/internal/reader/sanitizer"
|
"miniflux.app/v2/internal/reader/sanitizer"
|
||||||
"miniflux.app/v2/internal/urllib"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The "atom:feed" element is the document (i.e., top-level) element of
|
||||||
|
// an Atom Feed Document, acting as a container for metadata and data
|
||||||
|
// associated with the feed. Its element children consist of metadata
|
||||||
|
// elements followed by zero or more atom:entry child elements.
|
||||||
|
//
|
||||||
// Specs:
|
// Specs:
|
||||||
// https://tools.ietf.org/html/rfc4287
|
// https://tools.ietf.org/html/rfc4287
|
||||||
// https://validator.w3.org/feed/docs/atom.html
|
// https://validator.w3.org/feed/docs/atom.html
|
||||||
type atom10Feed struct {
|
type Atom10Feed struct {
|
||||||
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
||||||
ID string `xml:"id"`
|
|
||||||
Title atom10Text `xml:"title"`
|
// The "atom:id" element conveys a permanent, universally unique
|
||||||
Authors atomAuthors `xml:"author"`
|
// identifier for an entry or feed.
|
||||||
Icon string `xml:"icon"`
|
//
|
||||||
Links atomLinks `xml:"link"`
|
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
|
||||||
Entries []atom10Entry `xml:"entry"`
|
// definition of "IRI" excludes relative references. Though the IRI
|
||||||
|
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
|
||||||
|
// can be dereferenced.
|
||||||
|
//
|
||||||
|
// atom:feed elements MUST contain exactly one atom:id element.
|
||||||
|
ID string `xml:"http://www.w3.org/2005/Atom id"`
|
||||||
|
|
||||||
|
// The "atom:title" element is a Text construct that conveys a human-
|
||||||
|
// readable title for an entry or feed.
|
||||||
|
//
|
||||||
|
// atom:feed elements MUST contain exactly one atom:title element.
|
||||||
|
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
|
||||||
|
|
||||||
|
// The "atom:author" element is a Person construct that indicates the
|
||||||
|
// author of the entry or feed.
|
||||||
|
//
|
||||||
|
// atom:feed elements MUST contain one or more atom:author elements,
|
||||||
|
// unless all of the atom:feed element's child atom:entry elements
|
||||||
|
// contain at least one atom:author element.
|
||||||
|
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
|
||||||
|
|
||||||
|
// The "atom:icon" element's content is an IRI reference [RFC3987] that
|
||||||
|
// identifies an image that provides iconic visual identification for a
|
||||||
|
// feed.
|
||||||
|
//
|
||||||
|
// atom:feed elements MUST NOT contain more than one atom:icon element.
|
||||||
|
Icon string `xml:"http://www.w3.org/2005/Atom icon"`
|
||||||
|
|
||||||
|
// The "atom:logo" element's content is an IRI reference [RFC3987] that
|
||||||
|
// identifies an image that provides visual identification for a feed.
|
||||||
|
//
|
||||||
|
// atom:feed elements MUST NOT contain more than one atom:logo element.
|
||||||
|
Logo string `xml:"http://www.w3.org/2005/Atom logo"`
|
||||||
|
|
||||||
|
// atom:feed elements SHOULD contain one atom:link element with a rel
|
||||||
|
// attribute value of "self". This is the preferred URI for
|
||||||
|
// retrieving Atom Feed Documents representing this Atom feed.
|
||||||
|
//
|
||||||
|
// atom:feed elements MUST NOT contain more than one atom:link
|
||||||
|
// element with a rel attribute value of "alternate" that has the
|
||||||
|
// same combination of type and hreflang attribute values.
|
||||||
|
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
|
||||||
|
|
||||||
|
// The "atom:category" element conveys information about a category
|
||||||
|
// associated with an entry or feed. This specification assigns no
|
||||||
|
// meaning to the content (if any) of this element.
|
||||||
|
//
|
||||||
|
// atom:feed elements MAY contain any number of atom:category
|
||||||
|
// elements.
|
||||||
|
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
|
||||||
|
|
||||||
|
Entries []Atom10Entry `xml:"http://www.w3.org/2005/Atom entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *atom10Feed) Transform(baseURL string) *model.Feed {
|
type Atom10Entry struct {
|
||||||
var err error
|
// The "atom:id" element conveys a permanent, universally unique
|
||||||
|
// identifier for an entry or feed.
|
||||||
|
//
|
||||||
|
// Its content MUST be an IRI, as defined by [RFC3987]. Note that the
|
||||||
|
// definition of "IRI" excludes relative references. Though the IRI
|
||||||
|
// might use a dereferencable scheme, Atom Processors MUST NOT assume it
|
||||||
|
// can be dereferenced.
|
||||||
|
//
|
||||||
|
// atom:entry elements MUST contain exactly one atom:id element.
|
||||||
|
ID string `xml:"http://www.w3.org/2005/Atom id"`
|
||||||
|
|
||||||
feed := new(model.Feed)
|
// The "atom:title" element is a Text construct that conveys a human-
|
||||||
|
// readable title for an entry or feed.
|
||||||
|
//
|
||||||
|
// atom:entry elements MUST contain exactly one atom:title element.
|
||||||
|
Title Atom10Text `xml:"http://www.w3.org/2005/Atom title"`
|
||||||
|
|
||||||
feedURL := a.Links.firstLinkWithRelation("self")
|
// The "atom:published" element is a Date construct indicating an
|
||||||
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, feedURL)
|
// instant in time associated with an event early in the life cycle of
|
||||||
if err != nil {
|
// the entry.
|
||||||
feed.FeedURL = feedURL
|
Published string `xml:"http://www.w3.org/2005/Atom published"`
|
||||||
}
|
|
||||||
|
|
||||||
siteURL := a.Links.originalLink()
|
// The "atom:updated" element is a Date construct indicating the most
|
||||||
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, siteURL)
|
// recent instant in time when an entry or feed was modified in a way
|
||||||
if err != nil {
|
// the publisher considers significant. Therefore, not all
|
||||||
feed.SiteURL = siteURL
|
// modifications necessarily result in a changed atom:updated value.
|
||||||
}
|
//
|
||||||
|
// atom:entry elements MUST contain exactly one atom:updated element.
|
||||||
|
Updated string `xml:"http://www.w3.org/2005/Atom updated"`
|
||||||
|
|
||||||
feed.Title = html.UnescapeString(a.Title.String())
|
// atom:entry elements MUST NOT contain more than one atom:link
|
||||||
if feed.Title == "" {
|
// element with a rel attribute value of "alternate" that has the
|
||||||
feed.Title = feed.SiteURL
|
// same combination of type and hreflang attribute values.
|
||||||
}
|
Links AtomLinks `xml:"http://www.w3.org/2005/Atom link"`
|
||||||
|
|
||||||
feed.IconURL = strings.TrimSpace(a.Icon)
|
// atom:entry elements MUST contain an atom:summary element in either
|
||||||
|
// of the following cases:
|
||||||
|
// * the atom:entry contains an atom:content that has a "src"
|
||||||
|
// attribute (and is thus empty).
|
||||||
|
// * the atom:entry contains content that is encoded in Base64;
|
||||||
|
// i.e., the "type" attribute of atom:content is a MIME media type
|
||||||
|
// [MIMEREG], but is not an XML media type [RFC3023], does not
|
||||||
|
// begin with "text/", and does not end with "/xml" or "+xml".
|
||||||
|
//
|
||||||
|
// atom:entry elements MUST NOT contain more than one atom:summary
|
||||||
|
// element.
|
||||||
|
Summary Atom10Text `xml:"http://www.w3.org/2005/Atom summary"`
|
||||||
|
|
||||||
for _, entry := range a.Entries {
|
// atom:entry elements MUST NOT contain more than one atom:content
|
||||||
item := entry.Transform()
|
// element.
|
||||||
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, item.URL)
|
Content Atom10Text `xml:"http://www.w3.org/2005/Atom content"`
|
||||||
if err == nil {
|
|
||||||
item.URL = entryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.Author == "" {
|
// The "atom:author" element is a Person construct that indicates the
|
||||||
item.Author = a.Authors.String()
|
// author of the entry or feed.
|
||||||
}
|
//
|
||||||
|
// atom:entry elements MUST contain one or more atom:author elements
|
||||||
|
Authors AtomPersons `xml:"http://www.w3.org/2005/Atom author"`
|
||||||
|
|
||||||
if item.Title == "" {
|
// The "atom:category" element conveys information about a category
|
||||||
item.Title = sanitizer.TruncateHTML(item.Content, 100)
|
// associated with an entry or feed. This specification assigns no
|
||||||
}
|
// meaning to the content (if any) of this element.
|
||||||
|
//
|
||||||
|
// atom:entry elements MAY contain any number of atom:category
|
||||||
|
// elements.
|
||||||
|
Categories AtomCategories `xml:"http://www.w3.org/2005/Atom category"`
|
||||||
|
|
||||||
if item.Title == "" {
|
media.MediaItemElement
|
||||||
item.Title = item.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.Entries = append(feed.Entries, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return feed
|
|
||||||
}
|
|
||||||
|
|
||||||
type atom10Entry struct {
|
|
||||||
ID string `xml:"id"`
|
|
||||||
Title atom10Text `xml:"title"`
|
|
||||||
Published string `xml:"published"`
|
|
||||||
Updated string `xml:"updated"`
|
|
||||||
Links atomLinks `xml:"link"`
|
|
||||||
Summary atom10Text `xml:"summary"`
|
|
||||||
Content atom10Text `xml:"http://www.w3.org/2005/Atom content"`
|
|
||||||
Authors atomAuthors `xml:"author"`
|
|
||||||
Categories []atom10Category `xml:"category"`
|
|
||||||
media.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom10Entry) Transform() *model.Entry {
|
|
||||||
entry := model.NewEntry()
|
|
||||||
entry.URL = a.Links.originalLink()
|
|
||||||
entry.Date = a.entryDate()
|
|
||||||
entry.Author = a.Authors.String()
|
|
||||||
entry.Hash = a.entryHash()
|
|
||||||
entry.Content = a.entryContent()
|
|
||||||
entry.Title = a.entryTitle()
|
|
||||||
entry.Enclosures = a.entryEnclosures()
|
|
||||||
entry.CommentsURL = a.entryCommentsURL()
|
|
||||||
entry.Tags = a.entryCategories()
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom10Entry) entryTitle() string {
|
|
||||||
return html.UnescapeString(a.Title.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom10Entry) entryContent() string {
|
|
||||||
content := a.Content.String()
|
|
||||||
if content != "" {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := a.Summary.String()
|
|
||||||
if summary != "" {
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaDescription := a.FirstMediaDescription()
|
|
||||||
if mediaDescription != "" {
|
|
||||||
return mediaDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: The published date represents the original creation date for YouTube feeds.
|
|
||||||
// Example:
|
|
||||||
// <published>2019-01-26T08:02:28+00:00</published>
|
|
||||||
// <updated>2019-01-29T07:27:27+00:00</updated>
|
|
||||||
func (a *atom10Entry) entryDate() time.Time {
|
|
||||||
dateText := a.Published
|
|
||||||
if dateText == "" {
|
|
||||||
dateText = a.Updated
|
|
||||||
}
|
|
||||||
|
|
||||||
if dateText != "" {
|
|
||||||
result, err := date.Parse(dateText)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Unable to parse date from Atom 0.3 feed",
|
|
||||||
slog.String("date", dateText),
|
|
||||||
slog.String("id", a.ID),
|
|
||||||
slog.Any("error", err),
|
|
||||||
)
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom10Entry) entryHash() string {
|
|
||||||
for _, value := range []string{a.ID, a.Links.originalLink()} {
|
|
||||||
if value != "" {
|
|
||||||
return crypto.Hash(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atom10Entry) entryEnclosures() model.EnclosureList {
|
|
||||||
enclosures := make(model.EnclosureList, 0)
|
|
||||||
duplicates := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, mediaThumbnail := range a.AllMediaThumbnails() {
|
|
||||||
if _, found := duplicates[mediaThumbnail.URL]; !found {
|
|
||||||
duplicates[mediaThumbnail.URL] = true
|
|
||||||
enclosures = append(enclosures, &model.Enclosure{
|
|
||||||
URL: mediaThumbnail.URL,
|
|
||||||
MimeType: mediaThumbnail.MimeType(),
|
|
||||||
Size: mediaThumbnail.Size(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, link := range a.Links {
|
|
||||||
if strings.EqualFold(link.Rel, "enclosure") {
|
|
||||||
if link.URL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, found := duplicates[link.URL]; !found {
|
|
||||||
duplicates[link.URL] = true
|
|
||||||
length, _ := strconv.ParseInt(link.Length, 10, 0)
|
|
||||||
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mediaContent := range a.AllMediaContents() {
|
|
||||||
if _, found := duplicates[mediaContent.URL]; !found {
|
|
||||||
duplicates[mediaContent.URL] = true
|
|
||||||
enclosures = append(enclosures, &model.Enclosure{
|
|
||||||
URL: mediaContent.URL,
|
|
||||||
MimeType: mediaContent.MimeType(),
|
|
||||||
Size: mediaContent.Size(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mediaPeerLink := range a.AllMediaPeerLinks() {
|
|
||||||
if _, found := duplicates[mediaPeerLink.URL]; !found {
|
|
||||||
duplicates[mediaPeerLink.URL] = true
|
|
||||||
enclosures = append(enclosures, &model.Enclosure{
|
|
||||||
URL: mediaPeerLink.URL,
|
|
||||||
MimeType: mediaPeerLink.MimeType(),
|
|
||||||
Size: mediaPeerLink.Size(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return enclosures
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *atom10Entry) entryCategories() []string {
|
|
||||||
categoryList := make([]string, 0)
|
|
||||||
|
|
||||||
for _, atomCategory := range r.Categories {
|
|
||||||
if strings.TrimSpace(atomCategory.Label) != "" {
|
|
||||||
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Label))
|
|
||||||
} else {
|
|
||||||
categoryList = append(categoryList, strings.TrimSpace(atomCategory.Term))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return categoryList
|
|
||||||
}
|
|
||||||
|
|
||||||
// See https://tools.ietf.org/html/rfc4685#section-4
|
|
||||||
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
|
|
||||||
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
|
|
||||||
func (a *atom10Entry) entryCommentsURL() string {
|
|
||||||
commentsURL := a.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
|
|
||||||
if urllib.IsAbsoluteURL(commentsURL) {
|
|
||||||
return commentsURL
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type atom10Text struct {
|
|
||||||
Type string `xml:"type,attr"`
|
|
||||||
CharData string `xml:",chardata"`
|
|
||||||
InnerXML string `xml:",innerxml"`
|
|
||||||
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type atom10Category struct {
|
|
||||||
Term string `xml:"term,attr"`
|
|
||||||
Label string `xml:"label,attr"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Text construct contains human-readable text, usually in small
|
||||||
|
// quantities. The content of Text constructs is Language-Sensitive.
|
||||||
|
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1
|
||||||
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
|
// Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1
|
||||||
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
|
// HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2
|
||||||
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
|
// XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3
|
||||||
func (a *atom10Text) String() string {
|
type Atom10Text struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
CharData string `xml:",chardata"`
|
||||||
|
InnerXML string `xml:",innerxml"`
|
||||||
|
XHTMLRootElement AtomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Atom10Text) Body() string {
|
||||||
var content string
|
var content string
|
||||||
switch {
|
|
||||||
case a.Type == "", a.Type == "text", a.Type == "text/plain":
|
if strings.EqualFold(a.Type, "xhtml") {
|
||||||
if strings.HasPrefix(strings.TrimSpace(a.InnerXML), `<![CDATA[`) {
|
content = a.xhtmlContent()
|
||||||
content = html.EscapeString(a.CharData)
|
} else {
|
||||||
} else {
|
|
||||||
content = a.InnerXML
|
|
||||||
}
|
|
||||||
case a.Type == "xhtml":
|
|
||||||
var root = a.XHTMLRootElement
|
|
||||||
if root.XMLName.Local == "div" {
|
|
||||||
content = root.InnerXML
|
|
||||||
} else {
|
|
||||||
content = a.InnerXML
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
content = a.CharData
|
content = a.CharData
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(content)
|
return strings.TrimSpace(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomXHTMLRootElement struct {
|
func (a *Atom10Text) Title() string {
|
||||||
|
var content string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(a.Type, "xhtml"):
|
||||||
|
content = a.xhtmlContent()
|
||||||
|
case strings.Contains(a.InnerXML, "<![CDATA["):
|
||||||
|
content = html.UnescapeString(a.CharData)
|
||||||
|
default:
|
||||||
|
content = a.CharData
|
||||||
|
}
|
||||||
|
|
||||||
|
content = sanitizer.StripTags(content)
|
||||||
|
return strings.TrimSpace(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Atom10Text) xhtmlContent() string {
|
||||||
|
if a.XHTMLRootElement.XMLName.Local == "div" {
|
||||||
|
return a.XHTMLRootElement.InnerXML
|
||||||
|
}
|
||||||
|
return a.InnerXML
|
||||||
|
}
|
||||||
|
|
||||||
|
type AtomXHTMLRootElement struct {
|
||||||
XMLName xml.Name `xml:"div"`
|
XMLName xml.Name `xml:"div"`
|
||||||
InnerXML string `xml:",innerxml"`
|
InnerXML string `xml:",innerxml"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,254 @@
|
||||||
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/crypto"
|
||||||
|
"miniflux.app/v2/internal/model"
|
||||||
|
"miniflux.app/v2/internal/reader/date"
|
||||||
|
"miniflux.app/v2/internal/reader/sanitizer"
|
||||||
|
"miniflux.app/v2/internal/urllib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Atom10Adapter struct {
|
||||||
|
atomFeed *Atom10Feed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
|
||||||
|
return &Atom10Adapter{atomFeed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
|
||||||
|
feed := new(model.Feed)
|
||||||
|
|
||||||
|
// Populate the feed URL.
|
||||||
|
feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
|
||||||
|
if feedURL != "" {
|
||||||
|
if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
|
||||||
|
feed.FeedURL = absoluteFeedURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feed.FeedURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the site URL.
|
||||||
|
siteURL := a.atomFeed.Links.OriginalLink()
|
||||||
|
if siteURL != "" {
|
||||||
|
if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
|
||||||
|
feed.SiteURL = absoluteSiteURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feed.SiteURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the feed title.
|
||||||
|
feed.Title = a.atomFeed.Title.Body()
|
||||||
|
if feed.Title == "" {
|
||||||
|
feed.Title = feed.SiteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the feed icon.
|
||||||
|
if a.atomFeed.Icon != "" {
|
||||||
|
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
|
||||||
|
feed.IconURL = absoluteIconURL
|
||||||
|
}
|
||||||
|
} else if a.atomFeed.Logo != "" {
|
||||||
|
if absoluteLogoURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
|
||||||
|
feed.IconURL = absoluteLogoURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feed.Entries = a.populateEntries(feed.SiteURL)
|
||||||
|
return feed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Atom10Adapter) populateEntries(siteURL string) model.Entries {
|
||||||
|
entries := make(model.Entries, 0, len(a.atomFeed.Entries))
|
||||||
|
|
||||||
|
for _, atomEntry := range a.atomFeed.Entries {
|
||||||
|
entry := model.NewEntry()
|
||||||
|
|
||||||
|
// Populate the entry URL.
|
||||||
|
entry.URL = atomEntry.Links.OriginalLink()
|
||||||
|
if entry.URL != "" {
|
||||||
|
if absoluteEntryURL, err := urllib.AbsoluteURL(siteURL, entry.URL); err == nil {
|
||||||
|
entry.URL = absoluteEntryURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry content.
|
||||||
|
entry.Content = atomEntry.Content.Body()
|
||||||
|
if entry.Content == "" {
|
||||||
|
entry.Content = atomEntry.Summary.Body()
|
||||||
|
if entry.Content == "" {
|
||||||
|
entry.Content = atomEntry.FirstMediaDescription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry title.
|
||||||
|
entry.Title = atomEntry.Title.Title()
|
||||||
|
if entry.Title == "" {
|
||||||
|
entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
|
||||||
|
if entry.Title == "" {
|
||||||
|
entry.Title = entry.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry author.
|
||||||
|
authors := atomEntry.Authors.PersonNames()
|
||||||
|
if len(authors) == 0 {
|
||||||
|
authors = a.atomFeed.Authors.PersonNames()
|
||||||
|
}
|
||||||
|
sort.Strings(authors)
|
||||||
|
authors = slices.Compact(authors)
|
||||||
|
entry.Author = strings.Join(authors, ", ")
|
||||||
|
|
||||||
|
// Populate the entry date.
|
||||||
|
for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
|
||||||
|
if value != "" {
|
||||||
|
if parsedDate, err := date.Parse(value); err != nil {
|
||||||
|
slog.Debug("Unable to parse date from Atom 1.0 feed",
|
||||||
|
slog.String("date", value),
|
||||||
|
slog.String("url", entry.URL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
entry.Date = parsedDate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Date.IsZero() {
|
||||||
|
entry.Date = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate categories.
|
||||||
|
categories := atomEntry.Categories.CategoryNames()
|
||||||
|
if len(categories) == 0 {
|
||||||
|
categories = a.atomFeed.Categories.CategoryNames()
|
||||||
|
}
|
||||||
|
sort.Strings(categories)
|
||||||
|
entry.Tags = slices.Compact(categories)
|
||||||
|
|
||||||
|
// Populate the commentsURL if defined.
|
||||||
|
// See https://tools.ietf.org/html/rfc4685#section-4
|
||||||
|
// If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
|
||||||
|
// We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
|
||||||
|
commentsURL := atomEntry.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
|
||||||
|
if urllib.IsAbsoluteURL(commentsURL) {
|
||||||
|
entry.CommentsURL = commentsURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the entry hash.
|
||||||
|
for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
|
||||||
|
if value != "" {
|
||||||
|
entry.Hash = crypto.Hash(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry enclosures.
|
||||||
|
uniqueEnclosuresMap := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {
|
||||||
|
mediaURL := strings.TrimSpace(mediaThumbnail.URL)
|
||||||
|
if mediaURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, found := uniqueEnclosuresMap[mediaURL]; !found {
|
||||||
|
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
|
||||||
|
slog.Debug("Unable to build absolute URL for media thumbnail",
|
||||||
|
slog.String("url", mediaThumbnail.URL),
|
||||||
|
slog.String("site_url", siteURL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||||
|
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||||
|
URL: mediaAbsoluteURL,
|
||||||
|
MimeType: mediaThumbnail.MimeType(),
|
||||||
|
Size: mediaThumbnail.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
|
||||||
|
absoluteEnclosureURL, err := urllib.AbsoluteURL(siteURL, link.Href)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Unable to resolve absolute URL for enclosure",
|
||||||
|
slog.String("enclosure_url", link.Href),
|
||||||
|
slog.String("entry_url", entry.URL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; !found {
|
||||||
|
uniqueEnclosuresMap[absoluteEnclosureURL] = true
|
||||||
|
length, _ := strconv.ParseInt(link.Length, 10, 0)
|
||||||
|
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||||
|
URL: absoluteEnclosureURL,
|
||||||
|
MimeType: link.Type,
|
||||||
|
Size: length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mediaContent := range atomEntry.AllMediaContents() {
|
||||||
|
mediaURL := strings.TrimSpace(mediaContent.URL)
|
||||||
|
if mediaURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
|
||||||
|
slog.Debug("Unable to build absolute URL for media content",
|
||||||
|
slog.String("url", mediaContent.URL),
|
||||||
|
slog.String("site_url", siteURL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
|
||||||
|
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||||
|
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||||
|
URL: mediaAbsoluteURL,
|
||||||
|
MimeType: mediaContent.MimeType(),
|
||||||
|
Size: mediaContent.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
|
||||||
|
mediaURL := strings.TrimSpace(mediaPeerLink.URL)
|
||||||
|
if mediaURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mediaAbsoluteURL, err := urllib.AbsoluteURL(siteURL, mediaURL); err != nil {
|
||||||
|
slog.Debug("Unable to build absolute URL for media peer link",
|
||||||
|
slog.String("url", mediaPeerLink.URL),
|
||||||
|
slog.String("site_url", siteURL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; !found {
|
||||||
|
uniqueEnclosuresMap[mediaAbsoluteURL] = true
|
||||||
|
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||||
|
URL: mediaAbsoluteURL,
|
||||||
|
MimeType: mediaPeerLink.MimeType(),
|
||||||
|
Size: mediaPeerLink.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
|
@ -12,7 +12,6 @@ import (
|
||||||
func TestParseAtomSample(t *testing.T) {
|
func TestParseAtomSample(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="http://example.org/"/>
|
<link href="http://example.org/"/>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
|
@ -20,7 +19,6 @@ func TestParseAtomSample(t *testing.T) {
|
||||||
<name>John Doe</name>
|
<name>John Doe</name>
|
||||||
</author>
|
</author>
|
||||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title>Atom-Powered Robots Run Amok</title>
|
<title>Atom-Powered Robots Run Amok</title>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/2003/12/13/atom03"/>
|
||||||
|
@ -28,10 +26,9 @@ func TestParseAtomSample(t *testing.T) {
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<summary>Some text.</summary>
|
<summary>Some text.</summary>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)))
|
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -93,7 +90,7 @@ func TestParseFeedWithoutTitle(t *testing.T) {
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -123,7 +120,7 @@ func TestParseEntryWithoutTitleButWithURL(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -154,7 +151,7 @@ func TestParseEntryWithoutTitleButWithSummary(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -187,7 +184,7 @@ func TestParseEntryWithoutTitleButWithXHTMLContent(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -206,7 +203,7 @@ func TestParseFeedURL(t *testing.T) {
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -220,12 +217,31 @@ func TestParseFeedURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedWithRelativeURL(t *testing.T) {
|
func TestParseFeedWithRelativeFeedURL(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Example Feed</title>
|
||||||
|
<link rel="alternate" type="text/html" href="https://example.org/"/>
|
||||||
|
<link rel="self" type="application/atom+xml" href="/feed"/>
|
||||||
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.FeedURL != "https://example.org/feed" {
|
||||||
|
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithRelativeSiteURL(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="/blog/atom.xml" rel="self" type="application/atom+xml"/>
|
<link href="/blog/atom.xml" rel="self" type="application/atom+xml"/>
|
||||||
<link href="/blog"/>
|
<link href="/blog "/>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title>Test</title>
|
<title>Test</title>
|
||||||
|
@ -238,21 +254,53 @@ func TestParseFeedWithRelativeURL(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.FeedURL != "https://example.org/blog/atom.xml" {
|
if feed.FeedURL != "https://example.org/blog/atom.xml" {
|
||||||
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
|
t.Errorf("Incorrect feed URL, got: %q", feed.FeedURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.SiteURL != "https://example.org/blog" {
|
if feed.SiteURL != "https://example.org/blog" {
|
||||||
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
|
t.Errorf("Incorrect site URL, got: %q", feed.SiteURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].URL != "https://example.org/blog/article.html" {
|
if feed.Entries[0].URL != "https://example.org/blog/article.html" {
|
||||||
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
|
t.Errorf("Incorrect entry URL, got: %q", feed.Entries[0].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<link href="http://example.org "/>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.SiteURL != "http://example.org" {
|
||||||
|
t.Errorf("Incorrect site URL, got: %q", feed.SiteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<link href="/blog/atom.xml " rel="self" type="application/atom+xml"/>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.FeedURL != "https://example.org/blog/atom.xml" {
|
||||||
|
t.Errorf("Incorrect site URL, got: %q", feed.FeedURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -272,7 +320,7 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -298,7 +346,7 @@ func TestParseEntryURLWithTextHTMLType(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -324,7 +372,7 @@ func TestParseEntryURLWithNoRelAndNoType(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -350,7 +398,7 @@ func TestParseEntryURLWithAlternateRel(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -378,7 +426,7 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -412,7 +460,7 @@ func TestParseEntryWithPlainTextTitle(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -420,7 +468,7 @@ func TestParseEntryWithPlainTextTitle(t *testing.T) {
|
||||||
expected := `AT&T bought by SBC!`
|
expected := `AT&T bought by SBC!`
|
||||||
for i := range 2 {
|
for i := range 2 {
|
||||||
if feed.Entries[i].Title != expected {
|
if feed.Entries[i].Title != expected {
|
||||||
t.Errorf("Incorrect title for entry #%d, got: %q", i, feed.Entries[i].Title)
|
t.Errorf("Incorrect title for entry #%d, got: %q instead of %q", i, feed.Entries[i].Title, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -430,45 +478,32 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="http://example.org/"/>
|
<link href="http://example.org/"/>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title type="html"><code>Test</code> Test</title>
|
<title type="html"><code>Code</code> Test</title>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/z"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary>Some text.</summary>
|
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title type="html"><![CDATA[Test “Test”]]></title>
|
<title type="html"><![CDATA[Test with “unicode quote”]]></title>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/b"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary>Some text.</summary>
|
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title>
|
<title>
|
||||||
<![CDATA[Entry title with space around CDATA]]>
|
<![CDATA[Entry title with space around CDATA]]>
|
||||||
</title>
|
</title>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/c"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary>Some text.</summary>
|
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].Title != "<code>Test</code> Test" {
|
if feed.Entries[0].Title != "Code Test" {
|
||||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[1].Title != "Test “Test”" {
|
if feed.Entries[1].Title != "Test with “unicode quote”" {
|
||||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
|
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -497,13 +532,13 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].Title != `This is <b>XHTML</b> content.` {
|
if feed.Entries[0].Title != `This is XHTML content.` {
|
||||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
|
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -524,7 +559,7 @@ func TestParseEntryWithEmptyXHTMLTitle(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -551,7 +586,7 @@ func TestParseEntryWithXHTMLTitleWithoutDiv(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -577,7 +612,7 @@ func TestParseEntryWithNumericCharacterReferenceTitle(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -603,12 +638,12 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].Title != `'AT&T'` {
|
if feed.Entries[0].Title != `'AT&T'` {
|
||||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -629,7 +664,7 @@ func TestParseEntryWithXHTMLSummary(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -644,39 +679,33 @@ func TestParseEntryWithHTMLSummary(t *testing.T) {
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="http://example.org/"/>
|
<link href="http://example.org/"/>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title type="html">Example</title>
|
<title type="html">Example 1</title>
|
||||||
<link href="http://example.org/1"/>
|
<link href="http://example.org/1"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<summary type="html"><code>std::unique_ptr&lt;S&gt; myvar;</code></summary>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary type="html"><code>std::unique_ptr&lt;S&gt;</code></summary>
|
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title type="html">Example</title>
|
<title type="html">Example 2</title>
|
||||||
<link href="http://example.org/2"/>
|
<link href="http://example.org/2"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<summary type="text/html"><code>std::unique_ptr&lt;S&gt; myvar;</code></summary>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary type="text/html"><code>std::unique_ptr&lt;S&gt;</code></summary>
|
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<title type="html">Example</title>
|
<title type="html">Example 3</title>
|
||||||
<link href="http://example.org/3"/>
|
<link href="http://example.org/3"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<summary type="html"><![CDATA[<code>std::unique_ptr<S> myvar;</code>]]></summary>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary type="html"><![CDATA[<code>std::unique_ptr<S></code>]]></summary>
|
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := `<code>std::unique_ptr<S></code>`
|
if len(feed.Entries) != 3 {
|
||||||
|
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := `<code>std::unique_ptr<S> myvar;</code>`
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
if feed.Entries[i].Content != expected {
|
if feed.Entries[i].Content != expected {
|
||||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
||||||
|
@ -723,12 +752,12 @@ func TestParseEntryWithTextSummary(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := `AT&T <S>`
|
expected := `AT&T <S>`
|
||||||
for i := range 4 {
|
for i := range 4 {
|
||||||
if feed.Entries[i].Content != expected {
|
if feed.Entries[i].Content != expected {
|
||||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
||||||
|
@ -747,7 +776,7 @@ func TestParseEntryWithTextContent(t *testing.T) {
|
||||||
<link href="http://example.org/a"/>
|
<link href="http://example.org/a"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<content>AT&T <S></content>
|
<content>AT&T <strong>Strong Element</strong></content>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
|
@ -755,7 +784,7 @@ func TestParseEntryWithTextContent(t *testing.T) {
|
||||||
<link href="http://example.org/b"/>
|
<link href="http://example.org/b"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<content type="text">AT&T <S></content>
|
<content type="text">AT&T <strong>Strong Element</strong></content>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
|
@ -763,7 +792,7 @@ func TestParseEntryWithTextContent(t *testing.T) {
|
||||||
<link href="http://example.org/c"/>
|
<link href="http://example.org/c"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<content type="text/plain">AT&T <S></content>
|
<content type="text/plain">AT&T <strong>Strong Element</strong></content>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
|
@ -771,20 +800,20 @@ func TestParseEntryWithTextContent(t *testing.T) {
|
||||||
<link href="http://example.org/d"/>
|
<link href="http://example.org/d"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<content><![CDATA[AT&T <S>]]></content>
|
<content><![CDATA[AT&T <strong>Strong Element</strong>]]></content>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := `AT&T <S>`
|
expected := `AT&T <strong>Strong Element</strong>`
|
||||||
for i := range 4 {
|
for i := range 4 {
|
||||||
if feed.Entries[i].Content != expected {
|
if feed.Entries[i].Content != expected {
|
||||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
t.Errorf("Incorrect content for entry #%d, got: %q instead of %q", i, feed.Entries[i].Content, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -821,7 +850,7 @@ func TestParseEntryWithHTMLContent(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -852,7 +881,7 @@ func TestParseEntryWithXHTMLContent(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -881,7 +910,7 @@ func TestParseEntryWithAuthorName(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -910,7 +939,7 @@ func TestParseEntryWithoutAuthorName(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -925,7 +954,6 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="http://example.org/"/>
|
<link href="http://example.org/"/>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/2003/12/13/atom03"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
|
@ -938,10 +966,9 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
|
||||||
<name>Bob</name>
|
<name>Bob</name>
|
||||||
</author>
|
</author>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -951,7 +978,7 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEntryWithoutAuthor(t *testing.T) {
|
func TestParseFeedWithEntryWithoutAuthor(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
|
@ -959,17 +986,15 @@ func TestParseEntryWithoutAuthor(t *testing.T) {
|
||||||
<author>
|
<author>
|
||||||
<name>John Doe</name>
|
<name>John Doe</name>
|
||||||
</author>
|
</author>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/2003/12/13/atom03"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<summary>Some text.</summary>
|
<summary>Some text.</summary>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -990,17 +1015,18 @@ func TestParseFeedWithMultipleAuthors(t *testing.T) {
|
||||||
<author>
|
<author>
|
||||||
<name>Bob</name>
|
<name>Bob</name>
|
||||||
</author>
|
</author>
|
||||||
|
<author>
|
||||||
|
<name>Bob</name>
|
||||||
|
</author>
|
||||||
<entry>
|
<entry>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/2003/12/13/atom03"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<summary>Some text.</summary>
|
<summary>Some text.</summary>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1015,17 +1041,15 @@ func TestParseFeedWithoutAuthor(t *testing.T) {
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="http://example.org/"/>
|
<link href="http://example.org/"/>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://example.org/2003/12/13/atom03"/>
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<summary>Some text.</summary>
|
<summary>Some text.</summary>
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1075,13 +1099,13 @@ func TestParseEntryWithEnclosures(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(feed.Entries) != 1 {
|
if len(feed.Entries) != 1 {
|
||||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
|
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
|
||||||
|
@ -1116,6 +1140,89 @@ func TestParseEntryWithEnclosures(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseEntryWithRelativeEnclosureURL(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<id>https://www.example.org/myfeed</id>
|
||||||
|
<title>My Podcast Feed</title>
|
||||||
|
<link href="https://example.org" />
|
||||||
|
<link rel="self" href="https://example.org/myfeed" />
|
||||||
|
<entry>
|
||||||
|
<id>https://www.example.org/entries/1</id>
|
||||||
|
<title>Atom 1.0</title>
|
||||||
|
<updated>2005-07-15T12:00:00Z</updated>
|
||||||
|
<link href="https://www.example.org/entries/1" />
|
||||||
|
<link rel="enclosure"
|
||||||
|
type="audio/mpeg"
|
||||||
|
title="MP3"
|
||||||
|
href=" /myaudiofile.mp3 "
|
||||||
|
length="1234" />
|
||||||
|
</content>
|
||||||
|
</entry>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries) != 1 {
|
||||||
|
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries[0].Enclosures) != 1 {
|
||||||
|
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Entries[0].Enclosures[0].URL != "https://example.org/myaudiofile.mp3" {
|
||||||
|
t.Errorf("Incorrect enclosure URL, got: %q", feed.Entries[0].Enclosures[0].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEntryWithDuplicateEnclosureURL(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<id>http://www.example.org/myfeed</id>
|
||||||
|
<title>My Podcast Feed</title>
|
||||||
|
<link href="http://example.org" />
|
||||||
|
<link rel="self" href="http://example.org/myfeed" />
|
||||||
|
<entry>
|
||||||
|
<id>http://www.example.org/entries/1</id>
|
||||||
|
<title>Atom 1.0</title>
|
||||||
|
<updated>2005-07-15T12:00:00Z</updated>
|
||||||
|
<link href="http://www.example.org/entries/1" />
|
||||||
|
<link rel="enclosure"
|
||||||
|
type="audio/mpeg"
|
||||||
|
title="MP3"
|
||||||
|
href="http://www.example.org/myaudiofile.mp3"
|
||||||
|
length="1234" />
|
||||||
|
<link rel="enclosure"
|
||||||
|
type="audio/mpeg"
|
||||||
|
title="MP3"
|
||||||
|
href=" http://www.example.org/myaudiofile.mp3 "
|
||||||
|
length="1234" />
|
||||||
|
</content>
|
||||||
|
</entry>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries) != 1 {
|
||||||
|
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries[0].Enclosures) != 1 {
|
||||||
|
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
|
||||||
|
t.Errorf("Incorrect enclosure URL, got: %q", feed.Entries[0].Enclosures[0].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseEntryWithoutEnclosureURL(t *testing.T) {
|
func TestParseEntryWithoutEnclosureURL(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
@ -1135,7 +1242,7 @@ func TestParseEntryWithoutEnclosureURL(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1168,7 +1275,7 @@ func TestParseEntryWithPublished(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1194,7 +1301,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1206,7 +1313,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
|
||||||
|
|
||||||
func TestParseInvalidXml(t *testing.T) {
|
func TestParseInvalidXml(t *testing.T) {
|
||||||
data := `garbage`
|
data := `garbage`
|
||||||
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Parse should returns an error")
|
t.Error("Parse should returns an error")
|
||||||
}
|
}
|
||||||
|
@ -1221,7 +1328,7 @@ func TestParseTitleWithSingleQuote(t *testing.T) {
|
||||||
</feed>
|
</feed>
|
||||||
`
|
`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1240,7 +1347,7 @@ func TestParseTitleWithEncodedSingleQuote(t *testing.T) {
|
||||||
</feed>
|
</feed>
|
||||||
`
|
`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1259,7 +1366,7 @@ func TestParseTitleWithSingleQuoteAndHTMLType(t *testing.T) {
|
||||||
</feed>
|
</feed>
|
||||||
`
|
`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1278,7 +1385,7 @@ func TestParseWithHTMLEntity(t *testing.T) {
|
||||||
</feed>
|
</feed>
|
||||||
`
|
`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1297,7 +1404,7 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
|
||||||
</feed>
|
</feed>
|
||||||
`
|
`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1310,44 +1417,41 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
|
||||||
func TestParseMediaGroup(t *testing.T) {
|
func TestParseMediaGroup(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
||||||
<id>http://www.example.org/myfeed</id>
|
<id>https://www.example.org/myfeed</id>
|
||||||
<title>My Video Feed</title>
|
<title>My Video Feed</title>
|
||||||
<updated>2005-07-15T12:00:00Z</updated>
|
<updated>2005-07-15T12:00:00Z</updated>
|
||||||
<link href="http://example.org" />
|
<link href="https://example.org" />
|
||||||
<link rel="self" href="http://example.org/myfeed" />
|
<link rel="self" href="https://example.org/myfeed" />
|
||||||
<entry>
|
<entry>
|
||||||
<id>http://www.example.org/entries/1</id>
|
<id>https://www.example.org/entries/1</id>
|
||||||
<title>Some Video</title>
|
<title>Some Video</title>
|
||||||
<updated>2005-07-15T12:00:00Z</updated>
|
<updated>2005-07-15T12:00:00Z</updated>
|
||||||
<link href="http://www.example.org/entries/1" />
|
<link href="https://www.example.org/entries/1" />
|
||||||
<media:group>
|
<media:group>
|
||||||
<media:title>Another title</media:title>
|
<media:title>Another title</media:title>
|
||||||
<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
|
<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
|
||||||
<media:thumbnail url="https://example.org/thumbnail.jpg" width="480" height="360"/>
|
<media:content url=" /v/efg " type="application/x-shockwave-flash" width="640" height="390"/>
|
||||||
|
<media:content url=" " type="application/x-shockwave-flash" width="640" height="390"/>
|
||||||
|
<media:thumbnail url="https://www.example.org/duplicate-thumbnail.jpg" width="480" height="360"/>
|
||||||
|
<media:thumbnail url="https://www.example.org/duplicate-thumbnail.jpg" width="480" height="360"/>
|
||||||
|
<media:thumbnail url=" /thumbnail2.jpg " width="480" height="360"/>
|
||||||
|
<media:thumbnail url=" " width="480" height="360"/>
|
||||||
<media:description>Some description
|
<media:description>Some description
|
||||||
A website: http://example.org/</media:description>
|
A website: http://example.org/</media:description>
|
||||||
</media:group>
|
</media:group>
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(feed.Entries) != 1 {
|
if len(feed.Entries) != 1 {
|
||||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
|
if len(feed.Entries[0].Enclosures) != 4 {
|
||||||
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if feed.Entries[0].Content != `Some description<br>A website: <a href="http://example.org/">http://example.org/</a>` {
|
|
||||||
t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(feed.Entries[0].Enclosures) != 2 {
|
|
||||||
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1356,8 +1460,10 @@ A website: http://example.org/</media:description>
|
||||||
mimeType string
|
mimeType string
|
||||||
size int64
|
size int64
|
||||||
}{
|
}{
|
||||||
{"https://example.org/thumbnail.jpg", "image/*", 0},
|
{"https://www.example.org/duplicate-thumbnail.jpg", "image/*", 0},
|
||||||
|
{"https://example.org/thumbnail2.jpg", "image/*", 0},
|
||||||
{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
|
{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
|
||||||
|
{"https://example.org/v/efg", "application/x-shockwave-flash", 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
for index, enclosure := range feed.Entries[0].Enclosures {
|
for index, enclosure := range feed.Entries[0].Enclosures {
|
||||||
|
@ -1378,42 +1484,41 @@ A website: http://example.org/</media:description>
|
||||||
func TestParseMediaElements(t *testing.T) {
|
func TestParseMediaElements(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
||||||
<id>http://www.example.org/myfeed</id>
|
<id>https://www.example.org/myfeed</id>
|
||||||
<title>My Video Feed</title>
|
<title>My Video Feed</title>
|
||||||
<updated>2005-07-15T12:00:00Z</updated>
|
<updated>2005-07-15T12:00:00Z</updated>
|
||||||
<link href="http://example.org" />
|
<link href="https://example.org" />
|
||||||
<link rel="self" href="http://example.org/myfeed" />
|
<link rel="self" href="https://example.org/myfeed" />
|
||||||
<entry>
|
<entry>
|
||||||
<id>http://www.example.org/entries/1</id>
|
<id>https://www.example.org/entries/1</id>
|
||||||
<title>Some Video</title>
|
<title>Some Video</title>
|
||||||
<updated>2005-07-15T12:00:00Z</updated>
|
<updated>2005-07-15T12:00:00Z</updated>
|
||||||
<link href="http://www.example.org/entries/1" />
|
<link href="https://www.example.org/entries/1" />
|
||||||
<media:title>Another title</media:title>
|
<media:title>Another title</media:title>
|
||||||
<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
|
<media:content url="https://www.youtube.com/v/abcd" type="application/x-shockwave-flash" width="640" height="390"/>
|
||||||
<media:thumbnail url="https://example.org/thumbnail.jpg" width="480" height="360"/>
|
<media:content url=" /relative/media.mp4 " type="application/x-shockwave-flash" width="640" height="390"/>
|
||||||
|
<media:content url=" " type="application/x-shockwave-flash" width="640" height="390"/>
|
||||||
|
<media:thumbnail url="https://example.org/duplicated-thumbnail.jpg" width="480" height="360"/>
|
||||||
|
<media:thumbnail url=" https://example.org/duplicated-thumbnail.jpg " width="480" height="360"/>
|
||||||
|
<media:thumbnail url=" " width="480" height="360"/>
|
||||||
|
<media:peerLink type="application/x-bittorrent" href=" http://www.example.org/sampleFile.torrent " />
|
||||||
|
<media:peerLink type="application/x-bittorrent" href=" /sampleFile2.torrent" />
|
||||||
|
<media:peerLink type="application/x-bittorrent" href=" " />
|
||||||
<media:description>Some description
|
<media:description>Some description
|
||||||
A website: http://example.org/</media:description>
|
A website: http://example.org/</media:description>
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(feed.Entries) != 1 {
|
if len(feed.Entries) != 1 {
|
||||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
t.Fatalf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].URL != "http://www.example.org/entries/1" {
|
if len(feed.Entries[0].Enclosures) != 5 {
|
||||||
t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if feed.Entries[0].Content != `Some description<br>A website: <a href="http://example.org/">http://example.org/</a>` {
|
|
||||||
t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(feed.Entries[0].Enclosures) != 2 {
|
|
||||||
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1422,8 +1527,11 @@ A website: http://example.org/</media:description>
|
||||||
mimeType string
|
mimeType string
|
||||||
size int64
|
size int64
|
||||||
}{
|
}{
|
||||||
{"https://example.org/thumbnail.jpg", "image/*", 0},
|
{"https://example.org/duplicated-thumbnail.jpg", "image/*", 0},
|
||||||
{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
|
{"https://www.youtube.com/v/abcd", "application/x-shockwave-flash", 0},
|
||||||
|
{"https://example.org/relative/media.mp4", "application/x-shockwave-flash", 0},
|
||||||
|
{"http://www.example.org/sampleFile.torrent", "application/x-bittorrent", 0},
|
||||||
|
{"https://example.org/sampleFile2.torrent", "application/x-bittorrent", 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
for index, enclosure := range feed.Entries[0].Enclosures {
|
for index, enclosure := range feed.Entries[0].Enclosures {
|
||||||
|
@ -1467,7 +1575,7 @@ func TestParseRepliesLinkRelationWithHTMLType(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1511,7 +1619,7 @@ func TestParseRepliesLinkRelationWithXHTMLType(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1550,7 +1658,7 @@ func TestParseRepliesLinkRelationWithNoType(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1590,7 +1698,7 @@ func TestAbsoluteCommentsURL(t *testing.T) {
|
||||||
</entry>
|
</entry>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -1608,51 +1716,73 @@ func TestAbsoluteCommentsURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedWithCategories(t *testing.T) {
|
func TestParseItemWithCategories(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>Example Feed</title>
|
<title>Example Feed</title>
|
||||||
<link href="http://example.org/"/>
|
<link href="http://example.org/"/>
|
||||||
<author>
|
|
||||||
<name>Alice</name>
|
|
||||||
</author>
|
|
||||||
<author>
|
|
||||||
<name>Bob</name>
|
|
||||||
</author>
|
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
<link href="http://www.example.org/entries/1" />
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
<summary>Some text.</summary>
|
<summary>Some text.</summary>
|
||||||
<category term='Tech' />
|
<category term='ZZZZ' />
|
||||||
<category term='Technology' label='Science' />
|
<category term='Technology' label='Science' />
|
||||||
</entry>
|
</entry>
|
||||||
|
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(feed.Entries[0].Tags) != 2 {
|
if len(feed.Entries[0].Tags) != 2 {
|
||||||
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := "Tech"
|
expected := "Science"
|
||||||
result := feed.Entries[0].Tags[0]
|
result := feed.Entries[0].Tags[0]
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected = "Science"
|
expected = "ZZZZ"
|
||||||
result = feed.Entries[0].Tags[1]
|
result = feed.Entries[0].Tags[1]
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithCategories(t *testing.T) {
|
||||||
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Example Feed</title>
|
||||||
|
<link href="http://example.org/"/>
|
||||||
|
<category term='Test' label='Some Label' />
|
||||||
|
<category term='Test' label='Some Label' />
|
||||||
|
<category term='Test' label='Some Label' />
|
||||||
|
<entry>
|
||||||
|
<link href="http://www.example.org/entries/1" />
|
||||||
|
<updated>2003-12-13T18:30:02Z</updated>
|
||||||
|
<summary>Some text.</summary>
|
||||||
|
</entry>
|
||||||
|
</feed>`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries[0].Tags) != 1 {
|
||||||
|
t.Fatalf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "Some Label"
|
||||||
|
result := feed.Entries[0].Tags[0]
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseFeedWithIconURL(t *testing.T) {
|
func TestParseFeedWithIconURL(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
@ -1661,7 +1791,7 @@ func TestParseFeedWithIconURL(t *testing.T) {
|
||||||
<icon>http://example.org/icon.png</icon>
|
<icon>http://example.org/icon.png</icon>
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)), "10")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,77 +3,91 @@
|
||||||
|
|
||||||
package atom // import "miniflux.app/v2/internal/reader/atom"
|
package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type atomPerson struct {
|
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-3.2
|
||||||
Name string `xml:"name"`
|
type AtomPerson struct {
|
||||||
|
// The "atom:name" element's content conveys a human-readable name for the author.
|
||||||
|
// It MAY be the name of a corporation or other entity no individual authors can be named.
|
||||||
|
// Person constructs MUST contain exactly one "atom:name" element, whose content MUST be a string.
|
||||||
|
Name string `xml:"name"`
|
||||||
|
|
||||||
|
// The "atom:email" element's content conveys an e-mail address associated with the Person construct.
|
||||||
|
// Person constructs MAY contain an atom:email element, but MUST NOT contain more than one.
|
||||||
|
// Its content MUST be an e-mail address [RFC2822].
|
||||||
|
// Ordering of the element children of Person constructs MUST NOT be considered significant.
|
||||||
Email string `xml:"email"`
|
Email string `xml:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *atomPerson) String() string {
|
func (a *AtomPerson) PersonName() string {
|
||||||
name := ""
|
name := strings.TrimSpace(a.Name)
|
||||||
|
if name != "" {
|
||||||
switch {
|
return name
|
||||||
case a.Name != "":
|
|
||||||
name = a.Name
|
|
||||||
case a.Email != "":
|
|
||||||
name = a.Email
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(name)
|
return strings.TrimSpace(a.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomAuthors []*atomPerson
|
type AtomPersons []*AtomPerson
|
||||||
|
|
||||||
func (a atomAuthors) String() string {
|
func (a AtomPersons) PersonNames() []string {
|
||||||
var authors []string
|
var names []string
|
||||||
|
authorNamesMap := make(map[string]bool)
|
||||||
|
|
||||||
for _, person := range a {
|
for _, person := range a {
|
||||||
authors = append(authors, person.String())
|
personName := person.PersonName()
|
||||||
|
if _, ok := authorNamesMap[personName]; !ok {
|
||||||
|
names = append(names, personName)
|
||||||
|
authorNamesMap[personName] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(authors, ", ")
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomLink struct {
|
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.7
|
||||||
URL string `xml:"href,attr"`
|
type AtomLink struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
Type string `xml:"type,attr"`
|
Type string `xml:"type,attr"`
|
||||||
Rel string `xml:"rel,attr"`
|
Rel string `xml:"rel,attr"`
|
||||||
Length string `xml:"length,attr"`
|
Length string `xml:"length,attr"`
|
||||||
|
Title string `xml:"title,attr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomLinks []*atomLink
|
type AtomLinks []*AtomLink
|
||||||
|
|
||||||
func (a atomLinks) originalLink() string {
|
func (a AtomLinks) OriginalLink() string {
|
||||||
for _, link := range a {
|
for _, link := range a {
|
||||||
if strings.EqualFold(link.Rel, "alternate") {
|
if strings.EqualFold(link.Rel, "alternate") {
|
||||||
return strings.TrimSpace(link.URL)
|
return strings.TrimSpace(link.Href)
|
||||||
}
|
}
|
||||||
|
|
||||||
if link.Rel == "" && (link.Type == "" || link.Type == "text/html") {
|
if link.Rel == "" && (link.Type == "" || link.Type == "text/html") {
|
||||||
return strings.TrimSpace(link.URL)
|
return strings.TrimSpace(link.Href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a atomLinks) firstLinkWithRelation(relation string) string {
|
func (a AtomLinks) firstLinkWithRelation(relation string) string {
|
||||||
for _, link := range a {
|
for _, link := range a {
|
||||||
if strings.EqualFold(link.Rel, relation) {
|
if strings.EqualFold(link.Rel, relation) {
|
||||||
return strings.TrimSpace(link.URL)
|
return strings.TrimSpace(link.Href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
|
func (a AtomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
|
||||||
for _, link := range a {
|
for _, link := range a {
|
||||||
if strings.EqualFold(link.Rel, relation) {
|
if strings.EqualFold(link.Rel, relation) {
|
||||||
for _, contentType := range contentTypes {
|
for _, contentType := range contentTypes {
|
||||||
if strings.EqualFold(link.Type, contentType) {
|
if strings.EqualFold(link.Type, contentType) {
|
||||||
return strings.TrimSpace(link.URL)
|
return strings.TrimSpace(link.Href)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -81,3 +95,61 @@ func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ..
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AtomLinks) findAllLinksWithRelation(relation string) []*AtomLink {
|
||||||
|
var links []*AtomLink
|
||||||
|
|
||||||
|
for _, link := range a {
|
||||||
|
if strings.EqualFold(link.Rel, relation) {
|
||||||
|
link.Href = strings.TrimSpace(link.Href)
|
||||||
|
if link.Href != "" {
|
||||||
|
links = append(links, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "atom:category" element conveys information about a category
|
||||||
|
// associated with an entry or feed. This specification assigns no
|
||||||
|
// meaning to the content (if any) of this element.
|
||||||
|
//
|
||||||
|
// Specs: https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.2
|
||||||
|
type AtomCategory struct {
|
||||||
|
// The "term" attribute is a string that identifies the category to
|
||||||
|
// which the entry or feed belongs. Category elements MUST have a
|
||||||
|
// "term" attribute.
|
||||||
|
Term string `xml:"term,attr"`
|
||||||
|
|
||||||
|
// The "scheme" attribute is an IRI that identifies a categorization
|
||||||
|
// scheme. Category elements MAY have a "scheme" attribute.
|
||||||
|
Scheme string `xml:"scheme,attr"`
|
||||||
|
|
||||||
|
// The "label" attribute provides a human-readable label for display in
|
||||||
|
// end-user applications. The content of the "label" attribute is
|
||||||
|
// Language-Sensitive. Entities such as "&" and "<" represent
|
||||||
|
// their corresponding characters ("&" and "<", respectively), not
|
||||||
|
// markup. Category elements MAY have a "label" attribute.
|
||||||
|
Label string `xml:"label,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AtomCategories []AtomCategory
|
||||||
|
|
||||||
|
func (ac AtomCategories) CategoryNames() []string {
|
||||||
|
var categories []string
|
||||||
|
|
||||||
|
for _, category := range ac {
|
||||||
|
label := strings.TrimSpace(category.Label)
|
||||||
|
if label != "" {
|
||||||
|
categories = append(categories, label)
|
||||||
|
} else {
|
||||||
|
term := strings.TrimSpace(category.Term)
|
||||||
|
if term != "" {
|
||||||
|
categories = append(categories, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
package atom // import "miniflux.app/v2/internal/reader/atom"
|
package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
@ -12,45 +11,20 @@ import (
|
||||||
xml_decoder "miniflux.app/v2/internal/reader/xml"
|
xml_decoder "miniflux.app/v2/internal/reader/xml"
|
||||||
)
|
)
|
||||||
|
|
||||||
type atomFeed interface {
|
|
||||||
Transform(baseURL string) *model.Feed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse returns a normalized feed struct from a Atom feed.
|
// Parse returns a normalized feed struct from a Atom feed.
|
||||||
func Parse(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
|
func Parse(baseURL string, r io.ReadSeeker, version string) (*model.Feed, error) {
|
||||||
var rawFeed atomFeed
|
switch version {
|
||||||
if getAtomFeedVersion(r) == "0.3" {
|
case "0.3":
|
||||||
rawFeed = new(atom03Feed)
|
atomFeed := new(Atom03Feed)
|
||||||
} else {
|
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {
|
||||||
rawFeed = new(atom10Feed)
|
return nil, fmt.Errorf("atom: unable to parse Atom 0.3 feed: %w", err)
|
||||||
}
|
|
||||||
r.Seek(0, io.SeekStart)
|
|
||||||
|
|
||||||
if err := xml_decoder.NewXMLDecoder(r).Decode(rawFeed); err != nil {
|
|
||||||
return nil, fmt.Errorf("atom: unable to parse feed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawFeed.Transform(baseURL), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAtomFeedVersion(data io.ReadSeeker) string {
|
|
||||||
decoder := xml_decoder.NewXMLDecoder(data)
|
|
||||||
for {
|
|
||||||
token, _ := decoder.Token()
|
|
||||||
if token == nil {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
return NewAtom03Adapter(atomFeed).BuildFeed(baseURL), nil
|
||||||
if element, ok := token.(xml.StartElement); ok {
|
default:
|
||||||
if element.Name.Local == "feed" {
|
atomFeed := new(Atom10Feed)
|
||||||
for _, attr := range element.Attr {
|
if err := xml_decoder.NewXMLDecoder(r).Decode(atomFeed); err != nil {
|
||||||
if attr.Name.Local == "version" && attr.Value == "0.3" {
|
return nil, fmt.Errorf("atom: unable to parse Atom 1.0 feed: %w", err)
|
||||||
return "0.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "1.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return NewAtom10Adapter(atomFeed).BuildFeed(baseURL), nil
|
||||||
}
|
}
|
||||||
return "1.0"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,61 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package atom // import "miniflux.app/v2/internal/reader/atom"
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDetectAtom10(t *testing.T) {
|
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
||||||
|
|
||||||
<title>Example Feed</title>
|
|
||||||
<link href="http://example.org/"/>
|
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<author>
|
|
||||||
<name>John Doe</name>
|
|
||||||
</author>
|
|
||||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
|
||||||
|
|
||||||
<entry>
|
|
||||||
<title>Atom-Powered Robots Run Amok</title>
|
|
||||||
<link href="http://example.org/2003/12/13/atom03"/>
|
|
||||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
|
||||||
<updated>2003-12-13T18:30:02Z</updated>
|
|
||||||
<summary>Some text.</summary>
|
|
||||||
</entry>
|
|
||||||
|
|
||||||
</feed>`
|
|
||||||
|
|
||||||
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
|
|
||||||
if version != "1.0" {
|
|
||||||
t.Errorf(`Invalid Atom version detected: %s`, version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectAtom03(t *testing.T) {
|
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
|
|
||||||
<title>dive into mark</title>
|
|
||||||
<link rel="alternate" type="text/html" href="http://diveintomark.org/"/>
|
|
||||||
<modified>2003-12-13T18:30:02Z</modified>
|
|
||||||
<author><name>Mark Pilgrim</name></author>
|
|
||||||
<entry>
|
|
||||||
<title>Atom 0.3 snapshot</title>
|
|
||||||
<link rel="alternate" type="text/html" href="http://diveintomark.org/2003/12/13/atom03"/>
|
|
||||||
<id>tag:diveintomark.org,2003:3.2397</id>
|
|
||||||
<issued>2003-12-13T08:29:29-04:00</issued>
|
|
||||||
<modified>2003-12-13T18:30:02Z</modified>
|
|
||||||
<summary type="text/plain">This is a test</summary>
|
|
||||||
<content type="text/html" mode="escaped"><![CDATA[<p>HTML content</p>]]></content>
|
|
||||||
</entry>
|
|
||||||
</feed>`
|
|
||||||
|
|
||||||
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
|
|
||||||
if version != "0.3" {
|
|
||||||
t.Errorf(`Invalid Atom version detected: %s`, version)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,29 +3,13 @@
|
||||||
|
|
||||||
package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
|
package dublincore // import "miniflux.app/v2/internal/reader/dublincore"
|
||||||
|
|
||||||
import (
|
type DublinCoreChannelElement struct {
|
||||||
"strings"
|
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||||
|
|
||||||
"miniflux.app/v2/internal/reader/sanitizer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DublinCoreFeedElement represents Dublin Core feed XML elements.
|
|
||||||
type DublinCoreFeedElement struct {
|
|
||||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ channel>creator"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (feed *DublinCoreFeedElement) GetSanitizedCreator() string {
|
|
||||||
return strings.TrimSpace(sanitizer.StripTags(feed.DublinCoreCreator))
|
|
||||||
}
|
|
||||||
|
|
||||||
// DublinCoreItemElement represents Dublin Core entry XML elements.
|
|
||||||
type DublinCoreItemElement struct {
|
type DublinCoreItemElement struct {
|
||||||
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||||
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (item *DublinCoreItemElement) GetSanitizedCreator() string {
|
|
||||||
return strings.TrimSpace(sanitizer.StripTags(item.DublinCoreCreator))
|
|
||||||
}
|
|
||||||
|
|
|
@ -35,8 +35,3 @@ func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
|
||||||
// Transform document to UTF-8 from the specified encoding in XML prolog.
|
// Transform document to UTF-8 from the specified encoding in XML prolog.
|
||||||
return charset.NewReaderLabel(charsetLabel, r)
|
return charset.NewReaderLabel(charsetLabel, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CharsetReaderFromContentType is used when the encoding is not specified for the input document.
|
|
||||||
func CharsetReaderFromContentType(contentType string, input io.Reader) (io.Reader, error) {
|
|
||||||
return charset.NewReader(input, contentType)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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) {
|
func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, error) {
|
||||||
|
// We get the safe ciphers
|
||||||
|
ciphers := tls.CipherSuites()
|
||||||
|
if r.ignoreTLSErrors {
|
||||||
|
// and the insecure ones if we are ignoring TLS errors. This allows to connect to badly configured servers anyway
|
||||||
|
ciphers = append(ciphers, tls.InsecureCipherSuites()...)
|
||||||
|
}
|
||||||
|
cipherSuites := []uint16{}
|
||||||
|
for _, cipher := range ciphers {
|
||||||
|
cipherSuites = append(cipherSuites, cipher.ID)
|
||||||
|
}
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
Proxy: http.ProxyFromEnvironment,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
|
// Setting `DialContext` disables HTTP/2, this option forces the transport to try HTTP/2 regardless.
|
||||||
|
@ -128,6 +138,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||||
IdleConnTimeout: 10 * time.Second,
|
IdleConnTimeout: 10 * time.Second,
|
||||||
|
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: &tls.Config{
|
||||||
|
CipherSuites: cipherSuites,
|
||||||
InsecureSkipVerify: r.ignoreTLSErrors,
|
InsecureSkipVerify: r.ignoreTLSErrors,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -169,6 +180,7 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header = r.headers
|
req.Header = r.headers
|
||||||
|
req.Header.Set("Accept-Encoding", "br, gzip")
|
||||||
req.Header.Set("Accept", defaultAcceptHeader)
|
req.Header.Set("Accept", defaultAcceptHeader)
|
||||||
req.Header.Set("Connection", "close")
|
req.Header.Set("Connection", "close")
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,12 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"miniflux.app/v2/internal/locale"
|
"miniflux.app/v2/internal/locale"
|
||||||
)
|
)
|
||||||
|
@ -69,12 +73,31 @@ func (r *ResponseHandler) Close() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ResponseHandler) getReader(maxBodySize int64) io.ReadCloser {
|
||||||
|
contentEncoding := strings.ToLower(r.httpResponse.Header.Get("Content-Encoding"))
|
||||||
|
slog.Debug("Request response",
|
||||||
|
slog.String("effective_url", r.EffectiveURL()),
|
||||||
|
slog.String("content_length", r.httpResponse.Header.Get("Content-Length")),
|
||||||
|
slog.String("content_encoding", contentEncoding),
|
||||||
|
slog.String("content_type", r.httpResponse.Header.Get("Content-Type")),
|
||||||
|
)
|
||||||
|
|
||||||
|
reader := r.httpResponse.Body
|
||||||
|
switch contentEncoding {
|
||||||
|
case "br":
|
||||||
|
reader = NewBrotliReadCloser(r.httpResponse.Body)
|
||||||
|
case "gzip":
|
||||||
|
reader = NewGzipReadCloser(r.httpResponse.Body)
|
||||||
|
}
|
||||||
|
return http.MaxBytesReader(nil, reader, maxBodySize)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ResponseHandler) Body(maxBodySize int64) io.ReadCloser {
|
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) {
|
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)
|
buffer, err := io.ReadAll(limitedReader)
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
|
@ -94,23 +117,18 @@ func (r *ResponseHandler) ReadBody(maxBodySize int64) ([]byte, *locale.Localized
|
||||||
|
|
||||||
func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
|
func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
|
||||||
if r.clientErr != nil {
|
if r.clientErr != nil {
|
||||||
switch r.clientErr.(type) {
|
switch {
|
||||||
case x509.CertificateInvalidError, x509.HostnameError:
|
case isSSLError(r.clientErr):
|
||||||
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.tls_error", r.clientErr)
|
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.tls_error", r.clientErr)
|
||||||
case *net.OpError:
|
case isNetworkError(r.clientErr):
|
||||||
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_operation", r.clientErr)
|
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_operation", r.clientErr)
|
||||||
case net.Error:
|
case os.IsTimeout(r.clientErr):
|
||||||
networkErr := r.clientErr.(net.Error)
|
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_timeout", r.clientErr)
|
||||||
if networkErr.Timeout() {
|
case errors.Is(r.clientErr, io.EOF):
|
||||||
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.network_timeout", r.clientErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(r.clientErr, io.EOF) {
|
|
||||||
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_empty_response")
|
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_empty_response")
|
||||||
|
default:
|
||||||
|
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_client_error", r.clientErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: %w", r.clientErr), "error.http_client_error", r.clientErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.httpResponse.StatusCode {
|
switch r.httpResponse.StatusCode {
|
||||||
|
@ -145,3 +163,32 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isNetworkError(err error) bool {
|
||||||
|
if _, ok := err.(*url.Error); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var opErr *net.OpError
|
||||||
|
if ok := errors.As(err, &opErr); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSSLError(err error) bool {
|
||||||
|
var certErr x509.UnknownAuthorityError
|
||||||
|
if errors.As(err, &certErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostErr x509.HostnameError
|
||||||
|
if errors.As(err, &hostErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var algErr x509.InsecureAlgorithmError
|
||||||
|
return errors.As(err, &algErr)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package googleplay // import "miniflux.app/v2/internal/reader/googleplay"
|
||||||
|
|
||||||
|
// Specs:
|
||||||
|
// https://support.google.com/googleplay/podcasts/answer/6260341
|
||||||
|
// https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd
|
||||||
|
type GooglePlayChannelElement struct {
|
||||||
|
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
|
||||||
|
GooglePlayEmail string `xml:"http://www.google.com/schemas/play-podcasts/1.0 email"`
|
||||||
|
GooglePlayImage GooglePlayImageElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 image"`
|
||||||
|
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
|
||||||
|
GooglePlayCategory GooglePlayCategoryElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GooglePlayItemElement struct {
|
||||||
|
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
|
||||||
|
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
|
||||||
|
GooglePlayExplicit string `xml:"http://www.google.com/schemas/play-podcasts/1.0 explicit"`
|
||||||
|
GooglePlayBlock string `xml:"http://www.google.com/schemas/play-podcasts/1.0 block"`
|
||||||
|
GooglePlayNewFeedURL string `xml:"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GooglePlayImageElement struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GooglePlayCategoryElement struct {
|
||||||
|
Text string `xml:"text,attr"`
|
||||||
|
}
|
|
@ -236,14 +236,18 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||||
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
|
||||||
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
|
requestBuilder.WithUserAgent(originalFeed.UserAgent, config.Opts.HTTPClientUserAgent())
|
||||||
requestBuilder.WithCookie(originalFeed.Cookie)
|
requestBuilder.WithCookie(originalFeed.Cookie)
|
||||||
requestBuilder.WithETag(originalFeed.EtagHeader)
|
|
||||||
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
|
|
||||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||||
requestBuilder.UseProxy(originalFeed.FetchViaProxy)
|
requestBuilder.UseProxy(originalFeed.FetchViaProxy)
|
||||||
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
|
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
|
||||||
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
|
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
|
||||||
|
|
||||||
|
ignoreHTTPCache := originalFeed.IgnoreHTTPCache || forceRefresh
|
||||||
|
if !ignoreHTTPCache {
|
||||||
|
requestBuilder.WithETag(originalFeed.EtagHeader)
|
||||||
|
requestBuilder.WithLastModified(originalFeed.LastModifiedHeader)
|
||||||
|
}
|
||||||
|
|
||||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
|
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
|
||||||
defer responseHandler.Close()
|
defer responseHandler.Close()
|
||||||
|
|
||||||
|
@ -261,7 +265,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
||||||
return localizedError
|
return localizedError
|
||||||
}
|
}
|
||||||
|
|
||||||
if originalFeed.IgnoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
|
if ignoreHTTPCache || responseHandler.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
|
||||||
slog.Debug("Feed modified",
|
slog.Debug("Feed modified",
|
||||||
slog.Int64("user_id", userID),
|
slog.Int64("user_id", userID),
|
||||||
slog.Int64("feed_id", feedID),
|
slog.Int64("feed_id", feedID),
|
||||||
|
|
|
@ -15,11 +15,11 @@ import (
|
||||||
"miniflux.app/v2/internal/config"
|
"miniflux.app/v2/internal/config"
|
||||||
"miniflux.app/v2/internal/crypto"
|
"miniflux.app/v2/internal/crypto"
|
||||||
"miniflux.app/v2/internal/model"
|
"miniflux.app/v2/internal/model"
|
||||||
"miniflux.app/v2/internal/reader/encoding"
|
|
||||||
"miniflux.app/v2/internal/reader/fetcher"
|
"miniflux.app/v2/internal/reader/fetcher"
|
||||||
"miniflux.app/v2/internal/urllib"
|
"miniflux.app/v2/internal/urllib"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IconFinder struct {
|
type IconFinder struct {
|
||||||
|
@ -191,7 +191,7 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
|
||||||
"link[rel='apple-touch-icon-precomposed.png']",
|
"link[rel='apple-touch-icon-precomposed.png']",
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlDocumentReader, err := encoding.CharsetReaderFromContentType(contentType, body)
|
htmlDocumentReader, err := charset.NewReader(body, contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
|
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package itunes // import "miniflux.app/v2/internal/reader/itunes"
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
|
||||||
|
type ItunesChannelElement struct {
|
||||||
|
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
||||||
|
ItunesBlock string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd block"`
|
||||||
|
ItunesCategories []ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||||
|
ItunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
|
||||||
|
ItunesCopyright string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd copyright"`
|
||||||
|
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
||||||
|
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||||
|
Keywords string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd keywords"`
|
||||||
|
ItunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
|
||||||
|
ItunesOwner ItunesOwnerElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
|
||||||
|
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||||
|
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
|
||||||
|
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ItunesChannelElement) GetItunesCategories() []string {
|
||||||
|
var categories []string
|
||||||
|
for _, category := range i.ItunesCategories {
|
||||||
|
categories = append(categories, category.Text)
|
||||||
|
if category.SubCategory != nil {
|
||||||
|
categories = append(categories, category.SubCategory.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesItemElement struct {
|
||||||
|
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
||||||
|
ItunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
|
||||||
|
ItunesEpisodeType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType"`
|
||||||
|
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
||||||
|
ItunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
|
||||||
|
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||||
|
ItunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
|
||||||
|
ItunesSubtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
|
||||||
|
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||||
|
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
|
||||||
|
ItunesTranscript string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd transcript"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesImageElement struct {
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesCategoryElement struct {
|
||||||
|
Text string `xml:"text,attr"`
|
||||||
|
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItunesOwnerElement struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Email string `xml:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ItunesOwnerElement) String() string {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case i.Name != "":
|
||||||
|
name = i.Name
|
||||||
|
case i.Email != "":
|
||||||
|
name = i.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
|
@ -0,0 +1,172 @@
|
||||||
|
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package json // import "miniflux.app/v2/internal/reader/json"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"miniflux.app/v2/internal/crypto"
|
||||||
|
"miniflux.app/v2/internal/model"
|
||||||
|
"miniflux.app/v2/internal/reader/date"
|
||||||
|
"miniflux.app/v2/internal/reader/sanitizer"
|
||||||
|
"miniflux.app/v2/internal/urllib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JSONAdapter struct {
|
||||||
|
jsonFeed *JSONFeed
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJSONAdapter(jsonFeed *JSONFeed) *JSONAdapter {
|
||||||
|
return &JSONAdapter{jsonFeed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
|
||||||
|
feed := &model.Feed{
|
||||||
|
Title: strings.TrimSpace(j.jsonFeed.Title),
|
||||||
|
FeedURL: strings.TrimSpace(j.jsonFeed.FeedURL),
|
||||||
|
SiteURL: strings.TrimSpace(j.jsonFeed.HomePageURL),
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.FeedURL == "" {
|
||||||
|
feed.FeedURL = strings.TrimSpace(baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the feed URL if the site URL is empty.
|
||||||
|
if feed.SiteURL == "" {
|
||||||
|
feed.SiteURL = feed.FeedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if feedURL, err := urllib.AbsoluteURL(baseURL, feed.FeedURL); err == nil {
|
||||||
|
feed.FeedURL = feedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if siteURL, err := urllib.AbsoluteURL(baseURL, feed.SiteURL); err == nil {
|
||||||
|
feed.SiteURL = siteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the feed URL if the title is empty.
|
||||||
|
if feed.Title == "" {
|
||||||
|
feed.Title = feed.SiteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the icon URL if present.
|
||||||
|
for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
|
||||||
|
iconURL = strings.TrimSpace(iconURL)
|
||||||
|
if iconURL != "" {
|
||||||
|
if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, iconURL); err == nil {
|
||||||
|
feed.IconURL = absoluteIconURL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range j.jsonFeed.Items {
|
||||||
|
entry := model.NewEntry()
|
||||||
|
entry.Title = strings.TrimSpace(item.Title)
|
||||||
|
entry.URL = strings.TrimSpace(item.URL)
|
||||||
|
|
||||||
|
// Make sure the entry URL is absolute.
|
||||||
|
if entryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
|
||||||
|
entry.URL = entryURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// The entry title is optional, so we need to find a fallback.
|
||||||
|
if entry.Title == "" {
|
||||||
|
for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
|
||||||
|
if value != "" {
|
||||||
|
entry.Title = sanitizer.TruncateHTML(value, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the entry URL if the title is empty.
|
||||||
|
if entry.Title == "" {
|
||||||
|
entry.Title = entry.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry content.
|
||||||
|
for _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
entry.Content = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry date.
|
||||||
|
for _, value := range []string{item.DatePublished, item.DateModified} {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
if date, err := date.Parse(value); err != nil {
|
||||||
|
slog.Debug("Unable to parse date from JSON feed",
|
||||||
|
slog.String("date", value),
|
||||||
|
slog.String("url", entry.URL),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
entry.Date = date
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Date.IsZero() {
|
||||||
|
entry.Date = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry author.
|
||||||
|
itemAuthors := j.jsonFeed.Authors
|
||||||
|
itemAuthors = append(itemAuthors, item.Authors...)
|
||||||
|
itemAuthors = append(itemAuthors, item.Author, j.jsonFeed.Author)
|
||||||
|
|
||||||
|
var authorNames []string
|
||||||
|
for _, author := range itemAuthors {
|
||||||
|
authorName := strings.TrimSpace(author.Name)
|
||||||
|
if authorName != "" {
|
||||||
|
authorNames = append(authorNames, authorName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(authorNames)
|
||||||
|
authorNames = slices.Compact(authorNames)
|
||||||
|
entry.Author = strings.Join(authorNames, ", ")
|
||||||
|
|
||||||
|
// Populate the entry enclosures.
|
||||||
|
for _, attachment := range item.Attachments {
|
||||||
|
attachmentURL := strings.TrimSpace(attachment.URL)
|
||||||
|
if attachmentURL != "" {
|
||||||
|
if absoluteAttachmentURL, err := urllib.AbsoluteURL(feed.SiteURL, attachmentURL); err == nil {
|
||||||
|
entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
|
||||||
|
URL: absoluteAttachmentURL,
|
||||||
|
MimeType: attachment.MimeType,
|
||||||
|
Size: attachment.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the entry tags.
|
||||||
|
for _, tag := range item.Tags {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag != "" {
|
||||||
|
entry.Tags = append(entry.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a hash for the entry.
|
||||||
|
for _, value := range []string{item.ID, item.URL, item.ContentText + item.ContentHTML + item.Summary} {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
entry.Hash = crypto.Hash(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.Entries = append(feed.Entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feed
|
||||||
|
}
|
|
@ -3,207 +3,141 @@
|
||||||
|
|
||||||
package json // import "miniflux.app/v2/internal/reader/json"
|
package json // import "miniflux.app/v2/internal/reader/json"
|
||||||
|
|
||||||
import (
|
// JSON Feed specs:
|
||||||
"log/slog"
|
// https://www.jsonfeed.org/version/1.1/
|
||||||
"strings"
|
// https://www.jsonfeed.org/version/1/
|
||||||
"time"
|
type JSONFeed struct {
|
||||||
|
// Version is the URL of the version of the format the feed uses.
|
||||||
|
// This should appear at the very top, though we recognize that not all JSON generators allow for ordering.
|
||||||
|
Version string `json:"version"`
|
||||||
|
|
||||||
"miniflux.app/v2/internal/crypto"
|
// Title is the name of the feed, which will often correspond to the name of the website.
|
||||||
"miniflux.app/v2/internal/model"
|
Title string `json:"title"`
|
||||||
"miniflux.app/v2/internal/reader/date"
|
|
||||||
"miniflux.app/v2/internal/reader/sanitizer"
|
|
||||||
"miniflux.app/v2/internal/urllib"
|
|
||||||
)
|
|
||||||
|
|
||||||
type jsonFeed struct {
|
// HomePageURL is the URL of the resource that the feed describes.
|
||||||
Version string `json:"version"`
|
// This resource may or may not actually be a “home” page, but it should be an HTML page.
|
||||||
Title string `json:"title"`
|
HomePageURL string `json:"home_page_url"`
|
||||||
SiteURL string `json:"home_page_url"`
|
|
||||||
IconURL string `json:"icon"`
|
// FeedURL is the URL of the feed, and serves as the unique identifier for the feed.
|
||||||
FaviconURL string `json:"favicon"`
|
FeedURL string `json:"feed_url"`
|
||||||
FeedURL string `json:"feed_url"`
|
|
||||||
Authors []jsonAuthor `json:"authors"`
|
// Description provides more detail, beyond the title, on what the feed is about.
|
||||||
Author jsonAuthor `json:"author"`
|
Description string `json:"description"`
|
||||||
Items []jsonItem `json:"items"`
|
|
||||||
|
// IconURL is the URL of an image for the feed suitable to be used in a timeline, much the way an avatar might be used.
|
||||||
|
IconURL string `json:"icon"`
|
||||||
|
|
||||||
|
// FaviconURL is the URL of an image for the feed suitable to be used in a source list. It should be square and relatively small.
|
||||||
|
FaviconURL string `json:"favicon"`
|
||||||
|
|
||||||
|
// Authors specifies one or more feed authors. The author object has several members.
|
||||||
|
Authors []JSONAuthor `json:"authors"` // JSON Feed v1.1
|
||||||
|
|
||||||
|
// Author specifies the feed author. The author object has several members.
|
||||||
|
// JSON Feed v1 (deprecated)
|
||||||
|
Author JSONAuthor `json:"author"`
|
||||||
|
|
||||||
|
// Language is the primary language for the feed in the format specified in RFC 5646.
|
||||||
|
// The value is usually a 2-letter language tag from ISO 639-1, optionally followed by a region tag. (Examples: en or en-US.)
|
||||||
|
Language string `json:"language"`
|
||||||
|
|
||||||
|
// Expired is a boolean value that specifies whether or not the feed is finished.
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
|
||||||
|
// Items is an array, each representing an individual item in the feed.
|
||||||
|
Items []JSONItem `json:"items"`
|
||||||
|
|
||||||
|
// Hubs describes endpoints that can be used to subscribe to real-time notifications from the publisher of this feed.
|
||||||
|
Hubs []JSONHub `json:"hubs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonAuthor struct {
|
type JSONAuthor struct {
|
||||||
|
// Author's name.
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
|
||||||
|
// Author's website URL (Blog or micro-blog).
|
||||||
|
WebsiteURL string `json:"url"`
|
||||||
|
|
||||||
|
// Author's avatar URL.
|
||||||
|
AvatarURL string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonItem struct {
|
type JSONHub struct {
|
||||||
ID string `json:"id"`
|
// Type defines the protocol used to talk with the hub: "rssCloud" or "WebSub".
|
||||||
URL string `json:"url"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
|
||||||
Summary string `json:"summary"`
|
// URL is the location of the hub.
|
||||||
Text string `json:"content_text"`
|
URL string `json:"url"`
|
||||||
HTML string `json:"content_html"`
|
|
||||||
DatePublished string `json:"date_published"`
|
|
||||||
DateModified string `json:"date_modified"`
|
|
||||||
Authors []jsonAuthor `json:"authors"`
|
|
||||||
Author jsonAuthor `json:"author"`
|
|
||||||
Attachments []jsonAttachment `json:"attachments"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonAttachment struct {
|
type JSONItem struct {
|
||||||
URL string `json:"url"`
|
// Unique identifier for the item.
|
||||||
|
// Ideally, the id is the full URL of the resource described by the item, since URLs make great unique identifiers.
|
||||||
|
ID string `json:"id"`
|
||||||
|
|
||||||
|
// URL of the resource described by the item.
|
||||||
|
URL string `json:"url"`
|
||||||
|
|
||||||
|
// ExternalURL is the URL of a page elsewhere.
|
||||||
|
// This is especially useful for linkblogs.
|
||||||
|
// If url links to where you’re talking about a thing, then external_url links to the thing you’re talking about.
|
||||||
|
ExternalURL string `json:"external_url"`
|
||||||
|
|
||||||
|
// Title of the item (optional).
|
||||||
|
// Microblog items in particular may omit titles.
|
||||||
|
Title string `json:"title"`
|
||||||
|
|
||||||
|
// ContentHTML is the HTML body of the item.
|
||||||
|
ContentHTML string `json:"content_html"`
|
||||||
|
|
||||||
|
// ContentText is the text body of the item.
|
||||||
|
ContentText string `json:"content_text"`
|
||||||
|
|
||||||
|
// Summary is a plain text sentence or two describing the item.
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
|
||||||
|
// ImageURL is the URL of the main image for the item.
|
||||||
|
ImageURL string `json:"image"`
|
||||||
|
|
||||||
|
// BannerImageURL is the URL of an image to use as a banner.
|
||||||
|
BannerImageURL string `json:"banner_image"`
|
||||||
|
|
||||||
|
// DatePublished is the date the item was published.
|
||||||
|
DatePublished string `json:"date_published"`
|
||||||
|
|
||||||
|
// DateModified is the date the item was modified.
|
||||||
|
DateModified string `json:"date_modified"`
|
||||||
|
|
||||||
|
// Language is the language of the item.
|
||||||
|
Language string `json:"language"`
|
||||||
|
|
||||||
|
// Authors is an array of JSONAuthor.
|
||||||
|
Authors []JSONAuthor `json:"authors"`
|
||||||
|
|
||||||
|
// Author is a JSONAuthor.
|
||||||
|
// JSON Feed v1 (deprecated)
|
||||||
|
Author JSONAuthor `json:"author"`
|
||||||
|
|
||||||
|
// Tags is an array of strings.
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
|
||||||
|
// Attachments is an array of JSONAttachment.
|
||||||
|
Attachments []JSONAttachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JSONAttachment struct {
|
||||||
|
// URL of the attachment.
|
||||||
|
URL string `json:"url"`
|
||||||
|
|
||||||
|
// MIME type of the attachment.
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
Title string `json:"title"`
|
|
||||||
Size int64 `json:"size_in_bytes"`
|
// Title of the attachment.
|
||||||
Duration int `json:"duration_in_seconds"`
|
Title string `json:"title"`
|
||||||
}
|
|
||||||
|
// Size of the attachment in bytes.
|
||||||
func (j *jsonFeed) GetAuthor() string {
|
Size int64 `json:"size_in_bytes"`
|
||||||
if len(j.Authors) > 0 {
|
|
||||||
return (getAuthor(j.Authors[0]))
|
// Duration of the attachment in seconds.
|
||||||
}
|
Duration int `json:"duration_in_seconds"`
|
||||||
return getAuthor(j.Author)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonFeed) Transform(baseURL string) *model.Feed {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
feed := new(model.Feed)
|
|
||||||
|
|
||||||
feed.FeedURL, err = urllib.AbsoluteURL(baseURL, j.FeedURL)
|
|
||||||
if err != nil {
|
|
||||||
feed.FeedURL = j.FeedURL
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.SiteURL, err = urllib.AbsoluteURL(baseURL, j.SiteURL)
|
|
||||||
if err != nil {
|
|
||||||
feed.SiteURL = j.SiteURL
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.IconURL = strings.TrimSpace(j.IconURL)
|
|
||||||
|
|
||||||
if feed.IconURL == "" {
|
|
||||||
feed.IconURL = strings.TrimSpace(j.FaviconURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.Title = strings.TrimSpace(j.Title)
|
|
||||||
if feed.Title == "" {
|
|
||||||
feed.Title = feed.SiteURL
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range j.Items {
|
|
||||||
entry := item.Transform()
|
|
||||||
entryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL)
|
|
||||||
if err == nil {
|
|
||||||
entry.URL = entryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.Author == "" {
|
|
||||||
entry.Author = j.GetAuthor()
|
|
||||||
}
|
|
||||||
|
|
||||||
feed.Entries = append(feed.Entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return feed
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) GetDate() time.Time {
|
|
||||||
for _, value := range []string{j.DatePublished, j.DateModified} {
|
|
||||||
if value != "" {
|
|
||||||
d, err := date.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Unable to parse date from JSON feed",
|
|
||||||
slog.String("date", value),
|
|
||||||
slog.String("url", j.URL),
|
|
||||||
slog.Any("error", err),
|
|
||||||
)
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) GetAuthor() string {
|
|
||||||
if len(j.Authors) > 0 {
|
|
||||||
return getAuthor(j.Authors[0])
|
|
||||||
}
|
|
||||||
return getAuthor(j.Author)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) GetHash() string {
|
|
||||||
for _, value := range []string{j.ID, j.URL, j.Text + j.HTML + j.Summary} {
|
|
||||||
if value != "" {
|
|
||||||
return crypto.Hash(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) GetTitle() string {
|
|
||||||
if j.Title != "" {
|
|
||||||
return j.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range []string{j.Summary, j.Text, j.HTML} {
|
|
||||||
if value != "" {
|
|
||||||
return sanitizer.TruncateHTML(value, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return j.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) GetContent() string {
|
|
||||||
for _, value := range []string{j.HTML, j.Text, j.Summary} {
|
|
||||||
if value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) GetEnclosures() model.EnclosureList {
|
|
||||||
enclosures := make(model.EnclosureList, 0)
|
|
||||||
|
|
||||||
for _, attachment := range j.Attachments {
|
|
||||||
if attachment.URL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
enclosures = append(enclosures, &model.Enclosure{
|
|
||||||
URL: attachment.URL,
|
|
||||||
MimeType: attachment.MimeType,
|
|
||||||
Size: attachment.Size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return enclosures
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jsonItem) Transform() *model.Entry {
|
|
||||||
entry := model.NewEntry()
|
|
||||||
entry.URL = j.URL
|
|
||||||
entry.Date = j.GetDate()
|
|
||||||
entry.Author = j.GetAuthor()
|
|
||||||
entry.Hash = j.GetHash()
|
|
||||||
entry.Content = j.GetContent()
|
|
||||||
entry.Title = strings.TrimSpace(j.GetTitle())
|
|
||||||
entry.Enclosures = j.GetEnclosures()
|
|
||||||
if len(j.Tags) > 0 {
|
|
||||||
entry.Tags = j.Tags
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAuthor(author jsonAuthor) string {
|
|
||||||
if author.Name != "" {
|
|
||||||
return strings.TrimSpace(author.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,10 @@ import (
|
||||||
|
|
||||||
// Parse returns a normalized feed struct from a JSON feed.
|
// Parse returns a normalized feed struct from a JSON feed.
|
||||||
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
|
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
|
||||||
feed := new(jsonFeed)
|
jsonFeed := new(JSONFeed)
|
||||||
if err := json.NewDecoder(data).Decode(&feed); err != nil {
|
if err := json.NewDecoder(data).Decode(&jsonFeed); err != nil {
|
||||||
return nil, fmt.Errorf("json: unable to parse feed: %w", err)
|
return nil, fmt.Errorf("json: unable to parse feed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return feed.Transform(baseURL), nil
|
return NewJSONAdapter(jsonFeed).BuildFeed(baseURL), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseJsonFeed(t *testing.T) {
|
func TestParseJsonFeedVersion1(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -49,7 +49,7 @@ func TestParseJsonFeed(t *testing.T) {
|
||||||
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
|
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.IconURL != "https://micro.blog/jsonfeed/avatar.jpg" {
|
if feed.IconURL != "https://micro.blog/jsonfeed/favicon.png" {
|
||||||
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
|
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,7 +177,157 @@ func TestParsePodcast(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEntryWithoutAttachmentURL(t *testing.T) {
|
func TestParseFeedWithFeedURLWithTrailingSpace(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "My Example Feed",
|
||||||
|
"home_page_url": "https://example.org/",
|
||||||
|
"feed_url": "https://example.org/feed.json ",
|
||||||
|
"items": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.FeedURL != "https://example.org/feed.json" {
|
||||||
|
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithRelativeFeedURL(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "My Example Feed",
|
||||||
|
"home_page_url": "https://example.org/",
|
||||||
|
"feed_url": "/feed.json",
|
||||||
|
"items": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.FeedURL != "https://example.org/feed.json" {
|
||||||
|
t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedSiteURLWithTrailingSpace(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "My Example Feed",
|
||||||
|
"home_page_url": "https://example.org/ ",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"items": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.SiteURL != "https://example.org/" {
|
||||||
|
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithRelativeSiteURL(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "My Example Feed",
|
||||||
|
"home_page_url": "/home ",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"items": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.SiteURL != "https://example.org/home" {
|
||||||
|
t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithoutTitle(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"home_page_url": "https://example.org/",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2347259",
|
||||||
|
"url": "https://example.org/2347259",
|
||||||
|
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
||||||
|
"date_published": "2016-02-09T14:22:00-07:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Title != "https://example.org/" {
|
||||||
|
t.Errorf("Incorrect title, got: %s", feed.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithoutHomePage(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"title": "Some test",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2347259",
|
||||||
|
"url": "https://example.org/2347259",
|
||||||
|
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
||||||
|
"date_published": "2016-02-09T14:22:00-07:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.SiteURL != "https://example.org/feed.json" {
|
||||||
|
t.Errorf("Incorrect title, got: %s", feed.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithoutFeedURL(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "Some test",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2347259",
|
||||||
|
"url": "https://example.org/2347259",
|
||||||
|
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
||||||
|
"date_published": "2016-02-09T14:22:00-07:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.SiteURL != "https://example.org/feed.json" {
|
||||||
|
t.Errorf("Incorrect title, got: %s", feed.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseItemWithoutAttachmentURL(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"user_comment": "This is a podcast feed. You can add this feed to your podcast client using the following URL: http://therecord.co/feed.json",
|
"user_comment": "This is a podcast feed. You can add this feed to your podcast client using the following URL: http://therecord.co/feed.json",
|
||||||
|
@ -216,7 +366,7 @@ func TestParseEntryWithoutAttachmentURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedWithRelativeURL(t *testing.T) {
|
func TestParseItemWithRelativeURL(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "Example",
|
"title": "Example",
|
||||||
|
@ -241,7 +391,7 @@ func TestParseFeedWithRelativeURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAuthor(t *testing.T) {
|
func TestParseItemWithLegacyAuthorField(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
|
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
|
||||||
|
@ -277,7 +427,7 @@ func TestParseAuthor(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAuthors(t *testing.T) {
|
func TestParseItemWithMultipleAuthorFields(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1.1",
|
"version": "https://jsonfeed.org/version/1.1",
|
||||||
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
|
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
|
||||||
|
@ -285,7 +435,7 @@ func TestParseAuthors(t *testing.T) {
|
||||||
"home_page_url": "https://example.org/",
|
"home_page_url": "https://example.org/",
|
||||||
"feed_url": "https://example.org/feed.json",
|
"feed_url": "https://example.org/feed.json",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "This field is deprecated, use authors",
|
"name": "Deprecated Author Field",
|
||||||
"url": "http://example.org/",
|
"url": "http://example.org/",
|
||||||
"avatar": "https://example.org/avatar.png"
|
"avatar": "https://example.org/avatar.png"
|
||||||
},
|
},
|
||||||
|
@ -315,14 +465,15 @@ func TestParseAuthors(t *testing.T) {
|
||||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Entries[0].Author != "Brent Simmons" {
|
if feed.Entries[0].Author != "Brent Simmons, Deprecated Author Field" {
|
||||||
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
|
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedWithoutTitle(t *testing.T) {
|
func TestParseItemWithMultipleDuplicateAuthors(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1.1",
|
||||||
|
"title": "Example",
|
||||||
"home_page_url": "https://example.org/",
|
"home_page_url": "https://example.org/",
|
||||||
"feed_url": "https://example.org/feed.json",
|
"feed_url": "https://example.org/feed.json",
|
||||||
"items": [
|
"items": [
|
||||||
|
@ -330,7 +481,24 @@ func TestParseFeedWithoutTitle(t *testing.T) {
|
||||||
"id": "2347259",
|
"id": "2347259",
|
||||||
"url": "https://example.org/2347259",
|
"url": "https://example.org/2347259",
|
||||||
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
||||||
"date_published": "2016-02-09T14:22:00-07:00"
|
"date_published": "2016-02-09T14:22:00-07:00",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Author B",
|
||||||
|
"url": "http://example.org/",
|
||||||
|
"avatar": "https://example.org/avatar.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Author A",
|
||||||
|
"url": "http://example.org/",
|
||||||
|
"avatar": "https://example.org/avatar.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Author B",
|
||||||
|
"url": "http://example.org/",
|
||||||
|
"avatar": "https://example.org/avatar.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
|
@ -340,12 +508,16 @@ func TestParseFeedWithoutTitle(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if feed.Title != "https://example.org/" {
|
if len(feed.Entries) != 1 {
|
||||||
t.Errorf("Incorrect title, got: %s", feed.Title)
|
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Entries[0].Author != "Author A, Author B" {
|
||||||
|
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedItemWithInvalidDate(t *testing.T) {
|
func TestParseItemWithInvalidDate(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -376,34 +548,7 @@ func TestParseFeedItemWithInvalidDate(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedItemWithoutID(t *testing.T) {
|
func TestParseItemWithoutTitleButWithURL(t *testing.T) {
|
||||||
data := `{
|
|
||||||
"version": "https://jsonfeed.org/version/1",
|
|
||||||
"title": "My Example Feed",
|
|
||||||
"home_page_url": "https://example.org/",
|
|
||||||
"feed_url": "https://example.org/feed.json",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"content_text": "Some text."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
|
|
||||||
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(feed.Entries) != 1 {
|
|
||||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
|
||||||
}
|
|
||||||
|
|
||||||
if feed.Entries[0].Hash != "13b4c5aecd1b6d749afcee968fbf9c80f1ed1bbdbe1aaf25cb34ebd01144bbe9" {
|
|
||||||
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseFeedItemWithoutTitleButWithURL(t *testing.T) {
|
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -430,7 +575,7 @@ func TestParseFeedItemWithoutTitleButWithURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedItemWithoutTitleButWithSummary(t *testing.T) {
|
func TestParseItemWithoutTitleButWithSummary(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -457,7 +602,7 @@ func TestParseFeedItemWithoutTitleButWithSummary(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedItemWithoutTitleButWithHTMLContent(t *testing.T) {
|
func TestParseItemWithoutTitleButWithHTMLContent(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -484,7 +629,7 @@ func TestParseFeedItemWithoutTitleButWithHTMLContent(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFeedItemWithoutTitleButWithTextContent(t *testing.T) {
|
func TestParseItemWithoutTitleButWithTextContent(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -515,7 +660,7 @@ func TestParseFeedItemWithoutTitleButWithTextContent(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTruncateItemTitleUnicode(t *testing.T) {
|
func TestParseItemWithTooLongUnicodeTitle(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
|
@ -573,15 +718,34 @@ func TestParseItemTitleWithXMLTags(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInvalidJSON(t *testing.T) {
|
func TestParseItemWithoutID(t *testing.T) {
|
||||||
data := `garbage`
|
data := `{
|
||||||
_, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
"version": "https://jsonfeed.org/version/1",
|
||||||
if err == nil {
|
"title": "My Example Feed",
|
||||||
t.Error("Parse should returns an error")
|
"home_page_url": "https://example.org/",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"content_text": "Some text."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries) != 1 {
|
||||||
|
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Entries[0].Hash != "13b4c5aecd1b6d749afcee968fbf9c80f1ed1bbdbe1aaf25cb34ebd01144bbe9" {
|
||||||
|
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTags(t *testing.T) {
|
func TestParseItemTags(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
|
"user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json",
|
||||||
|
@ -600,7 +764,8 @@ func TestParseTags(t *testing.T) {
|
||||||
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
"content_text": "Cats are neat. \n\nhttps://example.org/cats",
|
||||||
"date_published": "2016-02-09T14:22:00-07:00",
|
"date_published": "2016-02-09T14:22:00-07:00",
|
||||||
"tags": [
|
"tags": [
|
||||||
"tag 1",
|
" tag 1",
|
||||||
|
" ",
|
||||||
"tag 2"
|
"tag 2"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -623,11 +788,11 @@ func TestParseTags(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFavicon(t *testing.T) {
|
func TestParseFeedFavicon(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"version": "https://jsonfeed.org/version/1",
|
"version": "https://jsonfeed.org/version/1",
|
||||||
"title": "My Example Feed",
|
"title": "My Example Feed",
|
||||||
"favicon": "https://micro.blog/jsonfeed/favicon.png",
|
"favicon": "https://example.org/jsonfeed/favicon.png",
|
||||||
"home_page_url": "https://example.org/",
|
"home_page_url": "https://example.org/",
|
||||||
"feed_url": "https://example.org/feed.json",
|
"feed_url": "https://example.org/feed.json",
|
||||||
"items": [
|
"items": [
|
||||||
|
@ -648,7 +813,81 @@ func TestParseFavicon(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if feed.IconURL != "https://micro.blog/jsonfeed/favicon.png" {
|
if feed.IconURL != "https://example.org/jsonfeed/favicon.png" {
|
||||||
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
|
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseFeedIcon(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "My Example Feed",
|
||||||
|
"icon": "https://example.org/jsonfeed/icon.png",
|
||||||
|
"home_page_url": "https://example.org/",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"content_text": "This is a second item.",
|
||||||
|
"url": "https://example.org/second-item"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"content_html": "<p>Hello, world!</p>",
|
||||||
|
"url": "https://example.org/initial-post"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if feed.IconURL != "https://example.org/jsonfeed/icon.png" {
|
||||||
|
t.Errorf("Incorrect icon URL, got: %s", feed.IconURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFeedWithRelativeAttachmentURL(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"version": "https://jsonfeed.org/version/1",
|
||||||
|
"title": "My Example Feed",
|
||||||
|
"home_page_url": "https://example.org/",
|
||||||
|
"feed_url": "https://example.org/feed.json",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"content_text": "This is a second item.",
|
||||||
|
"url": "https://example.org/second-item",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"url": " /attachment.mp3 ",
|
||||||
|
"mime_type": "audio/mpeg",
|
||||||
|
"size_in_bytes": 123456
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feed.Entries[0].Enclosures) != 1 {
|
||||||
|
t.Fatalf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
|
||||||
|
}
|
||||||
|
|
||||||
|
if feed.Entries[0].Enclosures[0].URL != "https://example.org/attachment.mp3" {
|
||||||
|
t.Errorf("Incorrect enclosure URL, got: %q", feed.Entries[0].Enclosures[0].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInvalidJSON(t *testing.T) {
|
||||||
|
data := `garbage`
|
||||||
|
_, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Parse should returns an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -11,17 +11,18 @@ import (
|
||||||
|
|
||||||
var textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
var textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
||||||
|
|
||||||
// Element represents XML media elements.
|
// Specs: https://www.rssboard.org/media-rss
|
||||||
type Element struct {
|
type MediaItemElement struct {
|
||||||
MediaGroups []Group `xml:"http://search.yahoo.com/mrss/ group"`
|
MediaCategories MediaCategoryList `xml:"http://search.yahoo.com/mrss/ category"`
|
||||||
MediaContents []Content `xml:"http://search.yahoo.com/mrss/ content"`
|
MediaGroups []Group `xml:"http://search.yahoo.com/mrss/ group"`
|
||||||
MediaThumbnails []Thumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
MediaContents []Content `xml:"http://search.yahoo.com/mrss/ content"`
|
||||||
MediaDescriptions DescriptionList `xml:"http://search.yahoo.com/mrss/ description"`
|
MediaThumbnails []Thumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||||
MediaPeerLinks []PeerLink `xml:"http://search.yahoo.com/mrss/ peerLink"`
|
MediaDescriptions DescriptionList `xml:"http://search.yahoo.com/mrss/ description"`
|
||||||
|
MediaPeerLinks []PeerLink `xml:"http://search.yahoo.com/mrss/ peerLink"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllMediaThumbnails returns all thumbnail elements merged together.
|
// AllMediaThumbnails returns all thumbnail elements merged together.
|
||||||
func (e *Element) AllMediaThumbnails() []Thumbnail {
|
func (e *MediaItemElement) AllMediaThumbnails() []Thumbnail {
|
||||||
var items []Thumbnail
|
var items []Thumbnail
|
||||||
items = append(items, e.MediaThumbnails...)
|
items = append(items, e.MediaThumbnails...)
|
||||||
for _, mediaGroup := range e.MediaGroups {
|
for _, mediaGroup := range e.MediaGroups {
|
||||||
|
@ -31,7 +32,7 @@ func (e *Element) AllMediaThumbnails() []Thumbnail {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllMediaContents returns all content elements merged together.
|
// AllMediaContents returns all content elements merged together.
|
||||||
func (e *Element) AllMediaContents() []Content {
|
func (e *MediaItemElement) AllMediaContents() []Content {
|
||||||
var items []Content
|
var items []Content
|
||||||
items = append(items, e.MediaContents...)
|
items = append(items, e.MediaContents...)
|
||||||
for _, mediaGroup := range e.MediaGroups {
|
for _, mediaGroup := range e.MediaGroups {
|
||||||
|
@ -41,7 +42,7 @@ func (e *Element) AllMediaContents() []Content {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllMediaPeerLinks returns all peer link elements merged together.
|
// AllMediaPeerLinks returns all peer link elements merged together.
|
||||||
func (e *Element) AllMediaPeerLinks() []PeerLink {
|
func (e *MediaItemElement) AllMediaPeerLinks() []PeerLink {
|
||||||
var items []PeerLink
|
var items []PeerLink
|
||||||
items = append(items, e.MediaPeerLinks...)
|
items = append(items, e.MediaPeerLinks...)
|
||||||
for _, mediaGroup := range e.MediaGroups {
|
for _, mediaGroup := range e.MediaGroups {
|
||||||
|
@ -51,7 +52,7 @@ func (e *Element) AllMediaPeerLinks() []PeerLink {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FirstMediaDescription returns the first description element.
|
// FirstMediaDescription returns the first description element.
|
||||||
func (e *Element) FirstMediaDescription() string {
|
func (e *MediaItemElement) FirstMediaDescription() string {
|
||||||
description := e.MediaDescriptions.First()
|
description := e.MediaDescriptions.First()
|
||||||
if description != "" {
|
if description != "" {
|
||||||
return description
|
return description
|
||||||
|
@ -85,17 +86,17 @@ type Content struct {
|
||||||
|
|
||||||
// MimeType returns the attachment mime type.
|
// MimeType returns the attachment mime type.
|
||||||
func (mc *Content) MimeType() string {
|
func (mc *Content) MimeType() string {
|
||||||
switch {
|
if mc.Type != "" {
|
||||||
case mc.Type == "" && mc.Medium == "image":
|
|
||||||
return "image/*"
|
|
||||||
case mc.Type == "" && mc.Medium == "video":
|
|
||||||
return "video/*"
|
|
||||||
case mc.Type == "" && mc.Medium == "audio":
|
|
||||||
return "audio/*"
|
|
||||||
case mc.Type == "" && mc.Medium == "video":
|
|
||||||
return "video/*"
|
|
||||||
case mc.Type != "":
|
|
||||||
return mc.Type
|
return mc.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mc.Medium {
|
||||||
|
case "image":
|
||||||
|
return "image/*"
|
||||||
|
case "video":
|
||||||
|
return "video/*"
|
||||||
|
case "audio":
|
||||||
|
return "audio/*"
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
@ -103,9 +104,6 @@ func (mc *Content) MimeType() string {
|
||||||
|
|
||||||
// Size returns the attachment size.
|
// Size returns the attachment size.
|
||||||
func (mc *Content) Size() int64 {
|
func (mc *Content) Size() int64 {
|
||||||
if mc.FileSize == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
size, _ := strconv.ParseInt(mc.FileSize, 10, 0)
|
size, _ := strconv.ParseInt(mc.FileSize, 10, 0)
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
@ -173,3 +171,20 @@ func (dl DescriptionList) First() string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaCategoryList []MediaCategory
|
||||||
|
|
||||||
|
func (mcl MediaCategoryList) Labels() []string {
|
||||||
|
var labels []string
|
||||||
|
for _, category := range mcl {
|
||||||
|
label := strings.TrimSpace(category.Label)
|
||||||
|
if label != "" {
|
||||||
|
labels = append(labels, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaCategory struct {
|
||||||
|
Label string `xml:"label,attr"`
|
||||||
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ func (h *Handler) Export(userID int64) (string, error) {
|
||||||
Title: feed.Title,
|
Title: feed.Title,
|
||||||
FeedURL: feed.FeedURL,
|
FeedURL: feed.FeedURL,
|
||||||
SiteURL: feed.SiteURL,
|
SiteURL: feed.SiteURL,
|
||||||
|
Description: feed.Description,
|
||||||
CategoryName: feed.Category.Title,
|
CategoryName: feed.Category.Title,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -68,11 +69,12 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
feed := &model.Feed{
|
feed := &model.Feed{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Title: subscription.Title,
|
Title: subscription.Title,
|
||||||
FeedURL: subscription.FeedURL,
|
FeedURL: subscription.FeedURL,
|
||||||
SiteURL: subscription.SiteURL,
|
SiteURL: subscription.SiteURL,
|
||||||
Category: category,
|
Description: subscription.Description,
|
||||||
|
Category: category,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.store.CreateFeed(feed)
|
h.store.CreateFeed(feed)
|
||||||
|
|
|
@ -27,11 +27,12 @@ type opmlHeader struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type opmlOutline struct {
|
type opmlOutline struct {
|
||||||
Title string `xml:"title,attr,omitempty"`
|
Title string `xml:"title,attr,omitempty"`
|
||||||
Text string `xml:"text,attr"`
|
Text string `xml:"text,attr"`
|
||||||
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
||||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
Description string `xml:"description,attr,omitempty"`
|
||||||
|
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||||
|
|
|
@ -34,6 +34,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
|
||||||
Title: outline.GetTitle(),
|
Title: outline.GetTitle(),
|
||||||
FeedURL: outline.FeedURL,
|
FeedURL: outline.FeedURL,
|
||||||
SiteURL: outline.GetSiteURL(),
|
SiteURL: outline.GetSiteURL(),
|
||||||
|
Description: outline.Description,
|
||||||
CategoryName: category,
|
CategoryName: category,
|
||||||
})
|
})
|
||||||
} else if outline.Outlines.HasChildren() {
|
} else if outline.Outlines.HasChildren() {
|
||||||
|
|
|
@ -33,7 +33,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
|
||||||
`
|
`
|
||||||
|
|
||||||
var expected SubcriptionList
|
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))
|
subscriptions, err := Parse(bytes.NewBufferString(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -48,10 +48,11 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
|
||||||
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
|
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
|
||||||
for _, subscription := range groupedSubs[categoryName] {
|
for _, subscription := range groupedSubs[categoryName] {
|
||||||
category.Outlines = append(category.Outlines, opmlOutline{
|
category.Outlines = append(category.Outlines, opmlOutline{
|
||||||
Title: subscription.Title,
|
Title: subscription.Title,
|
||||||
Text: subscription.Title,
|
Text: subscription.Title,
|
||||||
FeedURL: subscription.FeedURL,
|
FeedURL: subscription.FeedURL,
|
||||||
SiteURL: subscription.SiteURL,
|
SiteURL: subscription.SiteURL,
|
||||||
|
Description: subscription.Description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,12 +9,14 @@ type Subcription struct {
|
||||||
SiteURL string
|
SiteURL string
|
||||||
FeedURL string
|
FeedURL string
|
||||||
CategoryName string
|
CategoryName string
|
||||||
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Equals compare two subscriptions.
|
// Equals compare two subscriptions.
|
||||||
func (s Subcription) Equals(subscription *Subcription) bool {
|
func (s Subcription) Equals(subscription *Subcription) bool {
|
||||||
return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
|
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.
|
// SubcriptionList is a list of subscriptions.
|
||||||
|
|
|
@ -21,12 +21,12 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetectFeedFormat tries to guess the feed format from input data.
|
// DetectFeedFormat tries to guess the feed format from input data.
|
||||||
func DetectFeedFormat(r io.ReadSeeker) string {
|
func DetectFeedFormat(r io.ReadSeeker) (string, string) {
|
||||||
data := make([]byte, 512)
|
data := make([]byte, 512)
|
||||||
r.Read(data)
|
r.Read(data)
|
||||||
|
|
||||||
if bytes.HasPrefix(bytes.TrimSpace(data), []byte("{")) {
|
if bytes.HasPrefix(bytes.TrimSpace(data), []byte("{")) {
|
||||||
return FormatJSON
|
return FormatJSON, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Seek(0, io.SeekStart)
|
r.Seek(0, io.SeekStart)
|
||||||
|
@ -41,14 +41,19 @@ func DetectFeedFormat(r io.ReadSeeker) string {
|
||||||
if element, ok := token.(xml.StartElement); ok {
|
if element, ok := token.(xml.StartElement); ok {
|
||||||
switch element.Name.Local {
|
switch element.Name.Local {
|
||||||
case "rss":
|
case "rss":
|
||||||
return FormatRSS
|
return FormatRSS, ""
|
||||||
case "feed":
|
case "feed":
|
||||||
return FormatAtom
|
for _, attr := range element.Attr {
|
||||||
|
if attr.Name.Local == "version" && attr.Value == "0.3" {
|
||||||
|
return FormatAtom, "0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FormatAtom, "1.0"
|
||||||
case "RDF":
|
case "RDF":
|
||||||
return FormatRDF
|
return FormatRDF, ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FormatUnknown
|
return FormatUnknown, ""
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
func TestDetectRDF(t *testing.T) {
|
func TestDetectRDF(t *testing.T) {
|
||||||
data := `<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://my.netscape.com/rdf/simple/0.9/"></rdf:RDF>`
|
data := `<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://my.netscape.com/rdf/simple/0.9/"></rdf:RDF>`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatRDF {
|
if format != FormatRDF {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRDF)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRDF)
|
||||||
|
@ -19,7 +19,7 @@ func TestDetectRDF(t *testing.T) {
|
||||||
|
|
||||||
func TestDetectRSS(t *testing.T) {
|
func TestDetectRSS(t *testing.T) {
|
||||||
data := `<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`
|
data := `<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatRSS {
|
if format != FormatRSS {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRSS)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatRSS)
|
||||||
|
@ -28,7 +28,7 @@ func TestDetectRSS(t *testing.T) {
|
||||||
|
|
||||||
func TestDetectAtom10(t *testing.T) {
|
func TestDetectAtom10(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
|
data := `<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatAtom {
|
if format != FormatAtom {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
|
||||||
|
@ -37,7 +37,7 @@ func TestDetectAtom10(t *testing.T) {
|
||||||
|
|
||||||
func TestDetectAtom03(t *testing.T) {
|
func TestDetectAtom03(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="utf-8"?><feed version="0.3" xmlns="http://purl.org/atom/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xml:lang="en"></feed>`
|
data := `<?xml version="1.0" encoding="utf-8"?><feed version="0.3" xmlns="http://purl.org/atom/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xml:lang="en"></feed>`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatAtom {
|
if format != FormatAtom {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
|
||||||
|
@ -46,7 +46,7 @@ func TestDetectAtom03(t *testing.T) {
|
||||||
|
|
||||||
func TestDetectAtomWithISOCharset(t *testing.T) {
|
func TestDetectAtomWithISOCharset(t *testing.T) {
|
||||||
data := `<?xml version="1.0" encoding="ISO-8859-15"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
|
data := `<?xml version="1.0" encoding="ISO-8859-15"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatAtom {
|
if format != FormatAtom {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatAtom)
|
||||||
|
@ -60,7 +60,7 @@ func TestDetectJSON(t *testing.T) {
|
||||||
"title" : "Example"
|
"title" : "Example"
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatJSON {
|
if format != FormatJSON {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatJSON)
|
||||||
|
@ -71,7 +71,7 @@ func TestDetectUnknown(t *testing.T) {
|
||||||
data := `
|
data := `
|
||||||
<!DOCTYPE html> <html> </html>
|
<!DOCTYPE html> <html> </html>
|
||||||
`
|
`
|
||||||
format := DetectFeedFormat(strings.NewReader(data))
|
format, _ := DetectFeedFormat(strings.NewReader(data))
|
||||||
|
|
||||||
if format != FormatUnknown {
|
if format != FormatUnknown {
|
||||||
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatUnknown)
|
t.Errorf(`Wrong format detected: %q instead of %q`, format, FormatUnknown)
|
||||||
|
|
|
@ -19,10 +19,11 @@ var ErrFeedFormatNotDetected = errors.New("parser: unable to detect feed format"
|
||||||
// ParseFeed analyzes the input data and returns a normalized feed object.
|
// ParseFeed analyzes the input data and returns a normalized feed object.
|
||||||
func ParseFeed(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
|
func ParseFeed(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
|
||||||
r.Seek(0, io.SeekStart)
|
r.Seek(0, io.SeekStart)
|
||||||
switch DetectFeedFormat(r) {
|
format, version := DetectFeedFormat(r)
|
||||||
|
switch format {
|
||||||
case FormatAtom:
|
case FormatAtom:
|
||||||
r.Seek(0, io.SeekStart)
|
r.Seek(0, io.SeekStart)
|
||||||
return atom.Parse(baseURL, r)
|
return atom.Parse(baseURL, r, version)
|
||||||
case FormatRSS:
|
case FormatRSS:
|
||||||
r.Seek(0, io.SeekStart)
|
r.Seek(0, io.SeekStart)
|
||||||
return rss.Parse(baseURL, r)
|
return rss.Parse(baseURL, r)
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue